feat(FN-2482): add severity filtering for dashboard logs

- Add severity filter controls to the dashboard log viewer and apply filtering to rendered entries
- Extend dashboard-tui log streaming command to support severity filtering in output
- Update and stabilize dashboard and settings modal tests covering filter behavior and loading readiness
- Document the severity filter usage in CLI reference and add a changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-24 13:29:48 -07:00
committed by gsxdsm
parent 4335c462a6
commit 76961d41d3
8 changed files with 493 additions and 213 deletions

View File

@@ -652,6 +652,8 @@ function simulateKeypress(tui: DashboardTUI, key: string): void {
(tui as any).handleLogsKeypress(key);
}
const stripAnsi = (output: string): string => output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
describe("DashboardTUI Logs Selection", () => {
let tui: DashboardTUI & {
_stdout: string[];
@@ -739,6 +741,110 @@ describe("DashboardTUI Logs Selection", () => {
});
});
describe("DashboardTUI Logs severity filter", () => {
let tui: DashboardTUI & {
_stdout: string[];
_setTerminalSize: (cols: number, rows: number) => void;
};
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tui = createTestTUI();
(tui as any).activeSection = "logs";
(tui as any).isRunning = true;
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});
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 stripAnsi(stdoutWriteSpy.mock.calls.map(([chunk]) => String(chunk)).join(""));
}
it("cycles severity filter with f/F in all → info → warn → error → all order", () => {
expect((tui as any).logsSeverityFilter).toBe("all");
simulateKeypress(tui, "f");
expect((tui as any).logsSeverityFilter).toBe("info");
simulateKeypress(tui, "f");
expect((tui as any).logsSeverityFilter).toBe("warn");
simulateKeypress(tui, "f");
expect((tui as any).logsSeverityFilter).toBe("error");
simulateKeypress(tui, "F");
expect((tui as any).logsSeverityFilter).toBe("all");
});
it("renders only entries that match the active severity filter", () => {
tui.log("info-entry");
tui.warn("warn-entry");
tui.error("error-entry");
simulateKeypress(tui, "f"); // info
let rendered = renderLogs();
expect(rendered).toContain("info-entry");
expect(rendered).not.toContain("warn-entry");
expect(rendered).not.toContain("error-entry");
simulateKeypress(tui, "f"); // warn
rendered = renderLogs();
expect(rendered).toContain("warn-entry");
expect(rendered).not.toContain("info-entry");
expect(rendered).not.toContain("error-entry");
simulateKeypress(tui, "f"); // error
rendered = renderLogs();
expect(rendered).toContain("error-entry");
expect(rendered).not.toContain("info-entry");
expect(rendered).not.toContain("warn-entry");
});
it("applies Home/End and arrow bounds to filtered entries", () => {
tui.warn("warn-1");
tui.log("info-1");
tui.error("error-1");
tui.warn("warn-2");
tui.log("info-2");
simulateKeypress(tui, "f"); // info
simulateKeypress(tui, "f"); // warn
expect((tui as any).logsSeverityFilter).toBe("warn");
simulateKeypress(tui, "End");
expect((tui as any).selectedLogIndex).toBe(1);
simulateKeypress(tui, "\x1b[B");
expect((tui as any).selectedLogIndex).toBe(1);
simulateKeypress(tui, "Home");
expect((tui as any).selectedLogIndex).toBe(0);
simulateKeypress(tui, "\x1b[A");
expect((tui as any).selectedLogIndex).toBe(0);
});
it("shows a filter-specific empty message when no entries match", () => {
tui.log("only-info");
simulateKeypress(tui, "f"); // info
simulateKeypress(tui, "f"); // warn (zero matches)
const rendered = renderLogs();
expect(rendered).toContain("No log entries match filter WARN.");
expect(rendered).toContain("Press [f] to cycle severity filter.");
expect(rendered).not.toContain("No log entries yet.");
});
});
describe("DashboardTUI Logs viewport window", () => {
let tui: DashboardTUI & {
_stdout: string[];
@@ -809,8 +915,6 @@ describe("DashboardTUI Logs footer-safe row budgeting", () => {
};
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
const stripAnsi = (output: string): string => output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
beforeEach(() => {
tui = createTestTUI();
(tui as any).activeSection = "logs";

View File

@@ -25,6 +25,8 @@ export interface LogEntry {
export type SectionId = "logs" | "system" | "utilities" | "stats" | "settings";
type LogsSeverityFilter = "all" | LogEntry["level"];
export interface SystemInfo {
host: string;
port: number;
@@ -279,6 +281,7 @@ export class DashboardTUI {
private logsViewportStart = 0;
private logsWrapEnabled = false;
private logsExpandedMode = false;
private logsSeverityFilter: LogsSeverityFilter = "all";
constructor() {
this.logBuffer = new LogRingBuffer();
@@ -315,11 +318,7 @@ export class DashboardTUI {
...entry,
timestamp: new Date(),
});
// Clamp selection index if it exceeds new length
const newLength = this.logBuffer.getAll().length;
if (this.selectedLogIndex >= newLength) {
this.selectedLogIndex = Math.max(0, newLength - 1);
}
this.clampSelectedLogIndex(this.getFilteredLogEntries());
this.render();
}
@@ -543,7 +542,7 @@ export class DashboardTUI {
}
private handleLogsKeypress(key: string): void {
const entries = this.logBuffer.getAll();
const entries = this.getFilteredLogEntries();
const maxIndex = Math.max(0, entries.length - 1);
// Esc: close expanded mode (also closes help overlay)
@@ -577,6 +576,15 @@ export class DashboardTUI {
return;
}
// f: cycle severity filter (all -> info -> warn -> error -> all)
if (key === "f" || key === "F") {
this.cycleLogsSeverityFilter();
this.clampSelectedLogIndex(this.getFilteredLogEntries());
this.logsViewportStart = 0;
this.render();
return;
}
// Arrow up or k: move selection to older entry
if (key === "\x1b[A" || key === "k" || key === "K") {
if (entries.length === 0) return;
@@ -631,6 +639,37 @@ export class DashboardTUI {
}
}
private getFilteredLogEntries(): LogEntry[] {
const entries = this.logBuffer.getAll();
if (this.logsSeverityFilter === "all") {
return entries;
}
return entries.filter((entry) => entry.level === this.logsSeverityFilter);
}
private clampSelectedLogIndex(entries: LogEntry[]): void {
if (entries.length === 0) {
this.selectedLogIndex = 0;
this.logsExpandedMode = false;
return;
}
if (this.selectedLogIndex >= entries.length) {
this.selectedLogIndex = entries.length - 1;
}
if (this.selectedLogIndex < 0) {
this.selectedLogIndex = 0;
}
}
private cycleLogsSeverityFilter(): void {
const order: LogsSeverityFilter[] = ["all", "info", "warn", "error"];
const currentIndex = order.indexOf(this.logsSeverityFilter);
this.logsSeverityFilter = order[(currentIndex + 1) % order.length];
}
private async handleUtilityKeypress(key: string): Promise<void> {
if (!this.callbacks) return;
@@ -853,17 +892,25 @@ export class DashboardTUI {
private renderLogsSection(): void {
const cols = process.stdout.columns || 80;
const rows = process.stdout.rows ?? 38;
const entries = this.logBuffer.getAll();
const allEntries = this.logBuffer.getAll();
const entries = this.getFilteredLogEntries();
const rowBudget = this.getLogsListRowBudget(rows);
process.stdout.write(colorize("\n LOGS\n", "bold"));
process.stdout.write(colorize(` Ring buffer: ${this.logBuffer.total}/${MAX_LOG_ENTRIES} entries\n`, "dim"));
if (entries.length === 0) {
if (allEntries.length === 0) {
process.stdout.write(colorize(" No log entries yet.\n", "dim"));
return;
}
if (entries.length === 0) {
const filterLabel = this.logsSeverityFilter.toUpperCase();
process.stdout.write(colorize(` No log entries match filter ${filterLabel}.\n`, "dim"));
process.stdout.write(colorize(" Press [f] to cycle severity filter.\n", "dim"));
return;
}
// Clamp selection index to valid range
const safeSelectedIndex = Math.min(this.selectedLogIndex, Math.max(0, entries.length - 1));
if (safeSelectedIndex !== this.selectedLogIndex) {
@@ -877,9 +924,10 @@ export class DashboardTUI {
}
// Normal list mode
// Show mode indicator (only in list mode)
const modeIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
process.stdout.write(modeIndicator + "\n\n");
// Show mode indicators (only in list mode)
const wrapIndicator = this.logsWrapEnabled ? colorize(" [w] wrap on", "dim") : colorize(" [w] wrap off", "dim");
const filterIndicator = colorize(` [f] filter ${this.logsSeverityFilter}`, "dim");
process.stdout.write(`${wrapIndicator}${filterIndicator}\n\n`);
if (rowBudget === 0) {
process.stdout.write(colorize(" Terminal too short — expand terminal to view logs.\n", "dim"));
@@ -1288,6 +1336,7 @@ export class DashboardTUI {
boxRow(" [Home/End] First/last log entry (Logs)"),
boxRow(" [Enter/Space/e] Expand log (Logs)"),
boxRow(" [w] Toggle word wrap (Logs)"),
boxRow(" [f] Cycle severity filter (Logs)"),
boxRow(" [?] / [h] Toggle help"),
boxRow(" [q] Quit"),
boxRow(" [Ctrl+C] Force quit"),
@@ -1300,6 +1349,7 @@ export class DashboardTUI {
" [1-5] Switch tab | [n/p] Next/Prev | [q] Quit",
" [↑↓/k/j] Navigate logs | [Home/End] First/Last (Logs)",
" [Enter/Space/e] Expand log | [w] Toggle wrap (Logs)",
" [f] Cycle severity filter (Logs)",
" [r] Refresh | [c] Clear logs | [t] Toggle engine",
" [?/h] Help | [Ctrl+C] Force quit",
];