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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user