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:
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