feat(frontend): add ConfigListDetail, RawConfigSection components and useConfigActiveStatus hook

- ConfigListDetail: reusable two-pane master/detail layout (list + detail)
  with active/inactive badges, sorted active-first, keyboard navigation,
  and responsive collapse to Dropdown below 900 px
- RawConfigSection: collapsible raw-text editor with save/feedback for
  any config file, backed by configurable fetch/save callbacks
- useConfigActiveStatus: hook that derives active jail, filter, and action
  sets from the live jails list and jail config data
This commit is contained in:
2026-03-13 14:34:49 +01:00
parent cf2336c0bc
commit 0c0acd7f51
3 changed files with 660 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
/**
* useConfigActiveStatus — compute active status for jails, filters, and
* actions by correlating runtime jail data with their config files.
*
* Active determination rules:
* - Jail: `enabled === true` in the live JailSummary list.
* - Filter: referenced by at least one enabled jail (jail name = filter name
* by fail2ban convention, unless a filter field is provided).
* - Action: referenced in the `actions` array of at least one enabled jail's
* JailConfig.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchJails } from "../api/jails";
import {
fetchActionFiles,
fetchFilterFiles,
fetchJailConfigs,
} from "../api/config";
import type { JailConfig } from "../types/config";
import type { JailSummary } from "../types/jail";
// ---------------------------------------------------------------------------
// Public interface
// ---------------------------------------------------------------------------
export interface UseConfigActiveStatusResult {
/** Names of jails that are currently enabled. */
activeJails: Set<string>;
/** Names of filter files referenced by at least one enabled jail. */
activeFilters: Set<string>;
/** Names of action files referenced by at least one enabled jail. */
activeActions: Set<string>;
/** True while any parallel fetch is in progress. */
loading: boolean;
/** Error message from any failed fetch, or null. */
error: string | null;
/** Re-fetch all data sources. */
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Fetch jails, jail configs, filter files, and action files in parallel and
* derive active-status sets for each config type.
*
* @returns Active-status sets, loading flag, error, and refresh function.
*/
export function useConfigActiveStatus(): UseConfigActiveStatusResult {
const [activeJails, setActiveJails] = useState<Set<string>>(new Set());
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
const [activeActions, setActiveActions] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
Promise.all([
fetchJails(),
fetchJailConfigs(),
fetchFilterFiles(),
fetchActionFiles(),
])
.then(([jailsResp, configsResp, _filterResp, _actionResp]) => {
if (ctrl.signal.aborted) return;
const summaries: JailSummary[] = jailsResp.jails;
const configs: JailConfig[] = configsResp.jails;
// Active jails: enabled in the runtime summary list.
const jailSet = new Set<string>(
summaries.filter((j) => j.enabled).map((j) => j.name),
);
// Active filters: referenced by any enabled jail.
// By fail2ban convention the filter name equals the jail name unless
// the config explicitly declares a [filter] section (not surfaced in
// JailConfig), so we use the jail name as the filter name.
const filterSet = new Set<string>();
for (const cfg of configs) {
if (jailSet.has(cfg.name)) {
filterSet.add(cfg.name);
}
}
// Active actions: referenced in the actions array of any enabled jail.
const actionSet = new Set<string>();
for (const cfg of configs) {
if (jailSet.has(cfg.name)) {
for (const action of cfg.actions) {
// Actions can be stored as "name[param=val]" — extract base name.
const bracketIdx = action.indexOf("[");
const baseName = (bracketIdx >= 0 ? action.slice(0, bracketIdx) : action).trim();
actionSet.add(baseName);
}
}
}
setActiveJails(jailSet);
setActiveFilters(filterSet);
setActiveActions(actionSet);
setLoading(false);
})
.catch((err: unknown) => {
if (ctrl.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load status.");
setLoading(false);
});
}, []);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return {
activeJails,
activeFilters,
activeActions,
loading,
error,
refresh: load,
};
}