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:
@@ -18,6 +18,7 @@ import type {
|
||||
ConfFileCreateRequest,
|
||||
ConfFilesResponse,
|
||||
ConfFileUpdateRequest,
|
||||
Fail2BanLogResponse,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
FilterCreateRequest,
|
||||
@@ -43,6 +44,7 @@ import type {
|
||||
ServerSettingsUpdate,
|
||||
JailFileConfig,
|
||||
JailFileConfigUpdate,
|
||||
ServiceStatusResponse,
|
||||
} from "../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -541,3 +543,29 @@ export async function deactivateJail(
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fail2ban log viewer (Task 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch the tail of the fail2ban daemon log file.
|
||||
*
|
||||
* @param lines - Number of tail lines to return (1–2000, default 200).
|
||||
* @param filter - Optional plain-text substring; only matching lines returned.
|
||||
*/
|
||||
export async function fetchFail2BanLog(
|
||||
lines?: number,
|
||||
filter?: string,
|
||||
): Promise<Fail2BanLogResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (lines !== undefined) params.set("lines", String(lines));
|
||||
if (filter !== undefined && filter !== "") params.set("filter", filter);
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
return get<Fail2BanLogResponse>(`${ENDPOINTS.configFail2BanLog}${query}`);
|
||||
}
|
||||
|
||||
/** Fetch fail2ban service health status with current log configuration. */
|
||||
export async function fetchServiceStatus(): Promise<ServiceStatusResponse> {
|
||||
return get<ServiceStatusResponse>(ENDPOINTS.configServiceStatus);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ export const ENDPOINTS = {
|
||||
configActionParsed: (name: string): string =>
|
||||
`/config/actions/${encodeURIComponent(name)}/parsed`,
|
||||
|
||||
// fail2ban log viewer (Task 2)
|
||||
configFail2BanLog: "/config/fail2ban-log",
|
||||
configServiceStatus: "/config/service-status",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Server settings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
518
frontend/src/components/config/LogTab.tsx
Normal file
518
frontend/src/components/config/LogTab.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* LogTab — fail2ban log viewer and service health panel.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwise24Regular,
|
||||
DocumentBulletList24Regular,
|
||||
Filter24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { fetchFail2BanLog, fetchServiceStatus } from "../../api/config";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Available line-count options for the log tail. */
|
||||
const LINE_COUNT_OPTIONS: number[] = [100, 200, 500, 1000];
|
||||
|
||||
/** Auto-refresh interval options in seconds. */
|
||||
const AUTO_REFRESH_INTERVALS: { label: string; value: number }[] = [
|
||||
{ label: "5 s", value: 5 },
|
||||
{ label: "10 s", value: 10 },
|
||||
{ label: "30 s", value: 30 },
|
||||
];
|
||||
|
||||
/** Debounce delay for the filter input in milliseconds. */
|
||||
const FILTER_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Log targets that are not file paths — file-based viewing is unavailable. */
|
||||
const NON_FILE_TARGETS = new Set(["STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
healthGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
marginTop: tokens.spacingVerticalM,
|
||||
},
|
||||
statCard: {
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXS,
|
||||
},
|
||||
statLabel: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
statValue: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
},
|
||||
metaRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalL,
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
metaItem: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
},
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
marginBottom: tokens.spacingVerticalM,
|
||||
},
|
||||
filterInput: {
|
||||
width: "200px",
|
||||
},
|
||||
logContainer: {
|
||||
backgroundColor: tokens.colorNeutralBackground4,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
|
||||
maxHeight: "560px",
|
||||
overflowY: "auto",
|
||||
fontFamily: "monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
lineHeight: "1.6",
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
},
|
||||
logLineError: {
|
||||
color: tokens.colorPaletteRedForeground1,
|
||||
},
|
||||
logLineWarning: {
|
||||
color: tokens.colorPaletteYellowForeground1,
|
||||
},
|
||||
logLineDebug: {
|
||||
color: tokens.colorNeutralForeground4,
|
||||
},
|
||||
logLineDefault: {
|
||||
color: tokens.colorNeutralForeground1,
|
||||
},
|
||||
truncatedBanner: {
|
||||
marginBottom: tokens.spacingVerticalS,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontSize: tokens.fontSizeBase100,
|
||||
},
|
||||
emptyLog: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontStyle: "italic",
|
||||
padding: tokens.spacingVerticalS,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine the CSS class key for a log line based on its severity.
|
||||
*
|
||||
* fail2ban formats log lines as:
|
||||
* ``2025-… fail2ban.filter [PID]: LEVEL message``
|
||||
*
|
||||
* @param line - A single log line string.
|
||||
* @returns The severity key: "error" | "warning" | "debug" | "default".
|
||||
*/
|
||||
function detectSeverity(line: string): "error" | "warning" | "debug" | "default" {
|
||||
const upper = line.toUpperCase();
|
||||
if (upper.includes(" ERROR ") || upper.includes(" CRITICAL ")) return "error";
|
||||
if (upper.includes(" WARNING ") || upper.includes(": WARNING") || upper.includes(" WARN ")) return "warning";
|
||||
if (upper.includes(" DEBUG ")) return "debug";
|
||||
return "default";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Log tab component for the Configuration page.
|
||||
*
|
||||
* Shows fail2ban service health and a live log viewer with refresh controls.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function LogTab(): React.JSX.Element {
|
||||
const configStyles = useConfigStyles();
|
||||
const styles = useStyles();
|
||||
|
||||
// ---- data state ----------------------------------------------------------
|
||||
const [status, setStatus] = useState<ServiceStatusResponse | null>(null);
|
||||
const [logData, setLogData] = useState<Fail2BanLogResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// ---- toolbar state -------------------------------------------------------
|
||||
const [linesCount, setLinesCount] = useState<number>(200);
|
||||
const [filterRaw, setFilterRaw] = useState<string>("");
|
||||
const [filterValue, setFilterValue] = useState<string>("");
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [refreshInterval, setRefreshInterval] = useState(10);
|
||||
|
||||
// ---- refs ----------------------------------------------------------------
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ---- scroll helper -------------------------------------------------------
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- fetch logic ---------------------------------------------------------
|
||||
const fetchData = useCallback(
|
||||
async (showSpinner: boolean): Promise<void> => {
|
||||
if (showSpinner) setIsRefreshing(true);
|
||||
try {
|
||||
// Use allSettled so a log-read failure doesn't hide the service status.
|
||||
const [svcResult, logResult] = await Promise.allSettled([
|
||||
fetchServiceStatus(),
|
||||
fetchFail2BanLog(linesCount, filterValue || undefined),
|
||||
]);
|
||||
|
||||
if (svcResult.status === "fulfilled") {
|
||||
setStatus(svcResult.value);
|
||||
} else {
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
if (logResult.status === "fulfilled") {
|
||||
setLogData(logResult.value);
|
||||
setError(null);
|
||||
} else {
|
||||
const reason: unknown = logResult.reason;
|
||||
setError(reason instanceof Error ? reason.message : "Failed to load log data.");
|
||||
}
|
||||
} finally {
|
||||
if (showSpinner) setIsRefreshing(false);
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[linesCount, filterValue],
|
||||
);
|
||||
|
||||
// ---- initial load --------------------------------------------------------
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void fetchData(false);
|
||||
}, [fetchData]);
|
||||
|
||||
// ---- scroll to bottom when new log data arrives -------------------------
|
||||
useEffect(() => {
|
||||
if (logData) {
|
||||
// Tiny timeout lets the DOM paint before scrolling.
|
||||
const t = setTimeout(scrollToBottom, 50);
|
||||
return (): void => { clearTimeout(t); };
|
||||
}
|
||||
}, [logData, scrollToBottom]);
|
||||
|
||||
// ---- auto-refresh interval ----------------------------------------------
|
||||
useEffect(() => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
if (autoRefresh) {
|
||||
autoRefreshTimerRef.current = setInterval(() => {
|
||||
void fetchData(true);
|
||||
}, refreshInterval * 1000);
|
||||
}
|
||||
return (): void => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, refreshInterval, fetchData]);
|
||||
|
||||
// ---- filter debounce ----------------------------------------------------
|
||||
const handleFilterChange = useCallback((value: string): void => {
|
||||
setFilterRaw(value);
|
||||
if (filterDebounceRef.current) clearTimeout(filterDebounceRef.current);
|
||||
filterDebounceRef.current = setTimeout(() => {
|
||||
setFilterValue(value);
|
||||
}, FILTER_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
// ---- render helpers ------------------------------------------------------
|
||||
const renderLogLine = (line: string, idx: number): React.JSX.Element => {
|
||||
const severity = detectSeverity(line);
|
||||
const className =
|
||||
severity === "error"
|
||||
? styles.logLineError
|
||||
: severity === "warning"
|
||||
? styles.logLineWarning
|
||||
: severity === "debug"
|
||||
? styles.logLineDebug
|
||||
: styles.logLineDefault;
|
||||
return (
|
||||
<div key={idx} className={className}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- loading state -------------------------------------------------------
|
||||
if (loading) {
|
||||
return <Spinner label="Loading log viewer…" />;
|
||||
}
|
||||
|
||||
// ---- error state ---------------------------------------------------------
|
||||
if (error && !status && !logData) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
const isNonFileTarget =
|
||||
logData?.log_target != null &&
|
||||
NON_FILE_TARGETS.has(logData.log_target.toUpperCase());
|
||||
|
||||
const isTruncated =
|
||||
logData != null && logData.total_lines > logData.lines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Service Health Panel */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<DocumentBulletList24Regular />
|
||||
<Text weight="semibold" size={400}>
|
||||
Service Health
|
||||
</Text>
|
||||
{status?.online ? (
|
||||
<Badge appearance="filled" color="success">
|
||||
Running
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge appearance="filled" color="danger">
|
||||
Offline
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && !status.online && (
|
||||
<MessageBar intent="warning" style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<MessageBarBody>
|
||||
fail2ban is not running or unreachable. Check the server and socket
|
||||
configuration.
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{status?.online && (
|
||||
<>
|
||||
<div className={styles.healthGrid}>
|
||||
{status.version && (
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Version</Text>
|
||||
<Text className={styles.statValue}>{status.version}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Active Jails</Text>
|
||||
<Text className={styles.statValue}>{status.jail_count}</Text>
|
||||
</div>
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Currently Banned</Text>
|
||||
<Text className={styles.statValue}>{status.total_bans}</Text>
|
||||
</div>
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Currently Failed</Text>
|
||||
<Text className={styles.statValue}>{status.total_failures}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<div className={styles.metaItem}>
|
||||
<Text className={styles.statLabel}>Log Level</Text>
|
||||
<Text size={300}>{status.log_level}</Text>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<Text className={styles.statLabel}>Log Target</Text>
|
||||
<Text size={300}>{status.log_target}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Log Viewer */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
||||
<Text weight="semibold" size={400}>
|
||||
Log Viewer
|
||||
</Text>
|
||||
{logData && (
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
{logData.log_path}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Non-file target info banner */}
|
||||
{isNonFileTarget && (
|
||||
<MessageBar intent="info" style={{ marginBottom: tokens.spacingVerticalM }}>
|
||||
<MessageBarBody>
|
||||
fail2ban is logging to <strong>{logData.log_target}</strong>.
|
||||
File-based log viewing is not available.
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Toolbar — only shown when log data is available */}
|
||||
{!isNonFileTarget && (
|
||||
<>
|
||||
<div className={styles.toolbar}>
|
||||
{/* Filter input */}
|
||||
<Field label="Filter">
|
||||
<Input
|
||||
className={styles.filterInput}
|
||||
type="text"
|
||||
value={filterRaw}
|
||||
contentBefore={<Filter24Regular />}
|
||||
placeholder="Substring filter…"
|
||||
onChange={(_e, d) => { handleFilterChange(d.value); }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Lines count selector */}
|
||||
<Field label="Lines">
|
||||
<Select
|
||||
value={String(linesCount)}
|
||||
onChange={(_e, d) => { setLinesCount(Number(d.value)); }}
|
||||
>
|
||||
{LINE_COUNT_OPTIONS.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{/* Manual refresh */}
|
||||
<div style={{ alignSelf: "flex-end" }}>
|
||||
<Button
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
appearance="secondary"
|
||||
onClick={() => void fetchData(true)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh toggle */}
|
||||
<div style={{ alignSelf: "flex-end" }}>
|
||||
<Switch
|
||||
label="Auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onChange={(_e, d) => { setAutoRefresh(d.checked); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh interval selector */}
|
||||
{autoRefresh && (
|
||||
<Field label="Interval">
|
||||
<Select
|
||||
value={String(refreshInterval)}
|
||||
onChange={(_e, d) => { setRefreshInterval(Number(d.value)); }}
|
||||
>
|
||||
{AUTO_REFRESH_INTERVALS.map((opt) => (
|
||||
<option key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Truncation notice */}
|
||||
{isTruncated && (
|
||||
<Text className={styles.truncatedBanner} block>
|
||||
Showing last {logData.lines.length} of {logData.total_lines} lines.
|
||||
Increase the line count or use the filter to narrow results.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Log lines container */}
|
||||
<div className={styles.logContainer} ref={logContainerRef}>
|
||||
{isRefreshing && (
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<Spinner size="tiny" label="Refreshing…" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logData && logData.lines.length === 0 ? (
|
||||
<div className={styles.emptyLog}>
|
||||
{filterValue
|
||||
? `No lines match the filter "${filterValue}".`
|
||||
: "No log entries found."}
|
||||
</div>
|
||||
) : (
|
||||
logData?.lines.map((line, idx) => renderLogLine(line, idx))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General fetch error */}
|
||||
{error && (
|
||||
<MessageBar intent="error" style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/config/__tests__/LogTab.test.tsx
Normal file
189
frontend/src/components/config/__tests__/LogTab.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export { GlobalTab } from "./GlobalTab";
|
||||
export { JailFilesTab } from "./JailFilesTab";
|
||||
export { JailFileForm } from "./JailFileForm";
|
||||
export { JailsTab } from "./JailsTab";
|
||||
export { LogTab } from "./LogTab";
|
||||
export { MapTab } from "./MapTab";
|
||||
export { RawConfigSection } from "./RawConfigSection";
|
||||
export type { RawConfigSectionProps } from "./RawConfigSection";
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FiltersTab,
|
||||
GlobalTab,
|
||||
JailsTab,
|
||||
LogTab,
|
||||
MapTab,
|
||||
RegexTesterTab,
|
||||
ServerTab,
|
||||
@@ -60,7 +61,8 @@ type TabValue =
|
||||
| "global"
|
||||
| "server"
|
||||
| "map"
|
||||
| "regex";
|
||||
| "regex"
|
||||
| "log";
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
@@ -91,6 +93,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
<Tab value="regex">Regex Tester</Tab>
|
||||
<Tab value="log">Log</Tab>
|
||||
</TabList>
|
||||
|
||||
<div className={styles.tabContent} key={tab}>
|
||||
@@ -101,6 +104,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
{tab === "regex" && <RegexTesterTab />}
|
||||
{tab === "log" && <LogTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -592,3 +592,39 @@ export interface FilterCreateRequest {
|
||||
export interface AssignFilterRequest {
|
||||
filter_name: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fail2ban log viewer types (Task 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Response for ``GET /api/config/fail2ban-log``. */
|
||||
export interface Fail2BanLogResponse {
|
||||
/** Resolved absolute path of the log file being read. */
|
||||
log_path: string;
|
||||
/** Log lines (tail of file, optionally filtered by substring). */
|
||||
lines: string[];
|
||||
/** Total number of lines in the file before any filtering. */
|
||||
total_lines: number;
|
||||
/** Current fail2ban log level, e.g. "INFO". */
|
||||
log_level: string;
|
||||
/** Current fail2ban log target (file path or special value like "STDOUT"). */
|
||||
log_target: string;
|
||||
}
|
||||
|
||||
/** Response for ``GET /api/config/service-status``. */
|
||||
export interface ServiceStatusResponse {
|
||||
/** Whether fail2ban is reachable via its socket. */
|
||||
online: boolean;
|
||||
/** fail2ban version string, or null when offline. */
|
||||
version: string | null;
|
||||
/** Number of currently active jails. */
|
||||
jail_count: number;
|
||||
/** Aggregated current ban count across all jails. */
|
||||
total_bans: number;
|
||||
/** Aggregated current failure count across all jails. */
|
||||
total_failures: number;
|
||||
/** Current fail2ban log level. */
|
||||
log_level: string;
|
||||
/** Current fail2ban log target. */
|
||||
log_target: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user