feature/ignore-self-toggle #1
326
frontend/src/components/config/ConfigListDetail.tsx
Normal file
326
frontend/src/components/config/ConfigListDetail.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* ConfigListDetail — reusable two-pane master/detail layout component.
|
||||||
|
*
|
||||||
|
* Left pane: scrollable list of config items with active/inactive badges.
|
||||||
|
* Active items sort to the top. Selected item is highlighted.
|
||||||
|
* Right pane: renders {@link children} for the selected item, or an empty
|
||||||
|
* state message when nothing is selected.
|
||||||
|
*
|
||||||
|
* Keyboard navigation: ArrowUp / ArrowDown navigate items; Enter selects.
|
||||||
|
* On screens narrower than 900 px the list collapses to a Dropdown above
|
||||||
|
* the detail pane.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Dropdown,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Option,
|
||||||
|
Skeleton,
|
||||||
|
SkeletonItem,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import type { OptionOnSelectData, SelectionEvents } from "@fluentui/react-components";
|
||||||
|
import { DocumentSearch24Regular } from "@fluentui/react-icons";
|
||||||
|
import { useConfigStyles } from "./configStyles";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ConfigListDetailProps<T extends { name: string }> {
|
||||||
|
/** Complete list of config items to display. */
|
||||||
|
items: T[];
|
||||||
|
/** Predicate that determines whether an item is "active". */
|
||||||
|
isActive: (item: T) => boolean;
|
||||||
|
/** Currently selected item name, or null when nothing is selected. */
|
||||||
|
selectedName: string | null;
|
||||||
|
/** Called when the user selects an item by name. */
|
||||||
|
onSelect: (name: string) => void;
|
||||||
|
/** Whether the items list is still being fetched. */
|
||||||
|
loading: boolean;
|
||||||
|
/** Error string if the items list failed to load, or null. */
|
||||||
|
error: string | null;
|
||||||
|
/** Detail pane content for the currently selected item. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of skeleton placeholder rows shown while loading. */
|
||||||
|
const SKELETON_COUNT = 5;
|
||||||
|
|
||||||
|
/** Maximum characters shown in a list-item name before ellipsis truncation. */
|
||||||
|
const TOOLTIP_THRESHOLD = 22;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort items so active ones appear first, then alphabetically within each
|
||||||
|
* group.
|
||||||
|
*/
|
||||||
|
function sortItems<T extends { name: string }>(
|
||||||
|
items: T[],
|
||||||
|
isActive: (item: T) => boolean,
|
||||||
|
): T[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const aActive = isActive(a) ? 0 : 1;
|
||||||
|
const bActive = isActive(b) ? 0 : 1;
|
||||||
|
if (aActive !== bActive) return aActive - bActive;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-pane list/detail layout for config tabs.
|
||||||
|
*
|
||||||
|
* @param props - Component props.
|
||||||
|
* @returns JSX element.
|
||||||
|
*/
|
||||||
|
export function ConfigListDetail<T extends { name: string }>({
|
||||||
|
items,
|
||||||
|
isActive,
|
||||||
|
selectedName,
|
||||||
|
onSelect,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}: ConfigListDetailProps<T>): React.JSX.Element {
|
||||||
|
const styles = useConfigStyles();
|
||||||
|
|
||||||
|
const sorted = useMemo(() => sortItems(items, isActive), [items, isActive]);
|
||||||
|
|
||||||
|
/** Ref to the currently focused list item for keyboard navigation. */
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Keyboard navigation for the list pane
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||||
|
if (sorted.length === 0) return;
|
||||||
|
|
||||||
|
const currentIndex = selectedName
|
||||||
|
? sorted.findIndex((item) => item.name === selectedName)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextIdx = currentIndex < sorted.length - 1 ? currentIndex + 1 : 0;
|
||||||
|
const nextItem = sorted[nextIdx];
|
||||||
|
if (nextItem) onSelect(nextItem.name);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
const prevIdx = currentIndex > 0 ? currentIndex - 1 : sorted.length - 1;
|
||||||
|
const prevItem = sorted[prevIdx];
|
||||||
|
if (prevItem) onSelect(prevItem.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sorted, selectedName, onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scroll selected item into view when selection changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listRef.current || !selectedName) return;
|
||||||
|
const el = listRef.current.querySelector<HTMLElement>(
|
||||||
|
`[data-name="${CSS.escape(selectedName)}"]`,
|
||||||
|
);
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [selectedName]);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Dropdown handler (responsive collapse)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const handleDropdownSelect = useCallback(
|
||||||
|
(_ev: SelectionEvents, data: OptionOnSelectData): void => {
|
||||||
|
if (data.optionValue) onSelect(data.optionValue);
|
||||||
|
},
|
||||||
|
[onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Loading / error states
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Skeleton aria-label="Loading configuration items…">
|
||||||
|
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
|
||||||
|
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
||||||
|
))}
|
||||||
|
</Skeleton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{error}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Empty items list
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: tokens.spacingVerticalM,
|
||||||
|
padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalL}`,
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DocumentSearch24Regular
|
||||||
|
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Text size={400}>No items found.</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Full layout
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.listDetailRoot}>
|
||||||
|
{/* ── Left pane: full-width on narrow screens → Dropdown ── */}
|
||||||
|
<div
|
||||||
|
className={styles.listPane}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Configuration items"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
ref={listRef}
|
||||||
|
>
|
||||||
|
{/* Responsive dropdown (visible only on narrow screens via CSS) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "none",
|
||||||
|
}}
|
||||||
|
className="bangui-list-dropdown"
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
aria-label="Select configuration item"
|
||||||
|
value={selectedName ?? ""}
|
||||||
|
selectedOptions={selectedName ? [selectedName] : []}
|
||||||
|
onOptionSelect={handleDropdownSelect}
|
||||||
|
style={{ width: "100%", marginBottom: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
{sorted.map((item) => (
|
||||||
|
<Option key={item.name} value={item.name} text={item.name}>
|
||||||
|
{item.name}
|
||||||
|
{isActive(item) ? " (Active)" : " (Inactive)"}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item list (hidden on narrow screens via CSS) */}
|
||||||
|
<div className="bangui-list-items">
|
||||||
|
{sorted.map((item) => {
|
||||||
|
const active = isActive(item);
|
||||||
|
const selected = item.name === selectedName;
|
||||||
|
const needsTooltip = item.name.length > TOOLTIP_THRESHOLD;
|
||||||
|
|
||||||
|
const itemEl = (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
data-name={item.name}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={[
|
||||||
|
styles.listItem,
|
||||||
|
selected ? styles.listItemSelected : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(item.name);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onSelect(item.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size={300}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flex: 1,
|
||||||
|
marginRight: tokens.spacingHorizontalXS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
appearance={active ? "filled" : "outline"}
|
||||||
|
color={active ? "success" : "informative"}
|
||||||
|
size="small"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return needsTooltip ? (
|
||||||
|
<Tooltip key={item.name} content={item.name} relationship="label">
|
||||||
|
{itemEl}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
itemEl
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right pane: detail content ── */}
|
||||||
|
<div className={styles.detailPane}>
|
||||||
|
{selectedName === null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: tokens.spacingVerticalM,
|
||||||
|
padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalL}`,
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DocumentSearch24Regular
|
||||||
|
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Text size={400}>Select an item from the list.</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/config/RawConfigSection.tsx
Normal file
197
frontend/src/components/config/RawConfigSection.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* RawConfigSection — collapsible raw-text editor for .conf files.
|
||||||
|
*
|
||||||
|
* Renders a single Accordion item. On first expand the raw content is
|
||||||
|
* fetched; the user can edit the textarea and save with a primary button.
|
||||||
|
* Feedback is shown via an {@link AutoSaveIndicator}-style message bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionHeader,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionPanel,
|
||||||
|
Button,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Spinner,
|
||||||
|
Textarea,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RawConfigSectionProps {
|
||||||
|
/** Async function that returns the raw file content string. */
|
||||||
|
fetchContent: () => Promise<string>;
|
||||||
|
/** Async function that saves updated raw content. */
|
||||||
|
saveContent: (content: string) => Promise<void>;
|
||||||
|
/** Label shown in the collapsible header, e.g. "Raw Jail Configuration". */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum visible rows for the monospace text area. */
|
||||||
|
const MIN_ROWS = 15;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible section for viewing and editing raw .conf file content.
|
||||||
|
*
|
||||||
|
* @param props - Component props.
|
||||||
|
* @returns JSX element.
|
||||||
|
*/
|
||||||
|
export function RawConfigSection({
|
||||||
|
fetchContent,
|
||||||
|
saveContent,
|
||||||
|
label = "Raw Configuration",
|
||||||
|
}: RawConfigSectionProps): React.JSX.Element {
|
||||||
|
/** Raw text content; null means not yet loaded. */
|
||||||
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [localText, setLocalText] = useState("");
|
||||||
|
const [fetchLoading, setFetchLoading] = useState(false);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<
|
||||||
|
"idle" | "saving" | "saved" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** Whether the section has been expanded at least once. */
|
||||||
|
const loadedRef = useRef(false);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Called when the accordion panel opens for the first time. */
|
||||||
|
const handleExpand = useCallback(
|
||||||
|
(_: React.SyntheticEvent, data: { openItems: string[] }): void => {
|
||||||
|
const isOpen = data.openItems.includes("raw");
|
||||||
|
if (!isOpen || loadedRef.current) return;
|
||||||
|
loadedRef.current = true;
|
||||||
|
|
||||||
|
setFetchLoading(true);
|
||||||
|
setFetchError(null);
|
||||||
|
|
||||||
|
fetchContent()
|
||||||
|
.then((text) => {
|
||||||
|
setContent(text);
|
||||||
|
setLocalText(text);
|
||||||
|
setFetchLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setFetchError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load raw content.",
|
||||||
|
);
|
||||||
|
setFetchLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[fetchContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async (): Promise<void> => {
|
||||||
|
setSaveStatus("saving");
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
await saveContent(localText);
|
||||||
|
setContent(localText);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error ? err.message : "Failed to save raw content.";
|
||||||
|
setSaveError(msg);
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
}, [localText, saveContent]);
|
||||||
|
|
||||||
|
const handleRetry = useCallback((): void => {
|
||||||
|
void handleSave();
|
||||||
|
}, [handleSave]);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion collapsible onToggle={handleExpand}>
|
||||||
|
<AccordionItem value="raw">
|
||||||
|
<AccordionHeader>
|
||||||
|
<span style={{ fontWeight: tokens.fontWeightSemibold }}>{label}</span>
|
||||||
|
</AccordionHeader>
|
||||||
|
<AccordionPanel>
|
||||||
|
{fetchLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
padding: tokens.spacingVerticalM,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner size="tiny" />
|
||||||
|
<span style={{ color: tokens.colorNeutralForeground3 }}>
|
||||||
|
Loading…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fetchError && (
|
||||||
|
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||||
|
<MessageBarBody>{fetchError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fetchLoading && content !== null && (
|
||||||
|
<>
|
||||||
|
<Textarea
|
||||||
|
value={localText}
|
||||||
|
onChange={(_e, d) => {
|
||||||
|
setLocalText(d.value);
|
||||||
|
}}
|
||||||
|
resize="vertical"
|
||||||
|
rows={MIN_ROWS}
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
width: "100%",
|
||||||
|
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
|
||||||
|
marginBottom: tokens.spacingVerticalS,
|
||||||
|
}}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: tokens.spacingHorizontalS,
|
||||||
|
marginTop: tokens.spacingVerticalXS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void handleSave();
|
||||||
|
}}
|
||||||
|
disabled={saveStatus === "saving"}
|
||||||
|
>
|
||||||
|
Save Raw
|
||||||
|
</Button>
|
||||||
|
<AutoSaveIndicator
|
||||||
|
status={saveStatus}
|
||||||
|
errorText={saveError}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/hooks/useConfigActiveStatus.ts
Normal file
137
frontend/src/hooks/useConfigActiveStatus.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user