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).
This commit is contained in:
512
frontend/src/components/config/ServerHealthSection.tsx
Normal file
512
frontend/src/components/config/ServerHealthSection.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user