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

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

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