Files
BanGUI/frontend/src/components/config/ServerHealthSection.tsx
Lukas 1da38361a9 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).
2026-03-14 21:58:34 +01:00

513 lines
17 KiB
TypeScript

/**
* 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<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 (
<>
{/* 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>
</>
);
}