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:
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user