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:
@@ -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", () => {
|
||||
let tui: DashboardTUI & {
|
||||
_stdout: string[];
|
||||
|
||||
@@ -276,6 +276,7 @@ export class DashboardTUI {
|
||||
|
||||
// Logs interaction state
|
||||
private selectedLogIndex = 0;
|
||||
private logsViewportStart = 0;
|
||||
private logsWrapEnabled = false;
|
||||
private logsExpandedMode = false;
|
||||
|
||||
@@ -328,6 +329,7 @@ export class DashboardTUI {
|
||||
clearLogs(): void {
|
||||
this.logBuffer.clear();
|
||||
this.selectedLogIndex = 0;
|
||||
this.logsViewportStart = 0;
|
||||
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 {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const entries = this.logBuffer.getAll();
|
||||
@@ -795,6 +816,9 @@ export class DashboardTUI {
|
||||
|
||||
// Clamp selection index to valid range
|
||||
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 (this.logsExpandedMode) {
|
||||
@@ -807,14 +831,14 @@ export class DashboardTUI {
|
||||
const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
|
||||
process.stdout.write(modeIndicator + "\n\n");
|
||||
|
||||
// Calculate which entries are visible (last maxRows entries, reversed for display)
|
||||
const startIndex = Math.max(0, entries.length - maxRows);
|
||||
const visibleEntries = entries.slice(startIndex);
|
||||
// Calculate viewport window from selection, so every ring-buffer entry remains reachable.
|
||||
const startIndex = this.getLogViewportStart(entries.length, maxRows);
|
||||
const visibleEntries = entries.slice(startIndex, startIndex + maxRows);
|
||||
const visibleReversed = [...visibleEntries].reverse();
|
||||
|
||||
// Map selected index to display index (for highlighting)
|
||||
const selectedDisplayIndex = safeSelectedIndex >= startIndex
|
||||
? safeSelectedIndex - startIndex
|
||||
// Map selected index to display index (for highlighting in newest-first list)
|
||||
const selectedDisplayIndex = safeSelectedIndex >= startIndex && safeSelectedIndex < startIndex + visibleEntries.length
|
||||
? visibleEntries.length - 1 - (safeSelectedIndex - startIndex)
|
||||
: -1;
|
||||
|
||||
// In wrap mode, calculate available width for message body
|
||||
|
||||
@@ -667,7 +667,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
|
||||
// ── 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 dashboardDisposables: Array<() => void> = [];
|
||||
|
||||
@@ -1574,6 +1574,7 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
it("prints a warning when port fallback occurs", async () => {
|
||||
const fallbackPort = 12345;
|
||||
const serverEmitter = new EventEmitter();
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const mockServerListen = vi.fn((_port?: number) => {
|
||||
process.nextTick(() => serverEmitter.emit("listening"));
|
||||
@@ -1601,9 +1602,10 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Should print warning with both the requested and actual ports
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
`⚠ Port 4040 in use, using ${fallbackPort} instead`,
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
`[dashboard] Port 4040 in use, using ${fallbackPort} instead`,
|
||||
);
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2498,3 +2500,118 @@ describe("promptForPort", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,72 @@ let diagnosticStartTime = 0;
|
||||
let diagnosticDbHealthCheck: (() => boolean) | 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
|
||||
*/
|
||||
@@ -438,7 +504,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
try {
|
||||
if (!store) {
|
||||
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;
|
||||
}
|
||||
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})`;
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// (semaphore-gated via the engine's InProcessRuntime).
|
||||
//
|
||||
const onMergeImpl = (taskId: string) =>
|
||||
aiMergeTask(store, cwd, taskId, {
|
||||
agentStore,
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
});
|
||||
const onMergeImpl = async (taskId: string) => {
|
||||
const streamedMergeLog = new StreamedLogBuffer(
|
||||
(line) => logSink.log(line, "merge"),
|
||||
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);
|
||||
|
||||
@@ -790,7 +868,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
peerExchangeService.start();
|
||||
} catch (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
|
||||
@@ -858,7 +936,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => `${type}:${count}`)
|
||||
.join(", ");
|
||||
console.log(`[dashboard] active handles at shutdown: ${handleSummary}`);
|
||||
logSink.log(`active handles at shutdown: ${handleSummary}`, "dashboard");
|
||||
} catch {
|
||||
// Ignore errors getting handle types
|
||||
}
|
||||
@@ -876,7 +954,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
await peerExchangeService.stop();
|
||||
} catch (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();
|
||||
} catch (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 {
|
||||
await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" });
|
||||
} catch (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
|
||||
// PTY and the write is lost.
|
||||
registerHandler(process, "SIGHUP", () => {
|
||||
console.log("[dashboard] Received SIGHUP (terminal disconnected) — ignoring");
|
||||
logSink.log("Received SIGHUP (terminal disconnected) — ignoring", "dashboard");
|
||||
});
|
||||
} else {
|
||||
// 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();
|
||||
} catch (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 {
|
||||
@@ -1048,7 +1126,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => `${type}:${count}`)
|
||||
.join(", ");
|
||||
console.log(`[dashboard] active handles at shutdown: ${handleSummary}`);
|
||||
logSink.log(`active handles at shutdown: ${handleSummary}`, "dashboard");
|
||||
} catch {
|
||||
// Ignore errors getting handle types
|
||||
}
|
||||
@@ -1065,7 +1143,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
await peerExchangeService.stop();
|
||||
} catch (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();
|
||||
} catch (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 {
|
||||
await centralCoreForMesh.updateNode(localNodeIdForMesh, { status: "offline" });
|
||||
} catch (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
|
||||
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") {
|
||||
server.listen(0, selectedHost);
|
||||
} else {
|
||||
console.error(`Failed to start server: ${err.message}`);
|
||||
logSink.error(`Failed to start server: ${err.message}`, "dashboard");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -1116,7 +1194,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const actualPort = (server.address() as AddressInfo).port;
|
||||
|
||||
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 ───────
|
||||
@@ -1135,7 +1213,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
});
|
||||
} catch (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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user