Add fail2ban log viewer and service health to Config page

Task 2: adds a new Log tab to the Configuration page.

Backend:
- New Pydantic models: Fail2BanLogResponse, ServiceStatusResponse
  (backend/app/models/config.py)
- New service methods in config_service.py:
    read_fail2ban_log() — queries socket for log target/level, validates the
    resolved path against a safe-prefix allowlist (/var/log) to prevent
    path traversal, then reads the tail of the file via the existing
    _read_tail_lines() helper; optional substring filter applied server-side.
    get_service_status() — delegates to health_service.probe() and appends
    log level/target from the socket.
- New endpoints in routers/config.py:
    GET /api/config/fail2ban-log?lines=200&filter=...
    GET /api/config/service-status
  Both require authentication; log endpoint returns 400 for non-file log
  targets or path-traversal attempts, 502 when fail2ban is unreachable.

Frontend:
- New LogTab.tsx component:
    Service Health panel (Running/Offline badge, version, jail count, bans,
    failures, log level/target, offline warning banner).
    Log viewer with color-coded lines (error=red, warning=yellow,
    debug=grey), toolbar (filter input + debounce, lines selector, manual
    refresh, auto-refresh with interval selector), truncation notice, and
    auto-scroll to bottom on data updates.
  fetchData uses Promise.allSettled so a log-read failure never hides the
  service-health panel.
- Types: Fail2BanLogResponse, ServiceStatusResponse (types/config.ts)
- API functions: fetchFail2BanLog, fetchServiceStatus (api/config.ts)
- Endpoint constants (api/endpoints.ts)
- ConfigPage.tsx: Log tab added after existing tabs

Tests:
- Backend service tests: TestReadFail2BanLog (6), TestGetServiceStatus (2)
- Backend router tests: TestGetFail2BanLog (8), TestGetServiceStatus (3)
- Frontend: LogTab.test.tsx (8 tests)

Docs:
- Features.md: Log section added under Configuration View
- Architekture.md: config.py router and config_service.py descriptions updated
- Tasks.md: Task 2 marked done
This commit is contained in:
2026-03-14 12:54:03 +01:00
parent 5e1b8134d9
commit ab11ece001
15 changed files with 1527 additions and 4 deletions

View File

@@ -0,0 +1,189 @@
/**
* 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));
});
});