feat(stage-1): inactive jail discovery and activation

- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
  following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
  POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
  to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
This commit is contained in:
2026-03-13 15:44:36 +01:00
parent a344f1035b
commit 8d9d63b866
15 changed files with 2711 additions and 182 deletions

View File

@@ -10,7 +10,7 @@
* geo-location details.
*/
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
Badge,
Button,
@@ -32,6 +32,7 @@ import {
MessageBarBody,
Select,
Spinner,
Switch,
Text,
Tooltip,
makeStyles,
@@ -52,8 +53,11 @@ import {
StopRegular,
} from "@fluentui/react-icons";
import { Link } from "react-router-dom";
import { fetchInactiveJails } from "../api/config";
import { ActivateJailDialog } from "../components/config";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { ActiveBan, JailSummary } from "../types/jail";
import type { InactiveJail } from "../types/config";
import { ApiError } from "../api/client";
// ---------------------------------------------------------------------------
@@ -319,6 +323,25 @@ function JailOverviewSection(): React.JSX.Element {
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails();
const [opError, setOpError] = useState<string | null>(null);
const [showInactive, setShowInactive] = useState(true);
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
const loadInactive = useCallback((): void => {
fetchInactiveJails()
.then((res) => { setInactiveJails(res.jails); })
.catch(() => { /* non-critical */ });
}, []);
useEffect(() => {
loadInactive();
}, [loadInactive]);
const handleActivated = useCallback((): void => {
setActivateTarget(null);
refresh();
loadInactive();
}, [refresh, loadInactive]);
const handle = (fn: () => Promise<void>): void => {
setOpError(null);
@@ -327,6 +350,9 @@ function JailOverviewSection(): React.JSX.Element {
});
};
const activeNameSet = new Set(jails.map((j) => j.name));
const inactiveToShow = inactiveJails.filter((j) => !activeNameSet.has(j.name));
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
@@ -339,13 +365,16 @@ function JailOverviewSection(): React.JSX.Element {
)}
</Text>
<div className={styles.actionRow}>
<Switch
label="Show inactive"
checked={showInactive}
onChange={(_e, d) => { setShowInactive(d.checked); }}
/>
<Button
size="small"
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() => {
handle(reloadAll);
}}
onClick={() => { handle(reloadAll); }}
>
Reload All
</Button>
@@ -451,6 +480,86 @@ function JailOverviewSection(): React.JSX.Element {
</DataGrid>
</div>
)}
{/* Inactive jails table */}
{showInactive && inactiveToShow.length > 0 && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Text
size={300}
weight="semibold"
style={{ color: tokens.colorNeutralForeground3, marginBottom: tokens.spacingVerticalXS }}
block
>
Inactive jails ({String(inactiveToShow.length)})
</Text>
<div className={styles.tableWrapper}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: tokens.fontSizeBase200 }}>
<thead>
<tr style={{ borderBottom: `1px solid ${tokens.colorNeutralStroke2}` }}>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Jail</th>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Status</th>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Filter</th>
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Port</th>
<th style={{ textAlign: "left", padding: "6px 8px" }} />
</tr>
</thead>
<tbody>
{inactiveToShow.map((j) => (
<tr
key={j.name}
style={{
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
opacity: 0.7,
}}
>
<td style={{ padding: "6px 8px" }}>
<Link
to="/config"
style={{
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: "0.85rem",
textDecoration: "none",
color: tokens.colorBrandForeground1,
}}
>
{j.name}
</Link>
</td>
<td style={{ padding: "6px 8px" }}>
<Badge appearance="filled" color="subtle">inactive</Badge>
</td>
<td style={{ padding: "6px 8px" }}>
<Text size={200} style={{ fontFamily: "Consolas, 'Courier New', monospace" }}>
{j.filter || "—"}
</Text>
</td>
<td style={{ padding: "6px 8px" }}>
<Text size={200}>{j.port ?? "—"}</Text>
</td>
<td style={{ padding: "6px 8px" }}>
<Button
size="small"
appearance="primary"
icon={<PlayRegular />}
onClick={() => { setActivateTarget(j); }}
>
Activate
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<ActivateJailDialog
jail={activateTarget}
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
/>
</div>
);
}