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).
513 lines
17 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|