/** * 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. */ 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 // --------------------------------------------------------------------------- /** * Server health panel and log viewer section for ServerTab. * * @returns JSX element. */ export function ServerHealthSection(): React.JSX.Element { const configStyles = useConfigStyles(); const styles = useStyles(); // ---- data state ---------------------------------------------------------- const [status, setStatus] = useState(null); const [logData, setLogData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); // ---- toolbar state ------------------------------------------------------- const [linesCount, setLinesCount] = useState(200); const [filterRaw, setFilterRaw] = useState(""); const [filterValue, setFilterValue] = useState(""); const [autoRefresh, setAutoRefresh] = useState(false); const [refreshInterval, setRefreshInterval] = useState(10); // ---- refs ---------------------------------------------------------------- const logContainerRef = useRef(null); const filterDebounceRef = useRef | null>(null); const autoRefreshTimerRef = useRef | 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 => { 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 (
{line}
); }; // ---- loading state ------------------------------------------------------- if (loading) { return ; } // ---- error state --------------------------------------------------------- if (error && !status && !logData) { return ( {error} ); } 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 ( <> {/* Service Health Panel */}
Service Health {status?.online ? ( Running ) : ( Offline )}
{status && !status.online && ( fail2ban is not running or unreachable. Check the server and socket configuration. )} {status?.online && ( <>
{status.version && (
Version {status.version}
)}
Active Jails {status.jail_count}
Currently Banned {status.total_bans}
Currently Failed {status.total_failures}
Log Level {status.log_level}
Log Target {status.log_target}
)}
{/* Log Viewer */}
Log Viewer {logData && ( {logData.log_path} )}
{/* Non-file target info banner */} {isNonFileTarget && ( fail2ban is logging to {logData.log_target}. File-based log viewing is not available. )} {/* Toolbar — only shown when log data is available */} {!isNonFileTarget && ( <>
{/* Filter input */} } placeholder="Substring filter…" onChange={(_e, d) => { handleFilterChange(d.value); }} /> {/* Lines count selector */} {/* Manual refresh */}
{/* Auto-refresh toggle */}
{ setAutoRefresh(d.checked); }} />
{/* Auto-refresh interval selector */} {autoRefresh && ( )}
{/* Truncation notice */} {isTruncated && ( Showing last {logData.lines.length} of {logData.total_lines} lines. Increase the line count or use the filter to narrow results. )} {/* Log lines container */}
{isRefreshing && (
)} {logData && logData.lines.length === 0 ? (
{filterValue ? `No lines match the filter "${filterValue}".` : "No log entries found."}
) : ( logData?.lines.map((line, idx) => renderLogLine(line, idx)) )}
{/* General fetch error */} {error && ( {error} )} )}
); }