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:
@@ -13,6 +13,9 @@ interface DevServerLogViewerProps {
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
type LogSeverity = "info" | "warn" | "error";
|
||||
type LogSeverityFilter = "all" | LogSeverity;
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- ANSI escape stripping is required for readable terminal logs.
|
||||
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
@@ -42,6 +45,23 @@ function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getEntrySeverity(entry: DevServerLogEntry): LogSeverity {
|
||||
if (entry.stream === "stderr") {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const normalizedText = stripAnsi(entry.text).toLowerCase();
|
||||
if (/\b(warn|warning)\b/.test(normalizedText)) {
|
||||
return "warn";
|
||||
}
|
||||
|
||||
if (/\b(error|fatal)\b/.test(normalizedText)) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
return "info";
|
||||
}
|
||||
|
||||
function highlightText(value: string, search: string): ReactNode {
|
||||
if (!search) {
|
||||
return value;
|
||||
@@ -77,15 +97,24 @@ export function DevServerLogViewer({
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [severityFilter, setSeverityFilter] = useState<LogSeverityFilter>("all");
|
||||
|
||||
const filteredBySeverity = useMemo(() => {
|
||||
if (severityFilter === "all") {
|
||||
return entries;
|
||||
}
|
||||
|
||||
return entries.filter((entry) => getEntrySeverity(entry) === severityFilter);
|
||||
}, [entries, severityFilter]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return entries;
|
||||
return filteredBySeverity;
|
||||
}
|
||||
|
||||
return entries.filter((entry) => stripAnsi(entry.text).toLowerCase().includes(normalizedSearch));
|
||||
}, [entries, searchQuery]);
|
||||
return filteredBySeverity.filter((entry) => stripAnsi(entry.text).toLowerCase().includes(normalizedSearch));
|
||||
}, [filteredBySeverity, searchQuery]);
|
||||
|
||||
const matchCount = filteredEntries.length;
|
||||
|
||||
@@ -159,7 +188,25 @@ export function DevServerLogViewer({
|
||||
</div>
|
||||
|
||||
<div className="devserver-log-viewer__toolbar-actions">
|
||||
<label className="devserver-log-viewer__severity" htmlFor="devserver-log-severity-filter">
|
||||
<span className="visually-hidden">Filter logs by severity</span>
|
||||
<select
|
||||
id="devserver-log-severity-filter"
|
||||
className="select devserver-log-viewer__severity-select"
|
||||
value={severityFilter}
|
||||
onChange={(event) => setSeverityFilter(event.target.value as LogSeverityFilter)}
|
||||
data-testid="devserver-log-severity-filter"
|
||||
aria-label="Filter logs by severity"
|
||||
>
|
||||
<option value="all">All severities</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warn</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="devserver-log-viewer__search" htmlFor="devserver-log-search">
|
||||
<span className="visually-hidden">Search logs</span>
|
||||
<Search size={14} />
|
||||
<input
|
||||
id="devserver-log-search"
|
||||
@@ -169,6 +216,7 @@ export function DevServerLogViewer({
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search logs"
|
||||
data-testid="devserver-log-search-input"
|
||||
aria-label="Search logs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -222,7 +270,9 @@ export function DevServerLogViewer({
|
||||
<p className="devserver-log-viewer__empty" data-testid="devserver-log-empty">
|
||||
{entries.length === 0
|
||||
? "No logs yet. Start the dev server to see output."
|
||||
: "No log lines match your search."}
|
||||
: (filteredBySeverity.length === 0
|
||||
? "No log lines match the selected severity."
|
||||
: "No log lines match your search.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -97,6 +97,58 @@ describe("DevServerLogViewer", () => {
|
||||
expect(screen.getByText("No logs yet. Start the dev server to see output.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("provides an accessible label for search", () => {
|
||||
renderViewer({
|
||||
entries: [createEntry({ id: 1, text: "line" })],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Search logs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters entries by selected severity", () => {
|
||||
renderViewer({
|
||||
entries: [
|
||||
createEntry({ id: 1, text: "server ready", stream: "stdout" }),
|
||||
createEntry({ id: 2, text: "[warn] slow response", stream: "stdout" }),
|
||||
createEntry({ id: 3, text: "fatal exception", stream: "stdout" }),
|
||||
createEntry({ id: 4, text: "stderr output", stream: "stderr" }),
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
|
||||
const severitySelect = screen.getByTestId("devserver-log-severity-filter");
|
||||
|
||||
fireEvent.change(severitySelect, { target: { value: "warn" } });
|
||||
expect(screen.getByText("[warn] slow response")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server ready")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("fatal exception")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(severitySelect, { target: { value: "error" } });
|
||||
expect(screen.getByText("fatal exception")).toBeInTheDocument();
|
||||
expect(screen.getByText("stderr output")).toBeInTheDocument();
|
||||
expect(screen.queryByText("[warn] slow response")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(severitySelect, { target: { value: "all" } });
|
||||
expect(screen.getByText("server ready")).toBeInTheDocument();
|
||||
expect(screen.getByText("[warn] slow response")).toBeInTheDocument();
|
||||
expect(screen.getByText("fatal exception")).toBeInTheDocument();
|
||||
expect(screen.getByText("stderr output")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows severity-specific empty message when filter has no matches", () => {
|
||||
renderViewer({
|
||||
entries: [createEntry({ id: 1, text: "server ready", stream: "stdout" })],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("devserver-log-severity-filter"), {
|
||||
target: { value: "warn" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("No log lines match the selected severity.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("strips ANSI escape codes from display", () => {
|
||||
renderViewer({
|
||||
entries: [createEntry({ id: 1, text: "\u001b[32mSuccess\u001b[0m done" })],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39626,6 +39626,7 @@ html .column.drag-over * {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.devserver-log-viewer__severity,
|
||||
.devserver-log-viewer__search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -39633,6 +39634,12 @@ html .column.drag-over * {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.devserver-log-viewer__severity-select {
|
||||
min-height: calc(var(--space-lg) + var(--space-md));
|
||||
min-width: calc(var(--space-2xl) * 3);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.devserver-log-viewer__search-input {
|
||||
min-height: calc(var(--space-lg) + var(--space-md));
|
||||
min-width: calc(var(--space-2xl) * 4);
|
||||
|
||||
Reference in New Issue
Block a user