fix(FN-2312): stabilize dashboard TUI log navigation and merge log streaming

- Keep the Logs tab viewport anchored to selection so users can navigate the full in-memory ring buffer with arrow keys and Home/End
- Route streamed merge agent text through the dashboard log sink via a buffered line assembler to avoid raw fragment writes in TTY mode
- Expand dashboard and dashboard-tui command tests to cover viewport behavior, expanded log interactions, and streamed merge log handling
- Update CLI reference docs and add a @runfusion/fusion patch changeset describing the TUI log behavior fixes
This commit is contained in:
Fusion
2026-04-23 11:32:26 -07:00
committed by gsxdsm
parent e77dfffc9b
commit df2836c01d
6 changed files with 327 additions and 33 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix dashboard TUI log behavior so log navigation can reach all entries still present in the ring buffer and streamed merge output is buffered into log lines instead of writing raw fragments into the interactive terminal UI.

View File

@@ -105,11 +105,19 @@ immediately see host/port and access-token details.
| `Esc` | Close expanded view | | `Esc` | Close expanded view |
| `w` | Toggle wrap mode (long messages wrap vs. truncate) | | `w` | Toggle wrap mode (long messages wrap vs. truncate) |
The Logs list uses a scrollable viewport across the full in-memory ring buffer.
As long as an entry is still inside the buffer, you can reach it with
`↑`/`↓` (or `k`/`j`) and jump to absolute bounds with `Home`/`End`.
In wrapped mode, long log messages are displayed with word wrapping. Long In wrapped mode, long log messages are displayed with word wrapping. Long
unbroken tokens (such as URLs or stack traces) are hard-wrapped at the unbroken tokens (such as URLs or stack traces) are hard-wrapped at the
available width. In expanded view, the full message is shown with complete available width. In expanded view, the full message is shown with complete
wrapping for inspection. wrapping for inspection.
During interactive TTY mode, streamed runtime text (including merge-session
agent output) is routed into the Logs tab as stable line entries instead of
being written directly into the alternate-screen terminal surface.
In non-TTY mode (CI, piped output, scripts), the dashboard falls back to In non-TTY mode (CI, piped output, scripts), the dashboard falls back to
plain console output to maintain compatibility with automated workflows. plain console output to maintain compatibility with automated workflows.

View File

@@ -504,6 +504,68 @@ describe("DashboardTUI Logs Selection", () => {
}); });
}); });
describe("DashboardTUI Logs viewport window", () => {
let tui: DashboardTUI & {
_stdout: string[];
_setTerminalSize: (cols: number, rows: number) => void;
};
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tui = createTestTUI();
tui._setTerminalSize(80, 14); // maxRows = 5 in logs list
(tui as any).activeSection = "logs";
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
(tui as any).isRunning = true;
for (let i = 1; i <= 12; i++) {
tui.log(`Entry ${i}`);
}
});
afterEach(() => {
stdoutWriteSpy.mockRestore();
Object.defineProperty(process.stdout, "columns", { value: 80, writable: true });
Object.defineProperty(process.stdout, "rows", { value: 24, writable: true });
});
function renderLogs(): string {
stdoutWriteSpy.mockClear();
(tui as any).renderLogsSection();
return stdoutWriteSpy.mock.calls.map(([chunk]) => String(chunk)).join("");
}
it("keeps the selected older entry visible when navigating beyond newest screenful", () => {
// Jump to newest entry (index 11), then move to older entries.
simulateKeypress(tui, "End");
for (let i = 0; i < 7; i++) {
simulateKeypress(tui, "\x1b[A");
}
expect((tui as any).selectedLogIndex).toBe(4);
const output = renderLogs();
expect(output).toContain("Entry 5");
expect(output).toContain("Entry 9");
expect(output).not.toContain("Entry 12");
expect((tui as any).logsViewportStart).toBe(4);
});
it("Home and End reach full ring buffer bounds from the logs tab", () => {
simulateKeypress(tui, "Home");
expect((tui as any).selectedLogIndex).toBe(0);
let output = renderLogs();
expect(output).toContain("Entry 1");
simulateKeypress(tui, "End");
expect((tui as any).selectedLogIndex).toBe(11);
output = renderLogs();
expect(output).toContain("Entry 12");
expect((tui as any).logsViewportStart).toBe(7);
});
});
describe("DashboardTUI Logs Expanded Mode", () => { describe("DashboardTUI Logs Expanded Mode", () => {
let tui: DashboardTUI & { let tui: DashboardTUI & {
_stdout: string[]; _stdout: string[];

View File

@@ -276,6 +276,7 @@ export class DashboardTUI {
// Logs interaction state // Logs interaction state
private selectedLogIndex = 0; private selectedLogIndex = 0;
private logsViewportStart = 0;
private logsWrapEnabled = false; private logsWrapEnabled = false;
private logsExpandedMode = false; private logsExpandedMode = false;
@@ -328,6 +329,7 @@ export class DashboardTUI {
clearLogs(): void { clearLogs(): void {
this.logBuffer.clear(); this.logBuffer.clear();
this.selectedLogIndex = 0; this.selectedLogIndex = 0;
this.logsViewportStart = 0;
this.logsExpandedMode = false; this.logsExpandedMode = false;
} }
@@ -777,6 +779,25 @@ export class DashboardTUI {
} }
} }
private getLogViewportStart(totalEntries: number, maxRows: number): number {
if (totalEntries <= 0) {
this.logsViewportStart = 0;
return 0;
}
const maxStart = Math.max(0, totalEntries - maxRows);
let start = Math.min(this.logsViewportStart, maxStart);
if (this.selectedLogIndex < start) {
start = this.selectedLogIndex;
} else if (this.selectedLogIndex >= start + maxRows) {
start = this.selectedLogIndex - maxRows + 1;
}
this.logsViewportStart = Math.max(0, Math.min(start, maxStart));
return this.logsViewportStart;
}
private renderLogsSection(): void { private renderLogsSection(): void {
const cols = process.stdout.columns || 80; const cols = process.stdout.columns || 80;
const entries = this.logBuffer.getAll(); const entries = this.logBuffer.getAll();
@@ -795,6 +816,9 @@ export class DashboardTUI {
// Clamp selection index to valid range // Clamp selection index to valid range
const safeSelectedIndex = Math.min(this.selectedLogIndex, Math.max(0, entries.length - 1)); const safeSelectedIndex = Math.min(this.selectedLogIndex, Math.max(0, entries.length - 1));
if (safeSelectedIndex !== this.selectedLogIndex) {
this.selectedLogIndex = safeSelectedIndex;
}
// If expanded mode is on, render the detail pane // If expanded mode is on, render the detail pane
if (this.logsExpandedMode) { if (this.logsExpandedMode) {
@@ -807,14 +831,14 @@ export class DashboardTUI {
const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim"); const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
process.stdout.write(modeIndicator + "\n\n"); process.stdout.write(modeIndicator + "\n\n");
// Calculate which entries are visible (last maxRows entries, reversed for display) // Calculate viewport window from selection, so every ring-buffer entry remains reachable.
const startIndex = Math.max(0, entries.length - maxRows); const startIndex = this.getLogViewportStart(entries.length, maxRows);
const visibleEntries = entries.slice(startIndex); const visibleEntries = entries.slice(startIndex, startIndex + maxRows);
const visibleReversed = [...visibleEntries].reverse(); const visibleReversed = [...visibleEntries].reverse();
// Map selected index to display index (for highlighting) // Map selected index to display index (for highlighting in newest-first list)
const selectedDisplayIndex = safeSelectedIndex >= startIndex const selectedDisplayIndex = safeSelectedIndex >= startIndex && safeSelectedIndex < startIndex + visibleEntries.length
? safeSelectedIndex - startIndex ? visibleEntries.length - 1 - (safeSelectedIndex - startIndex)
: -1; : -1;
// In wrap mode, calculate available width for message body // In wrap mode, calculate available width for message body

View File

@@ -667,7 +667,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
// ── Import module under test (after mocks) ────────────────────────── // ── Import module under test (after mocks) ──────────────────────────
const { runDashboard: runDashboardImpl } = await import("./dashboard.js"); const { runDashboard: runDashboardImpl, StreamedLogBuffer } = await import("./dashboard.js");
const { processPullRequestMergeTask, getMergeStrategy, getTaskBranchName } = await import("./task-lifecycle.js"); const { processPullRequestMergeTask, getMergeStrategy, getTaskBranchName } = await import("./task-lifecycle.js");
const dashboardDisposables: Array<() => void> = []; const dashboardDisposables: Array<() => void> = [];
@@ -1574,6 +1574,7 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
it("prints a warning when port fallback occurs", async () => { it("prints a warning when port fallback occurs", async () => {
const fallbackPort = 12345; const fallbackPort = 12345;
const serverEmitter = new EventEmitter(); const serverEmitter = new EventEmitter();
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const mockServerListen = vi.fn((_port?: number) => { const mockServerListen = vi.fn((_port?: number) => {
process.nextTick(() => serverEmitter.emit("listening")); process.nextTick(() => serverEmitter.emit("listening"));
@@ -1601,9 +1602,10 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
// Should print warning with both the requested and actual ports // Should print warning with both the requested and actual ports
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleWarnSpy).toHaveBeenCalledWith(
` Port 4040 in use, using ${fallbackPort} instead`, `[dashboard] Port 4040 in use, using ${fallbackPort} instead`,
); );
consoleWarnSpy.mockRestore();
}); });
}); });
@@ -2498,3 +2500,118 @@ describe("promptForPort", () => {
removeListenerSpy.mockRestore(); removeListenerSpy.mockRestore();
}); });
}); });
describe("StreamedLogBuffer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("coalesces partial chunks and flushes after idle timeout", () => {
vi.useFakeTimers();
const lines: string[] = [];
const buffer = new StreamedLogBuffer((line) => lines.push(line), 100);
buffer.push("Hel");
buffer.push("lo");
expect(lines).toEqual([]);
vi.advanceTimersByTime(100);
expect(lines).toEqual(["Hello"]);
});
it("flushes complete newline-delimited lines immediately", () => {
const lines: string[] = [];
const buffer = new StreamedLogBuffer((line) => lines.push(line), 100);
buffer.push("one\ntwo\n");
expect(lines).toEqual(["one", "two"]);
buffer.dispose();
});
});
describe("runDashboard — merge stream sink routing", () => {
it("routes streamed merge deltas through log sink without raw stdout writes", async () => {
const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core");
const { aiMergeTask } = await import("@fusion/engine");
const { createServer } = await import("@fusion/dashboard");
const { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@mariozechner/pi-coding-agent");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
(AutomationStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
}));
(AgentStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listAgents: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
}));
(PluginStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
off: vi.fn(),
}));
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
getPluginRoutes: vi.fn().mockReturnValue([]),
on: vi.fn(),
off: vi.fn(),
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]),
}));
(AuthStorage.create as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getApiKey: vi.fn().mockResolvedValue(undefined),
getAuth: vi.fn(),
setAuth: vi.fn(),
});
(DefaultPackageManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
resolve: vi.fn().mockResolvedValue({ extensions: [] }),
}));
(ModelRegistry as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
registerProvider: vi.fn(),
refresh: vi.fn(),
}));
(discoverAndLoadExtensions as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
(createExtensionRuntime as unknown as ReturnType<typeof vi.fn>).mockReturnValue({});
const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (_store: unknown, _cwd: string, _taskId: string, opts: { onAgentText?: (delta: string) => void }) => {
opts.onAgentText?.("Hel");
opts.onAgentText?.("lo");
opts.onAgentText?.("\nWorld");
opts.onAgentText?.("!\nTail");
return { merged: true };
},
);
await runDashboard(0, { open: false, dev: true });
consoleLogSpy.mockClear();
stdoutWriteSpy.mockClear();
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
await serverOpts.onMerge("FN-TEST");
expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Hello");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] World!");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Tail");
expect(consoleLogSpy).not.toHaveBeenCalledWith("[merge] H");
stdoutWriteSpy.mockRestore();
consoleLogSpy.mockRestore();
});
});

View File

@@ -26,6 +26,72 @@ let diagnosticStartTime = 0;
let diagnosticDbHealthCheck: (() => boolean) | null = null; let diagnosticDbHealthCheck: (() => boolean) | null = null;
let diagnosticStoreListenerCheck: (() => Record<string, number>) | null = null; let diagnosticStoreListenerCheck: (() => Record<string, number>) | null = null;
const STREAM_LOG_FLUSH_IDLE_MS = 100;
export class StreamedLogBuffer {
private pending = "";
private flushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly emitLine: (line: string) => void,
private readonly flushIdleMs: number = STREAM_LOG_FLUSH_IDLE_MS,
) {}
push(delta: string): void {
if (!delta) return;
this.pending += delta;
this.flushCompletedLines();
this.scheduleFlush();
}
flush(): void {
this.clearFlushTimer();
const trailing = this.pending.trim();
if (trailing.length > 0) {
this.emitLine(trailing);
}
this.pending = "";
}
dispose(): void {
this.clearFlushTimer();
this.pending = "";
}
private flushCompletedLines(): void {
if (!this.pending.includes("\n")) {
return;
}
const splitLines = this.pending.split(/\r?\n/);
const completeLines = splitLines.slice(0, -1);
this.pending = splitLines[splitLines.length - 1] ?? "";
for (const line of completeLines) {
const normalized = line.trim();
if (normalized.length > 0) {
this.emitLine(normalized);
}
}
}
private scheduleFlush(): void {
this.clearFlushTimer();
this.flushTimer = setTimeout(() => {
this.flush();
}, this.flushIdleMs);
this.flushTimer.unref?.();
}
private clearFlushTimer(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
}
/** /**
* Format bytes to human-readable string * Format bytes to human-readable string
*/ */
@@ -438,7 +504,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
try { try {
if (!store) { if (!store) {
taskSummary = "tasks=unavailable (store not initialized)"; taskSummary = "tasks=unavailable (store not initialized)";
console.log(`[dashboard] shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`); logSink.log(`shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`, "dashboard");
return; return;
} }
const tasks = await store.listTasks({ slim: true, includeArchived: false }); const tasks = await store.listTasks({ slim: true, includeArchived: false });
@@ -457,8 +523,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
taskSummary = `tasks=unavailable (${message})`; taskSummary = `tasks=unavailable (${message})`;
} }
console.log( logSink.log(
`[dashboard] shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`, `shutdown requested reason=${reason} pid=${process.pid} ppid=${process.ppid} uptime=${uptimeSeconds}s ${taskSummary}`,
"dashboard",
); );
} }
@@ -559,11 +626,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// In non-dev mode: replaced by engine.onMerge() after ProjectEngine starts // In non-dev mode: replaced by engine.onMerge() after ProjectEngine starts
// (semaphore-gated via the engine's InProcessRuntime). // (semaphore-gated via the engine's InProcessRuntime).
// //
const onMergeImpl = (taskId: string) => const onMergeImpl = async (taskId: string) => {
aiMergeTask(store, cwd, taskId, { const streamedMergeLog = new StreamedLogBuffer(
agentStore, (line) => logSink.log(line, "merge"),
onAgentText: (delta) => process.stdout.write(delta), STREAM_LOG_FLUSH_IDLE_MS,
}); );
try {
return await aiMergeTask(store, cwd, taskId, {
agentStore,
onAgentText: (delta) => streamedMergeLog.push(delta),
});
} finally {
streamedMergeLog.flush();
streamedMergeLog.dispose();
}
};
const onMerge = (taskId: string) => onMergeImpl(taskId); const onMerge = (taskId: string) => onMergeImpl(taskId);
@@ -790,7 +868,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
peerExchangeService.start(); peerExchangeService.start();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to start peer exchange service: ${message}`); logSink.warn(`Failed to start peer exchange service: ${message}`, "dashboard");
} }
// Use the same CentralCore instance for mesh operations // Use the same CentralCore instance for mesh operations
@@ -858,7 +936,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([type, count]) => `${type}:${count}`) .map(([type, count]) => `${type}:${count}`)
.join(", "); .join(", ");
console.log(`[dashboard] active handles at shutdown: ${handleSummary}`); logSink.log(`active handles at shutdown: ${handleSummary}`, "dashboard");
} catch { } catch {
// Ignore errors getting handle types // Ignore errors getting handle types
} }
@@ -876,7 +954,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
await peerExchangeService.stop(); await peerExchangeService.stop();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop peer exchange service: ${message}`); logSink.warn(`Failed to stop peer exchange service: ${message}`, "dashboard");
} }
} }
@@ -886,13 +964,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
centralCoreForMesh.stopDiscovery(); centralCoreForMesh.stopDiscovery();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop mDNS discovery: ${message}`); logSink.warn(`Failed to stop mDNS discovery: ${message}`, "dashboard");
} }
try { try {
await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" }); await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to set local node offline: ${message}`); logSink.warn(`Failed to set local node offline: ${message}`, "dashboard");
} }
} }
@@ -909,7 +987,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// the process silently — the exit handler tries to log to the now-dead // the process silently — the exit handler tries to log to the now-dead
// PTY and the write is lost. // PTY and the write is lost.
registerHandler(process, "SIGHUP", () => { registerHandler(process, "SIGHUP", () => {
console.log("[dashboard] Received SIGHUP (terminal disconnected) — ignoring"); logSink.log("Received SIGHUP (terminal disconnected) — ignoring", "dashboard");
}); });
} else { } else {
// Dev mode: create HeartbeatMonitor + TriggerScheduler inline (engine not started) // Dev mode: create HeartbeatMonitor + TriggerScheduler inline (engine not started)
@@ -927,7 +1005,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
peerExchangeService.start(); peerExchangeService.start();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to initialize mesh networking: ${message}`); logSink.warn(`Failed to initialize mesh networking: ${message}`, "dashboard");
} }
try { try {
@@ -1048,7 +1126,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([type, count]) => `${type}:${count}`) .map(([type, count]) => `${type}:${count}`)
.join(", "); .join(", ");
console.log(`[dashboard] active handles at shutdown: ${handleSummary}`); logSink.log(`active handles at shutdown: ${handleSummary}`, "dashboard");
} catch { } catch {
// Ignore errors getting handle types // Ignore errors getting handle types
} }
@@ -1065,7 +1143,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
await peerExchangeService.stop(); await peerExchangeService.stop();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop peer exchange service: ${message}`); logSink.warn(`Failed to stop peer exchange service: ${message}`, "dashboard");
} }
} }
@@ -1075,13 +1153,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
centralCoreForMesh.stopDiscovery(); centralCoreForMesh.stopDiscovery();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to stop mDNS discovery: ${message}`); logSink.warn(`Failed to stop mDNS discovery: ${message}`, "dashboard");
} }
try { try {
await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" }); await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to set local node offline: ${message}`); logSink.warn(`Failed to set local node offline: ${message}`, "dashboard");
} }
} }
@@ -1097,7 +1175,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Ignore SIGHUP so the dashboard survives SSH session disconnects // Ignore SIGHUP so the dashboard survives SSH session disconnects
registerHandler(process, "SIGHUP", () => { registerHandler(process, "SIGHUP", () => {
console.log("[dashboard] Received SIGHUP (terminal disconnected) — ignoring"); logSink.log("Received SIGHUP (terminal disconnected) — ignoring", "dashboard");
}); });
} }
@@ -1107,7 +1185,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (err.code === "EADDRINUSE") { if (err.code === "EADDRINUSE") {
server.listen(0, selectedHost); server.listen(0, selectedHost);
} else { } else {
console.error(`Failed to start server: ${err.message}`); logSink.error(`Failed to start server: ${err.message}`, "dashboard");
process.exit(1); process.exit(1);
} }
}); });
@@ -1116,7 +1194,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const actualPort = (server.address() as AddressInfo).port; const actualPort = (server.address() as AddressInfo).port;
if (actualPort !== selectedPort) { if (actualPort !== selectedPort) {
console.log(`Port ${selectedPort} in use, using ${actualPort} instead`); logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard");
} }
// ── mDNS discovery: broadcast presence and listen for other nodes ─────── // ── mDNS discovery: broadcast presence and listen for other nodes ───────
@@ -1135,7 +1213,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to start mDNS discovery: ${message}`); logSink.warn(`Failed to start mDNS discovery: ${message}`, "dashboard");
} }
} }
@@ -1153,7 +1231,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.warn(`[dashboard] Failed to set local node online: ${message}`); logSink.warn(`Failed to set local node online: ${message}`, "dashboard");
} }
} }