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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user