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:
|
* Renders two sections:
|
||||||
* 1. **Service Health panel** — shows online/offline state, version, active
|
* 1. **Service Health panel** — shows online/offline state, version, active
|
||||||
* jail count, total bans, total failures, log level, and log target.
|
* jail count, total bans, total failures, log level, and log target.
|
||||||
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
|
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
|
||||||
* toolbar controls for line count, substring filter, manual refresh, and
|
* 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 {
|
import {
|
||||||
@@ -167,13 +167,11 @@ function detectSeverity(line: string): "error" | "warning" | "debug" | "default"
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log tab component for the Configuration page.
|
* Server health panel and log viewer section for ServerTab.
|
||||||
*
|
|
||||||
* Shows fail2ban service health and a live log viewer with refresh controls.
|
|
||||||
*
|
*
|
||||||
* @returns JSX element.
|
* @returns JSX element.
|
||||||
*/
|
*/
|
||||||
export function LogTab(): React.JSX.Element {
|
export function ServerHealthSection(): React.JSX.Element {
|
||||||
const configStyles = useConfigStyles();
|
const configStyles = useConfigStyles();
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
@@ -317,10 +315,8 @@ export function LogTab(): React.JSX.Element {
|
|||||||
logData != null && logData.total_lines > logData.lines.length;
|
logData != null && logData.total_lines > logData.lines.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* Service Health Panel */}
|
||||||
{/* Service Health Panel */}
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
|
||||||
<div className={configStyles.sectionCard}>
|
<div className={configStyles.sectionCard}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||||
<DocumentBulletList24Regular />
|
<DocumentBulletList24Regular />
|
||||||
@@ -384,9 +380,7 @@ export function LogTab(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* Log Viewer */}
|
||||||
{/* Log Viewer */}
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
|
||||||
<div className={configStyles.sectionCard}>
|
<div className={configStyles.sectionCard}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
||||||
<Text weight="semibold" size={400}>
|
<Text weight="semibold" size={400}>
|
||||||
@@ -513,6 +507,6 @@ export function LogTab(): React.JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
* ServerTab — fail2ban server-level settings editor.
|
* ServerTab — fail2ban server-level settings editor.
|
||||||
*
|
*
|
||||||
* Provides form fields for live server settings (log level, log target,
|
* Provides form fields for live server settings (log level, log target,
|
||||||
* DB purge age, DB max matches), a "Flush Logs" action button, and
|
* DB purge age, DB max matches), a "Flush Logs" action button,
|
||||||
* world map color threshold configuration.
|
* world map color threshold configuration, and service health + log viewer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
updateMapColorThresholds,
|
updateMapColorThresholds,
|
||||||
} from "../../api/config";
|
} from "../../api/config";
|
||||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||||
|
import { ServerHealthSection } from "./ServerHealthSection";
|
||||||
import { useConfigStyles } from "./configStyles";
|
import { useConfigStyles } from "./configStyles";
|
||||||
|
|
||||||
/** Available fail2ban log levels in descending severity order. */
|
/** Available fail2ban log levels in descending severity order. */
|
||||||
@@ -354,6 +355,9 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Service Health & Log Viewer section */}
|
||||||
|
<ServerHealthSection />
|
||||||
</div>
|
</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 { JailFilesTab } from "./JailFilesTab";
|
||||||
export { JailFileForm } from "./JailFileForm";
|
export { JailFileForm } from "./JailFileForm";
|
||||||
export { JailsTab } from "./JailsTab";
|
export { JailsTab } from "./JailsTab";
|
||||||
export { LogTab } from "./LogTab";
|
|
||||||
export { RawConfigSection } from "./RawConfigSection";
|
export { RawConfigSection } from "./RawConfigSection";
|
||||||
export type { RawConfigSectionProps } from "./RawConfigSection";
|
export type { RawConfigSectionProps } from "./RawConfigSection";
|
||||||
export { RegexList } from "./RegexList";
|
export { RegexList } from "./RegexList";
|
||||||
export type { RegexListProps } from "./RegexList";
|
export type { RegexListProps } from "./RegexList";
|
||||||
export { RegexTesterTab } from "./RegexTesterTab";
|
export { RegexTesterTab } from "./RegexTesterTab";
|
||||||
export { ServerTab } from "./ServerTab";
|
export { ServerTab } from "./ServerTab";
|
||||||
|
export { ServerHealthSection } from "./ServerHealthSection";
|
||||||
export { useConfigStyles } from "./configStyles";
|
export { useConfigStyles } from "./configStyles";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* Jails — per-jail config accordion with inline editing
|
* Jails — per-jail config accordion with inline editing
|
||||||
* Filters — structured filter.d form editor
|
* Filters — structured filter.d form editor
|
||||||
* Actions — structured action.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
|
* Regex Tester — live pattern tester
|
||||||
* Export — raw file editors for jail, filter, and action files
|
* Export — raw file editors for jail, filter, and action files
|
||||||
*/
|
*/
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
ActionsTab,
|
ActionsTab,
|
||||||
FiltersTab,
|
FiltersTab,
|
||||||
JailsTab,
|
JailsTab,
|
||||||
LogTab,
|
|
||||||
RegexTesterTab,
|
RegexTesterTab,
|
||||||
ServerTab,
|
ServerTab,
|
||||||
} from "../components/config";
|
} from "../components/config";
|
||||||
@@ -55,8 +54,7 @@ type TabValue =
|
|||||||
| "filters"
|
| "filters"
|
||||||
| "actions"
|
| "actions"
|
||||||
| "server"
|
| "server"
|
||||||
| "regex"
|
| "regex";
|
||||||
| "log";
|
|
||||||
|
|
||||||
export function ConfigPage(): React.JSX.Element {
|
export function ConfigPage(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@@ -85,7 +83,6 @@ export function ConfigPage(): React.JSX.Element {
|
|||||||
<Tab value="actions">Actions</Tab>
|
<Tab value="actions">Actions</Tab>
|
||||||
<Tab value="server">Server</Tab>
|
<Tab value="server">Server</Tab>
|
||||||
<Tab value="regex">Regex Tester</Tab>
|
<Tab value="regex">Regex Tester</Tab>
|
||||||
<Tab value="log">Log</Tab>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<div className={styles.tabContent} key={tab}>
|
<div className={styles.tabContent} key={tab}>
|
||||||
@@ -94,7 +91,6 @@ export function ConfigPage(): React.JSX.Element {
|
|||||||
{tab === "actions" && <ActionsTab />}
|
{tab === "actions" && <ActionsTab />}
|
||||||
{tab === "server" && <ServerTab />}
|
{tab === "server" && <ServerTab />}
|
||||||
{tab === "regex" && <RegexTesterTab />}
|
{tab === "regex" && <RegexTesterTab />}
|
||||||
{tab === "log" && <LogTab />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user