Merge Log tab into Server tab and remove Log tab

The Log tab provided a service health panel and log viewer. These are
consolidated into the Server tab with a new ServerHealthSection component
that encapsulates all log-related functionality.

- Extract service health panel and log viewer into ServerHealthSection component
- Add severity-based log line color coding (ERROR=red, WARNING=yellow, DEBUG=gray)
- Implement log filtering, line count selection, and auto-refresh controls
- Scroll to bottom when new log data arrives
- Render health metrics grid with version, jail count, bans, failures
- Show read-only log level and log target in health section
- Handle non-file targets with informational banner
- Import ServerHealthSection in ServerTab and render after map thresholds
- Remove LogTab component import from ConfigPage
- Remove 'log' from TabValue type
- Remove Log tab element from TabList
- Remove conditional render for LogTab
- Remove LogTab from barrel export (index.ts)
- Delete LogTab.tsx and LogTab.test.tsx files
- Update ConfigPage docstring

All 115 frontend tests pass (8 fewer due to deleted LogTab tests).
This commit is contained in:
2026-03-14 21:58:34 +01:00
parent 9630aea877
commit 1da38361a9
5 changed files with 17 additions and 212 deletions

View File

@@ -1,12 +1,12 @@
/**
* LogTab fail2ban log viewer and service health panel.
* ServerHealthSection service health panel and log viewer for ServerTab.
*
* Renders two sections:
* 1. **Service Health panel** shows online/offline state, version, active
* jail count, total bans, total failures, log level, and log target.
* 2. **Log viewer** displays the tail of the fail2ban daemon log file with
* toolbar controls for line count, substring filter, manual refresh, and
* optional auto-refresh. Log lines are color-coded by severity.
* optional auto-refresh. Log lines are color-coded by severity.
*/
import {
@@ -167,13 +167,11 @@ function detectSeverity(line: string): "error" | "warning" | "debug" | "default"
// ---------------------------------------------------------------------------
/**
* Log tab component for the Configuration page.
*
* Shows fail2ban service health and a live log viewer with refresh controls.
* Server health panel and log viewer section for ServerTab.
*
* @returns JSX element.
*/
export function LogTab(): React.JSX.Element {
export function ServerHealthSection(): React.JSX.Element {
const configStyles = useConfigStyles();
const styles = useStyles();
@@ -317,10 +315,8 @@ export function LogTab(): React.JSX.Element {
logData != null && logData.total_lines > logData.lines.length;
return (
<div>
{/* ------------------------------------------------------------------ */}
{/* Service Health Panel */}
{/* ------------------------------------------------------------------ */}
<>
{/* Service Health Panel */}
<div className={configStyles.sectionCard}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<DocumentBulletList24Regular />
@@ -384,9 +380,7 @@ export function LogTab(): React.JSX.Element {
)}
</div>
{/* ------------------------------------------------------------------ */}
{/* Log Viewer */}
{/* ------------------------------------------------------------------ */}
{/* Log Viewer */}
<div className={configStyles.sectionCard}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
<Text weight="semibold" size={400}>
@@ -513,6 +507,6 @@ export function LogTab(): React.JSX.Element {
</>
)}
</div>
</div>
</>
);
}

View File

@@ -2,8 +2,8 @@
* ServerTab — fail2ban server-level settings editor.
*
* Provides form fields for live server settings (log level, log target,
* DB purge age, DB max matches), a "Flush Logs" action button, and
* world map color threshold configuration.
* DB purge age, DB max matches), a "Flush Logs" action button,
* world map color threshold configuration, and service health + log viewer.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -31,6 +31,7 @@ import {
updateMapColorThresholds,
} from "../../api/config";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ServerHealthSection } from "./ServerHealthSection";
import { useConfigStyles } from "./configStyles";
/** Available fail2ban log levels in descending severity order. */
@@ -354,6 +355,9 @@ export function ServerTab(): React.JSX.Element {
</Text>
</div>
) : null}
{/* Service Health & Log Viewer section */}
<ServerHealthSection />
</div>
);
}

View File

@@ -1,189 +0,0 @@
/**
* Tests for the LogTab component (Task 2).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { LogTab } from "../LogTab";
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchFail2BanLog: vi.fn(),
fetchServiceStatus: vi.fn(),
}));
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
const mockFetchLog = vi.mocked(fetchFail2BanLog);
const mockFetchStatus = vi.mocked(fetchServiceStatus);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const onlineStatus: ServiceStatusResponse = {
online: true,
version: "1.0.2",
jail_count: 3,
total_bans: 12,
total_failures: 5,
log_level: "INFO",
log_target: "/var/log/fail2ban.log",
};
const offlineStatus: ServiceStatusResponse = {
online: false,
version: null,
jail_count: 0,
total_bans: 0,
total_failures: 0,
log_level: "UNKNOWN",
log_target: "UNKNOWN",
};
const logResponse: Fail2BanLogResponse = {
log_path: "/var/log/fail2ban.log",
lines: [
"2025-01-01 12:00:00 INFO sshd Found 1.2.3.4",
"2025-01-01 12:00:01 WARNING sshd Too many failures",
"2025-01-01 12:00:02 ERROR fail2ban something went wrong",
],
total_lines: 1000,
log_level: "INFO",
log_target: "/var/log/fail2ban.log",
};
const nonFileLogResponse: Fail2BanLogResponse = {
...logResponse,
log_target: "STDOUT",
lines: [],
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTab() {
return render(
<FluentProvider theme={webLightTheme}>
<LogTab />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("LogTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a spinner while loading", () => {
// Never resolves during this test.
mockFetchStatus.mockReturnValue(new Promise(() => undefined));
mockFetchLog.mockReturnValue(new Promise(() => undefined));
renderTab();
expect(screen.getByText(/loading log viewer/i)).toBeInTheDocument();
});
it("renders the health panel with Running badge when online", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
renderTab();
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
expect(screen.getByText("Running")).toBeInTheDocument();
expect(screen.getByText("1.0.2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument(); // active jails
expect(screen.getByText("12")).toBeInTheDocument(); // total bans
});
it("renders the Offline badge and warning when fail2ban is down", async () => {
mockFetchStatus.mockResolvedValue(offlineStatus);
mockFetchLog.mockRejectedValue(new Error("not running"));
renderTab();
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
expect(screen.getByText("Offline")).toBeInTheDocument();
expect(screen.getByText(/not running or unreachable/i)).toBeInTheDocument();
});
it("renders log lines in the log viewer", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText(/2025-01-01 12:00:00 INFO/)).toBeInTheDocument();
});
expect(screen.getByText(/2025-01-01 12:00:01 WARNING/)).toBeInTheDocument();
expect(screen.getByText(/2025-01-01 12:00:02 ERROR/)).toBeInTheDocument();
});
it("shows a non-file target info banner when log_target is STDOUT", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(nonFileLogResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText(/fail2ban is logging to/i)).toBeInTheDocument();
});
expect(screen.getByText(/STDOUT/)).toBeInTheDocument();
expect(screen.queryByText(/Refresh/)).toBeNull();
});
it("shows empty state when no lines match the filter", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue({ ...logResponse, lines: [] });
renderTab();
await waitFor(() => {
expect(screen.getByText(/no log entries found/i)).toBeInTheDocument();
});
});
it("shows truncation notice when total_lines > lines.length", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue({ ...logResponse, lines: logResponse.lines, total_lines: 1000 });
renderTab();
await waitFor(() => {
expect(screen.getByText(/showing last/i)).toBeInTheDocument();
});
});
it("calls fetchFail2BanLog again on Refresh button click", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
const user = userEvent.setup();
renderTab();
await waitFor(() => { expect(screen.getByText(/Refresh/)).toBeInTheDocument(); });
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
await user.click(refreshBtn);
await waitFor(() => { expect(mockFetchLog).toHaveBeenCalledTimes(2); });
});
});

View File

@@ -33,11 +33,11 @@ export { FiltersTab } from "./FiltersTab";
export { JailFilesTab } from "./JailFilesTab";
export { JailFileForm } from "./JailFileForm";
export { JailsTab } from "./JailsTab";
export { LogTab } from "./LogTab";
export { RawConfigSection } from "./RawConfigSection";
export type { RawConfigSectionProps } from "./RawConfigSection";
export { RegexList } from "./RegexList";
export type { RegexListProps } from "./RegexList";
export { RegexTesterTab } from "./RegexTesterTab";
export { ServerTab } from "./ServerTab";
export { ServerHealthSection } from "./ServerHealthSection";
export { useConfigStyles } from "./configStyles";

View File

@@ -8,7 +8,7 @@
* Jails — per-jail config accordion with inline editing
* Filters — structured filter.d form editor
* Actions — structured action.d form editor
* Server — server-level settings, logging, database config, map thresholds + flush logs
* Server — server-level settings, map thresholds, service health + log viewer
* Regex Tester — live pattern tester
* Export — raw file editors for jail, filter, and action files
*/
@@ -19,7 +19,6 @@ import {
ActionsTab,
FiltersTab,
JailsTab,
LogTab,
RegexTesterTab,
ServerTab,
} from "../components/config";
@@ -55,8 +54,7 @@ type TabValue =
| "filters"
| "actions"
| "server"
| "regex"
| "log";
| "regex";
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
@@ -85,7 +83,6 @@ export function ConfigPage(): React.JSX.Element {
<Tab value="actions">Actions</Tab>
<Tab value="server">Server</Tab>
<Tab value="regex">Regex Tester</Tab>
<Tab value="log">Log</Tab>
</TabList>
<div className={styles.tabContent} key={tab}>
@@ -94,7 +91,6 @@ export function ConfigPage(): React.JSX.Element {
{tab === "actions" && <ActionsTab />}
{tab === "server" && <ServerTab />}
{tab === "regex" && <RegexTesterTab />}
{tab === "log" && <LogTab />}
</div>
</div>
);