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

@@ -7,6 +7,7 @@ import { ENDPOINTS } from "./endpoints";
import type {
ActionConfig,
ActionConfigUpdate,
ActivateJailRequest,
AddLogPathRequest,
ConfFileContent,
ConfFileCreateRequest,
@@ -16,6 +17,8 @@ import type {
FilterConfigUpdate,
GlobalConfig,
GlobalConfigUpdate,
InactiveJailListResponse,
JailActivationResponse,
JailConfigFileContent,
JailConfigFileEnabledUpdate,
JailConfigFilesResponse,
@@ -290,3 +293,38 @@ export async function updateParsedJailFile(
): Promise<void> {
await put<undefined>(ENDPOINTS.configJailFileParsed(filename), update);
}
// ---------------------------------------------------------------------------
// Inactive jails (Stage 1)
// ---------------------------------------------------------------------------
/** Fetch all inactive jails from config files. */
export async function fetchInactiveJails(): Promise<InactiveJailListResponse> {
return get<InactiveJailListResponse>(ENDPOINTS.configJailsInactive);
}
/**
* Activate an inactive jail, optionally providing override values.
*
* @param name - The jail name.
* @param overrides - Optional parameter overrides (bantime, findtime, etc.).
*/
export async function activateJail(
name: string,
overrides?: ActivateJailRequest
): Promise<JailActivationResponse> {
return post<JailActivationResponse>(
ENDPOINTS.configJailActivate(name),
overrides ?? {}
);
}
/** Deactivate an active jail. */
export async function deactivateJail(
name: string
): Promise<JailActivationResponse> {
return post<JailActivationResponse>(
ENDPOINTS.configJailDeactivate(name),
undefined
);
}

View File

@@ -61,9 +61,14 @@ export const ENDPOINTS = {
// Configuration
// -------------------------------------------------------------------------
configJails: "/config/jails",
configJailsInactive: "/config/jails/inactive",
configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`,
configJailLogPath: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/logpath`,
configJailActivate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/activate`,
configJailDeactivate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/deactivate`,
configGlobal: "/config/global",
configReload: "/config/reload",
configRegexTest: "/config/regex-test",

View File

@@ -0,0 +1,240 @@
/**
* ActivateJailDialog — confirmation dialog for activating an inactive jail.
*
* Displays the jail name and provides optional override fields for bantime,
* findtime, maxretry, port and logpath. Calls the activate endpoint on
* confirmation and propagates the result via callbacks.
*/
import { useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
} from "@fluentui/react-components";
import { activateJail } from "../../api/config";
import type { ActivateJailRequest, InactiveJail } from "../../types/config";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ActivateJailDialogProps {
/** The inactive jail to activate, or null when the dialog is closed. */
jail: InactiveJail | null;
/** Whether the dialog is currently open. */
open: boolean;
/** Called when the dialog should be closed without taking action. */
onClose: () => void;
/** Called after the jail has been successfully activated. */
onActivated: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Confirmation dialog for activating an inactive jail.
*
* All override fields are optional — leaving them blank uses the values
* already in the config files.
*
* @param props - Component props.
* @returns JSX element.
*/
export function ActivateJailDialog({
jail,
open,
onClose,
onActivated,
}: ActivateJailDialogProps): React.JSX.Element {
const [bantime, setBantime] = useState("");
const [findtime, setFindtime] = useState("");
const [maxretry, setMaxretry] = useState("");
const [port, setPort] = useState("");
const [logpath, setLogpath] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const resetForm = (): void => {
setBantime("");
setFindtime("");
setMaxretry("");
setPort("");
setLogpath("");
setError(null);
};
const handleClose = (): void => {
if (submitting) return;
resetForm();
onClose();
};
const handleConfirm = (): void => {
if (!jail || submitting) return;
const overrides: ActivateJailRequest = {};
if (bantime.trim()) overrides.bantime = bantime.trim();
if (findtime.trim()) overrides.findtime = findtime.trim();
if (maxretry.trim()) {
const n = parseInt(maxretry.trim(), 10);
if (!isNaN(n)) overrides.maxretry = n;
}
if (port.trim()) overrides.port = port.trim();
if (logpath.trim()) {
overrides.logpath = logpath
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
setSubmitting(true);
setError(null);
activateJail(jail.name, overrides)
.then(() => {
resetForm();
onActivated();
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? err.message
: err instanceof Error
? err.message
: String(err);
setError(msg);
})
.finally(() => {
setSubmitting(false);
});
};
if (!jail) return <></>;
return (
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) handleClose(); }}>
<DialogSurface>
<DialogBody>
<DialogTitle>Activate jail &ldquo;{jail.name}&rdquo;</DialogTitle>
<DialogContent>
<Text block style={{ marginBottom: tokens.spacingVerticalM }}>
This will write <code>enabled = true</code> to{" "}
<code>jail.d/{jail.name}.local</code> and reload fail2ban. The
jail will start monitoring immediately.
</Text>
<Text block weight="semibold" style={{ marginBottom: tokens.spacingVerticalS }}>
Override values (leave blank to use config defaults)
</Text>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: tokens.spacingVerticalS,
}}
>
<Field
label="Ban time"
hint={jail.bantime ? `Current: ${jail.bantime}` : undefined}
>
<Input
placeholder={jail.bantime ?? "e.g. 10m"}
value={bantime}
disabled={submitting}
onChange={(_e, d) => { setBantime(d.value); }}
/>
</Field>
<Field
label="Find time"
hint={jail.findtime ? `Current: ${jail.findtime}` : undefined}
>
<Input
placeholder={jail.findtime ?? "e.g. 10m"}
value={findtime}
disabled={submitting}
onChange={(_e, d) => { setFindtime(d.value); }}
/>
</Field>
<Field
label="Max retry"
hint={jail.maxretry != null ? `Current: ${String(jail.maxretry)}` : undefined}
>
<Input
type="number"
placeholder={jail.maxretry != null ? String(jail.maxretry) : "e.g. 5"}
value={maxretry}
disabled={submitting}
onChange={(_e, d) => { setMaxretry(d.value); }}
/>
</Field>
<Field
label="Port"
hint={jail.port ? `Current: ${jail.port}` : undefined}
>
<Input
placeholder={jail.port ?? "e.g. ssh"}
value={port}
disabled={submitting}
onChange={(_e, d) => { setPort(d.value); }}
/>
</Field>
</div>
<Field
label="Log path(s)"
hint="One path per line; leave blank to use config defaults."
style={{ marginTop: tokens.spacingVerticalS }}
>
<Input
placeholder={
jail.logpath.length > 0 ? jail.logpath[0] : "/var/log/example.log"
}
value={logpath}
disabled={submitting}
onChange={(_e, d) => { setLogpath(d.value); }}
/>
</Field>
{error && (
<MessageBar
intent="error"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={handleConfirm}
disabled={submitting}
icon={submitting ? <Spinner size="tiny" /> : undefined}
>
{submitting ? "Activating…" : "Activate"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}

View File

@@ -6,7 +6,7 @@
* raw-config editor.
*/
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Badge,
Button,
@@ -25,23 +25,29 @@ import {
ArrowClockwise24Regular,
Dismiss24Regular,
LockClosed24Regular,
LockOpen24Regular,
Play24Regular,
} from "@fluentui/react-icons";
import { ApiError } from "../../api/client";
import {
addLogPath,
deactivateJail,
deleteLogPath,
fetchInactiveJails,
fetchJailConfigFileContent,
updateJailConfigFile,
} from "../../api/config";
import type {
AddLogPathRequest,
ConfFileUpdateRequest,
InactiveJail,
JailConfig,
JailConfigUpdate,
} from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
import { useJailConfigs } from "../../hooks/useConfig";
import { ActivateJailDialog } from "./ActivateJailDialog";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ConfigListDetail } from "./ConfigListDetail";
import { RawConfigSection } from "./RawConfigSection";
@@ -55,6 +61,7 @@ import { useConfigStyles } from "./configStyles";
interface JailConfigDetailProps {
jail: JailConfig;
onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
onDeactivate?: () => void;
}
/**
@@ -69,6 +76,7 @@ interface JailConfigDetailProps {
function JailConfigDetail({
jail,
onSave,
onDeactivate,
}: JailConfigDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [banTime, setBanTime] = useState(String(jail.ban_time));
@@ -443,6 +451,18 @@ function JailConfigDetail({
/>
</div>
{onDeactivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="secondary"
icon={<LockOpen24Regular />}
onClick={onDeactivate}
>
Deactivate Jail
</Button>
</div>
)}
{/* Raw Configuration */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
@@ -455,6 +475,102 @@ function JailConfigDetail({
);
}
// ---------------------------------------------------------------------------
// InactiveJailDetail
// ---------------------------------------------------------------------------
interface InactiveJailDetailProps {
jail: InactiveJail;
onActivate: () => void;
}
/**
* Read-only detail view for an inactive jail, with an Activate button.
*
* @param props - Component props.
* @returns JSX element.
*/
function InactiveJailDetail({
jail,
onActivate,
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
return (
<div className={styles.detailPane}>
<Text weight="semibold" size={500} block style={{ marginBottom: tokens.spacingVerticalM }}>
{jail.name}
</Text>
<div className={styles.fieldRow}>
<Field label="Filter">
<Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} />
</Field>
<Field label="Port">
<Input readOnly value={jail.port ?? "(auto)"} />
</Field>
</div>
<div className={styles.fieldRowThree}>
<Field label="Ban time">
<Input readOnly value={jail.bantime ?? "(default)"} />
</Field>
<Field label="Find time">
<Input readOnly value={jail.findtime ?? "(default)"} />
</Field>
<Field label="Max retry">
<Input readOnly value={jail.maxretry != null ? String(jail.maxretry) : "(default)"} />
</Field>
</div>
<Field label="Log path(s)">
{jail.logpath.length === 0 ? (
<Text size={200} className={styles.infoText}>(none)</Text>
) : (
<div>
{jail.logpath.map((p) => (
<Text key={p} block size={200} className={styles.codeFont}>
{p}
</Text>
))}
</div>
)}
</Field>
{jail.actions.length > 0 && (
<Field label="Actions">
<div>
{jail.actions.map((a) => (
<Badge
key={a}
appearance="tint"
color="informative"
style={{ marginRight: tokens.spacingHorizontalXS }}
>
{a}
</Badge>
))}
</div>
</Field>
)}
<Field label="Source file">
<Input readOnly value={jail.source_file} className={styles.codeFont} />
</Field>
<div style={{ marginTop: tokens.spacingVerticalL }}>
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// JailsTab
// ---------------------------------------------------------------------------
@@ -474,20 +590,99 @@ export function JailsTab(): React.JSX.Element {
const [reloading, setReloading] = useState(false);
const [reloadMsg, setReloadMsg] = useState<string | null>(null);
// Inactive jails
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [inactiveLoading, setInactiveLoading] = useState(false);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
const [deactivating, setDeactivating] = useState(false);
const loadInactive = useCallback((): void => {
setInactiveLoading(true);
fetchInactiveJails()
.then((res) => { setInactiveJails(res.jails); })
.catch(() => { /* non-critical — active-only view still works */ })
.finally(() => { setInactiveLoading(false); });
}, []);
useEffect(() => {
loadInactive();
}, [loadInactive]);
const handleRefresh = useCallback((): void => {
refresh();
loadInactive();
}, [refresh, loadInactive]);
const handleReload = useCallback(async () => {
setReloading(true);
setReloadMsg(null);
try {
await reloadAll();
setReloadMsg("fail2ban reloaded.");
refresh();
loadInactive();
} catch (err: unknown) {
setReloadMsg(err instanceof ApiError ? err.message : "Reload failed.");
} finally {
setReloading(false);
}
}, [reloadAll]);
}, [reloadAll, refresh, loadInactive]);
if (loading) {
const handleDeactivate = useCallback((name: string): void => {
setDeactivating(true);
setReloadMsg(null);
deactivateJail(name)
.then(() => {
setReloadMsg(`Jail "${name}" deactivated.`);
setSelectedName(null);
refresh();
loadInactive();
})
.catch((err: unknown) => {
setReloadMsg(
err instanceof ApiError ? err.message : "Deactivation failed."
);
})
.finally(() => { setDeactivating(false); });
}, [refresh, loadInactive]);
const handleActivated = useCallback((): void => {
setActivateTarget(null);
setSelectedName(null);
refresh();
loadInactive();
}, [refresh, loadInactive]);
/** Unified list items: active jails first (from useJailConfigs), then inactive. */
const listItems = useMemo<Array<{ name: string; kind: "active" | "inactive" }>>(() => {
const activeItems = jails.map((j) => ({ name: j.name, kind: "active" as const }));
const activeNames = new Set(jails.map((j) => j.name));
const inactiveItems = inactiveJails
.filter((j) => !activeNames.has(j.name))
.map((j) => ({ name: j.name, kind: "inactive" as const }));
return [...activeItems, ...inactiveItems];
}, [jails, inactiveJails]);
const activeJailMap = useMemo(
() => new Map(jails.map((j) => [j.name, j])),
[jails],
);
const inactiveJailMap = useMemo(
() => new Map(inactiveJails.map((j) => [j.name, j])),
[inactiveJails],
);
const selectedListItem = listItems.find((item) => item.name === selectedName);
const selectedActiveJail =
selectedListItem?.kind === "active"
? activeJailMap.get(selectedListItem.name)
: undefined;
const selectedInactiveJail =
selectedListItem?.kind === "inactive"
? inactiveJailMap.get(selectedListItem.name)
: undefined;
if (loading && listItems.length === 0) {
return (
<Skeleton aria-label="Loading jail configs…">
{[0, 1, 2].map((i) => (
@@ -505,7 +700,7 @@ export function JailsTab(): React.JSX.Element {
);
}
if (jails.length === 0) {
if (listItems.length === 0 && !loading && !inactiveLoading) {
return (
<div className={styles.emptyState}>
<LockClosed24Regular
@@ -513,7 +708,7 @@ export function JailsTab(): React.JSX.Element {
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No active jails found.
No jails found.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Ensure fail2ban is running and jails are configured.
@@ -522,24 +717,20 @@ export function JailsTab(): React.JSX.Element {
);
}
const selectedJail: JailConfig | undefined = jails.find(
(j) => j.name === selectedName,
);
return (
<div className={styles.tabContent}>
<div className={styles.buttonRow}>
<Button
appearance="secondary"
icon={<ArrowClockwise24Regular />}
onClick={refresh}
onClick={handleRefresh}
>
Refresh
</Button>
<Button
appearance="secondary"
icon={<ArrowClockwise24Regular />}
disabled={reloading}
disabled={reloading || deactivating}
onClick={() => void handleReload()}
>
{reloading ? "Reloading…" : "Reload fail2ban"}
@@ -556,18 +747,34 @@ export function JailsTab(): React.JSX.Element {
<div style={{ marginTop: tokens.spacingVerticalM }}>
<ConfigListDetail
items={jails}
isActive={(jail) => activeJails.has(jail.name)}
items={listItems}
isActive={(item) => item.kind === "active" && activeJails.has(item.name)}
selectedName={selectedName}
onSelect={setSelectedName}
loading={false}
error={null}
>
{selectedJail !== undefined ? (
<JailConfigDetail jail={selectedJail} onSave={updateJail} />
{selectedActiveJail !== undefined ? (
<JailConfigDetail
jail={selectedActiveJail}
onSave={updateJail}
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
/>
) : null}
</ConfigListDetail>
</div>
<ActivateJailDialog
jail={activateTarget}
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
/>
</div>
);
}

View File

@@ -8,6 +8,8 @@
export { ActionsTab } from "./ActionsTab";
export { ActionForm } from "./ActionForm";
export type { ActionFormProps } from "./ActionForm";
export { ActivateJailDialog } from "./ActivateJailDialog";
export type { ActivateJailDialogProps } from "./ActivateJailDialog";
export { AutoSaveIndicator } from "./AutoSaveIndicator";
export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator";
export { ConfFilesTab } from "./ConfFilesTab";

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>
);
}

View File

@@ -380,3 +380,60 @@ export interface JailFileConfig {
export interface JailFileConfigUpdate {
jails?: Record<string, JailSectionConfig> | null;
}
// ---------------------------------------------------------------------------
// Inactive jail models (Stage 1)
// ---------------------------------------------------------------------------
/**
* A jail discovered in fail2ban config files that is not currently active.
*
* Maps 1-to-1 with the backend ``InactiveJail`` Pydantic model.
*/
export interface InactiveJail {
/** Jail name from the config section header. */
name: string;
/** Filter name (may include mode suffix, e.g. ``sshd[mode=normal]``). */
filter: string;
/** Action references listed in the config (raw strings). */
actions: string[];
/** Port(s) to monitor, or null. */
port: string | null;
/** Log file paths to monitor. */
logpath: string[];
/** Ban duration as a raw config string (e.g. ``"10m"``), or null. */
bantime: string | null;
/** Failure-counting window as a raw config string, or null. */
findtime: string | null;
/** Number of failures before a ban is issued, or null. */
maxretry: number | null;
/** Absolute path to the config file where this jail is defined. */
source_file: string;
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
enabled: boolean;
}
export interface InactiveJailListResponse {
jails: InactiveJail[];
total: number;
}
/**
* Optional override values when activating an inactive jail.
*/
export interface ActivateJailRequest {
bantime?: string | null;
findtime?: string | null;
maxretry?: number | null;
port?: string | null;
logpath?: string[] | null;
}
export interface JailActivationResponse {
/** Name of the affected jail. */
name: string;
/** New activation state: true after activate, false after deactivate. */
active: boolean;
/** Human-readable result message. */
message: string;
}