Refactor frontend pages and config components into single-component files for Task 13

This commit is contained in:
2026-04-19 09:30:35 +02:00
parent 6c053cdaee
commit 38b9d35255
31 changed files with 2158 additions and 2603 deletions

View File

@@ -1,14 +1,6 @@
import { useCallback, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
@@ -32,8 +24,10 @@ import {
PlayRegular,
} from "@fluentui/react-icons";
import { useBlocklists } from "../../hooks/useBlocklist";
import type { BlocklistSource, PreviewResponse } from "../../types/blocklist";
import type { BlocklistSource } from "../../types/blocklist";
import { useBlocklistStyles } from "./blocklistStyles";
import { SourceFormDialog } from "./SourceFormDialog";
import { PreviewDialog } from "./PreviewDialog";
interface SourceFormValues {
name: string;
@@ -41,162 +35,6 @@ interface SourceFormValues {
enabled: boolean;
}
interface SourceFormDialogProps {
open: boolean;
mode: "add" | "edit";
initial: SourceFormValues;
saving: boolean;
error: string | null;
onClose: () => void;
onSubmit: (values: SourceFormValues) => void;
}
function SourceFormDialog({
open,
mode,
initial,
saving,
error,
onClose,
onSubmit,
}: SourceFormDialogProps): React.JSX.Element {
const styles = useBlocklistStyles();
const [values, setValues] = useState<SourceFormValues>(initial);
const handleOpen = useCallback((): void => {
setValues(initial);
}, [initial]);
return (
<Dialog
open={open}
onOpenChange={(_ev, data) => {
if (!data.open) onClose();
}}
>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>{mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"}</DialogTitle>
<DialogContent>
<div className={styles.dialogForm}>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<Field label="Name" required>
<Input
value={values.name}
onChange={(_ev, d) => { setValues((p) => ({ ...p, name: d.value })); }}
placeholder="e.g. Blocklist.de — All"
/>
</Field>
<Field label="URL" required>
<Input
value={values.url}
onChange={(_ev, d) => { setValues((p) => ({ ...p, url: d.value })); }}
placeholder="https://lists.blocklist.de/lists/all.txt"
/>
</Field>
<Switch
label="Enabled"
checked={values.enabled}
onChange={(_ev, d) => { setValues((p) => ({ ...p, enabled: d.checked })); }}
/>
</div>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
appearance="primary"
disabled={saving || !values.name.trim() || !values.url.trim()}
onClick={() => { onSubmit(values); }}
>
{saving ? <Spinner size="tiny" /> : mode === "add" ? "Add" : "Save"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}
interface PreviewDialogProps {
open: boolean;
source: BlocklistSource | null;
onClose: () => void;
fetchPreview: (id: number) => Promise<PreviewResponse>;
}
function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
const styles = useBlocklistStyles();
const [data, setData] = useState<PreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOpen = useCallback((): void => {
if (!source) return;
setData(null);
setError(null);
setLoading(true);
fetchPreview(source.id)
.then((result) => {
setData(result);
setLoading(false);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to fetch preview");
setLoading(false);
});
}, [source, fetchPreview]);
return (
<Dialog
open={open}
onOpenChange={(_ev, d) => {
if (!d.open) onClose();
}}
>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>Preview {source?.name ?? ""}</DialogTitle>
<DialogContent>
{loading && (
<div style={{ textAlign: "center", padding: "16px" }}>
<Spinner label="Downloading…" />
</div>
)}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{data && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<Text size={300}>
{data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}:
</Text>
<div className={styles.previewList}>
{data.entries.map((entry) => (
<div key={entry}>{entry}</div>
))}
</div>
</div>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}
interface SourcesSectionProps {
onRunImport: () => void;
runImportRunning: boolean;

View File

@@ -0,0 +1,83 @@
import { useCallback, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
MessageBar,
MessageBarBody,
Spinner,
Text,
} from "@fluentui/react-components";
import type { BlocklistSource, PreviewResponse } from "../../types/blocklist";
interface PreviewDialogProps {
open: boolean;
source: BlocklistSource | null;
onClose: () => void;
fetchPreview: (id: number) => Promise<PreviewResponse>;
}
export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
const [data, setData] = useState<PreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOpen = useCallback((): void => {
if (!source) return;
setData(null);
setError(null);
setLoading(true);
fetchPreview(source.id)
.then((result) => {
setData(result);
setLoading(false);
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to fetch preview");
setLoading(false);
});
}, [source, fetchPreview]);
return (
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) onClose(); }}>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>Preview {source?.name ?? ""}</DialogTitle>
<DialogContent>
{loading && (
<div style={{ textAlign: "center", padding: "16px" }}>
<Spinner label="Downloading…" />
</div>
)}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{data && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<Text size={300}>
{data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}:
</Text>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{data.entries.map((entry) => (
<div key={entry}>{entry}</div>
))}
</div>
</div>
)}
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}

View File

@@ -0,0 +1,102 @@
import { useCallback, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
MessageBarBody,
Switch,
} from "@fluentui/react-components";
interface SourceFormValues {
name: string;
url: string;
enabled: boolean;
}
interface SourceFormDialogProps {
open: boolean;
mode: "add" | "edit";
initial: SourceFormValues;
saving: boolean;
error: string | null;
onClose: () => void;
onSubmit: (values: SourceFormValues) => void;
}
export function SourceFormDialog({
open,
mode,
initial,
saving,
error,
onClose,
onSubmit,
}: SourceFormDialogProps): React.JSX.Element {
const [values, setValues] = useState<SourceFormValues>(initial);
const handleOpen = useCallback((): void => {
setValues(initial);
}, [initial]);
return (
<Dialog
open={open}
onOpenChange={(_ev, data) => {
if (!data.open) onClose();
}}
>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}>
<DialogBody>
<DialogTitle>{mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"}</DialogTitle>
<DialogContent>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<Field label="Name" required>
<Input
value={values.name}
onChange={(_ev, d) => { setValues((p) => ({ ...p, name: d.value })); }}
placeholder="e.g. Blocklist.de — All"
/>
</Field>
<Field label="URL" required>
<Input
value={values.url}
onChange={(_ev, d) => { setValues((p) => ({ ...p, url: d.value })); }}
placeholder="https://lists.blocklist.de/lists/all.txt"
/>
</Field>
<Switch
label="Enabled"
checked={values.enabled}
onChange={(_ev, d) => { setValues((p) => ({ ...p, enabled: d.checked })); }}
/>
</div>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
appearance="primary"
disabled={saving || !values.name.trim() || !values.url.trim()}
onClick={() => { onSubmit(values); }}
>
{saving ? "Saving…" : mode === "add" ? "Add" : "Save"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}

View File

@@ -0,0 +1,94 @@
import { useCallback, useState } from "react";
import { Button, Field, Input, MessageBar, MessageBarBody } from "@fluentui/react-components";
import { Delete24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
import type { ActionConfig } from "../../types/config";
import { useActionRawFile } from "../../hooks/useActionRawFile";
import { ActionForm } from "./ActionForm";
import { RawConfigSection } from "./RawConfigSection";
import { useConfigStyles } from "./configStyles";
interface ActionDetailProps {
action: ActionConfig;
onAssignClick: () => void;
onRemovedFromJail: (jailName: string) => Promise<void>;
}
export function ActionDetail({ action, onAssignClick, onRemovedFromJail }: ActionDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [removingJail, setRemovingJail] = useState<string | null>(null);
const [removeError, setRemoveError] = useState<string | null>(null);
const { fetchRawContent, saveRawContent } = useActionRawFile(action.name);
const handleRemoveFromJail = useCallback(
(jailName: string): void => {
setRemovingJail(jailName);
setRemoveError(null);
onRemovedFromJail(jailName)
.catch((err: unknown) => {
setRemoveError(err instanceof Error ? err.message : "Failed to remove action from jail.");
})
.finally(() => {
setRemovingJail(null);
});
},
[onRemovedFromJail],
);
return (
<div>
<div className={styles.fieldRow} style={{ marginBottom: "var(--spacingVerticalS)" }}>
<Field label="Source file">
<Input
readOnly
value={action.source_file || action.filename}
className={styles.codeInput}
size="small"
/>
</Field>
</div>
<ActionForm name={action.name} />
<div style={{ marginTop: "var(--spacingVerticalM)" }}>
<Button appearance="secondary" icon={<LinkEdit24Regular />} onClick={onAssignClick}>
Assign to Jail
</Button>
</div>
{action.used_by_jails.length > 0 && (
<div style={{ marginTop: "var(--spacingVerticalM)" }}>
{removeError !== null && (
<MessageBar intent="error" style={{ marginBottom: "var(--spacingVerticalXS)" }}>
<MessageBarBody>{removeError}</MessageBarBody>
</MessageBar>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{action.used_by_jails.map((jailName) => (
<div key={jailName} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontFamily: "monospace", fontSize: 13 }}>{jailName}</span>
<Button
appearance="subtle"
size="small"
icon={<Delete24Regular />}
disabled={removingJail !== null}
onClick={() => { handleRemoveFromJail(jailName); }}
aria-label={`Remove action from ${jailName}`}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
<div style={{ marginTop: "var(--spacingVerticalL)" }}>
<RawConfigSection
fetchContent={fetchRawContent}
saveContent={saveRawContent}
label="Raw Action Configuration"
/>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
/**
* ActionForm — structured form editor for a single ``action.d/*.conf`` file.
* ActionForm — structured form editor for a single `action.d/*.conf` file.
*
* Displays parsed fields grouped into collapsible sections:
* - Includes (before / after)
@@ -11,289 +11,15 @@
* Provides a Save button and shows saving/error state.
*/
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
Textarea,
} from "@fluentui/react-components";
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
import type { ActionConfig, ActionConfigUpdate } from "../../types/config";
import { MessageBar, MessageBarBody, Skeleton, SkeletonItem } from "@fluentui/react-components";
import { useActionConfig } from "../../hooks/useActionConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Editable key-value table for definition_vars / init_vars. */
interface KVEditorProps {
entries: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const rows = Object.entries(entries);
const handleKeyChange = (oldKey: string, newKey: string): void => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const handleValueChange = (key: string, value: string): void => {
onChange({ ...entries, [key]: value });
};
const handleDelete = (key: string): void => {
const { [key]: _removed, ...rest } = entries;
onChange(rest);
};
const handleAdd = (): void => {
let newKey = "new_var";
let n = 1;
while (newKey in entries) {
newKey = `new_var_${String(n)}`;
n++;
}
onChange({ ...entries, [newKey]: "" });
};
return (
<div>
{rows.map(([key, value]) => (
<div key={key} className={styles.fieldRow}>
<Input
value={key}
size="small"
style={{ width: 160, fontFamily: "monospace" }}
aria-label={`Variable name: ${key}`}
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
/>
<Textarea
value={value}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
rows={value.includes("\n") ? 3 : 1}
aria-label={`Value for ${key}`}
onChange={(_e, d) => { handleValueChange(key, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(key); }}
aria-label={`Delete variable ${key}`}
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
Add variable
</Button>
</div>
);
}
/** A Textarea field for a single lifecycle command. */
interface CommandFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
}
function CommandField({ label, value, onChange }: CommandFieldProps): React.JSX.Element {
return (
<Field label={label}>
<Textarea
value={value}
onChange={(_e, d) => { onChange(d.value); }}
rows={value.split("\n").length + 1}
style={{ fontFamily: "monospace", width: "100%" }}
placeholder={`${label} command(s)`}
/>
</Field>
);
}
// ---------------------------------------------------------------------------
// ActionFormEditor
// ---------------------------------------------------------------------------
interface ActionFormEditorProps {
config: ActionConfig;
onSave: (update: ActionConfigUpdate) => Promise<void>;
}
function ActionFormEditor({
config,
onSave,
}: ActionFormEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const [before, setBefore] = useState(config.before ?? "");
const [after, setAfter] = useState(config.after ?? "");
const [actionstart, setActionstart] = useState(config.actionstart ?? "");
const [actionstop, setActionstop] = useState(config.actionstop ?? "");
const [actioncheck, setActioncheck] = useState(config.actioncheck ?? "");
const [actionban, setActionban] = useState(config.actionban ?? "");
const [actionunban, setActionunban] = useState(config.actionunban ?? "");
const [actionflush, setActionflush] = useState(config.actionflush ?? "");
const [definitionVars, setDefinitionVars] = useState<Record<string, string>>(
config.definition_vars
);
const [initVars, setInitVars] = useState<Record<string, string>>(config.init_vars);
// Reset draft when config reloads.
useEffect(() => {
setBefore(config.before ?? "");
setAfter(config.after ?? "");
setActionstart(config.actionstart ?? "");
setActionstop(config.actionstop ?? "");
setActioncheck(config.actioncheck ?? "");
setActionban(config.actionban ?? "");
setActionunban(config.actionunban ?? "");
setActionflush(config.actionflush ?? "");
setDefinitionVars(config.definition_vars);
setInitVars(config.init_vars);
}, [config]);
const autoSavePayload = useMemo<ActionConfigUpdate>(() => ({
before: before.trim() || null,
after: after.trim() || null,
actionstart: actionstart.trim() || null,
actionstop: actionstop.trim() || null,
actioncheck: actioncheck.trim() || null,
actionban: actionban.trim() || null,
actionunban: actionunban.trim() || null,
actionflush: actionflush.trim() || null,
definition_vars: definitionVars,
init_vars: initVars,
}), [
after, actionban, actioncheck, actionflush, actionstart,
actionstop, actionunban, before, definitionVars, initVars,
]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, onSave);
const defVarCount = Object.keys(definitionVars).length;
const initVarCount = Object.keys(initVars).length;
return (
<div>
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">
{config.filename}
</Text>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
<Accordion multiple collapsible defaultOpenItems={["lifecycle"]}>
{/* Includes */}
<AccordionItem value="includes" className={styles.accordionItem}>
<AccordionHeader>Includes</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="before">
<Input
value={before}
onChange={(_e, d) => { setBefore(d.value); }}
placeholder="e.g. iptables-common.conf"
className={styles.codeInput}
/>
</Field>
<Field label="after">
<Input
value={after}
onChange={(_e, d) => { setAfter(d.value); }}
className={styles.codeInput}
/>
</Field>
</div>
</AccordionPanel>
</AccordionItem>
{/* Lifecycle commands */}
<AccordionItem value="lifecycle" className={styles.accordionItem}>
<AccordionHeader>Lifecycle commands</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<CommandField label="actionstart" value={actionstart} onChange={setActionstart} />
<CommandField label="actionstop" value={actionstop} onChange={setActionstop} />
<CommandField label="actioncheck" value={actioncheck} onChange={setActioncheck} />
<CommandField label="actionban" value={actionban} onChange={setActionban} />
<CommandField label="actionunban" value={actionunban} onChange={setActionunban} />
<CommandField label="actionflush" value={actionflush} onChange={setActionflush} />
</div>
</AccordionPanel>
</AccordionItem>
{/* Definition variables */}
<AccordionItem value="definition_vars" className={styles.accordionItem}>
<AccordionHeader>
{`Definition variables (${String(defVarCount)})`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={definitionVars} onChange={setDefinitionVars} />
</div>
</AccordionPanel>
</AccordionItem>
{/* Init variables */}
<AccordionItem value="init_vars" className={styles.accordionItem}>
<AccordionHeader>
{`Init variables (${String(initVarCount)})`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={initVars} onChange={setInitVars} />
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}
// ---------------------------------------------------------------------------
// ActionForm (public export)
// ---------------------------------------------------------------------------
import { ActionFormEditor } from "./ActionFormEditor";
export interface ActionFormProps {
/** Action base name (e.g. ``"iptables"``). */
/** Action base name (e.g. `"iptables"`). */
name: string;
}
/**
* Loads and renders the structured form editor for one action.
*/
export function ActionForm({ name }: ActionFormProps): React.JSX.Element {
const { config, loading, error, save } = useActionConfig(name);
@@ -318,11 +44,5 @@ export function ActionForm({ name }: ActionFormProps): React.JSX.Element {
);
}
return (
<ActionFormEditor
config={config}
onSave={save}
/>
);
return <ActionFormEditor config={config} onSave={save} />;
}

View File

@@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Field,
Input,
Text,
} from "@fluentui/react-components";
import type { ActionConfig, ActionConfigUpdate } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
import { CommandField } from "./CommandField";
import { KVEditor } from "./KVEditor";
interface ActionFormEditorProps {
config: ActionConfig;
onSave: (update: ActionConfigUpdate) => Promise<void>;
}
export function ActionFormEditor({ config, onSave }: ActionFormEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const [before, setBefore] = useState(config.before ?? "");
const [after, setAfter] = useState(config.after ?? "");
const [actionstart, setActionstart] = useState(config.actionstart ?? "");
const [actionstop, setActionstop] = useState(config.actionstop ?? "");
const [actioncheck, setActioncheck] = useState(config.actioncheck ?? "");
const [actionban, setActionban] = useState(config.actionban ?? "");
const [actionunban, setActionunban] = useState(config.actionunban ?? "");
const [actionflush, setActionflush] = useState(config.actionflush ?? "");
const [definitionVars, setDefinitionVars] = useState<Record<string, string>>(config.definition_vars);
const [initVars, setInitVars] = useState<Record<string, string>>(config.init_vars);
useEffect(() => {
setBefore(config.before ?? "");
setAfter(config.after ?? "");
setActionstart(config.actionstart ?? "");
setActionstop(config.actionstop ?? "");
setActioncheck(config.actioncheck ?? "");
setActionban(config.actionban ?? "");
setActionunban(config.actionunban ?? "");
setActionflush(config.actionflush ?? "");
setDefinitionVars(config.definition_vars);
setInitVars(config.init_vars);
}, [config]);
const autoSavePayload = useMemo<ActionConfigUpdate>(() => ({
before: before.trim() || null,
after: after.trim() || null,
actionstart: actionstart.trim() || null,
actionstop: actionstop.trim() || null,
actioncheck: actioncheck.trim() || null,
actionban: actionban.trim() || null,
actionunban: actionunban.trim() || null,
actionflush: actionflush.trim() || null,
definition_vars: definitionVars,
init_vars: initVars,
}), [after, actionban, actioncheck, actionflush, actionstart, actionstop, actionunban, before, definitionVars, initVars]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = useAutoSave(autoSavePayload, onSave);
const defVarCount = Object.keys(definitionVars).length;
const initVarCount = Object.keys(initVars).length;
return (
<div>
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">{config.filename}</Text>
<AutoSaveIndicator status={saveStatus} errorText={saveErrorText} onRetry={retrySave} />
</div>
<Accordion multiple collapsible defaultOpenItems={["lifecycle"]}>
<AccordionItem value="includes" className={styles.accordionItem}>
<AccordionHeader>Includes</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="before">
<Input
value={before}
onChange={(_e, d) => { setBefore(d.value); }}
placeholder="e.g. iptables-common.conf"
className={styles.codeInput}
/>
</Field>
<Field label="after">
<Input
value={after}
onChange={(_e, d) => { setAfter(d.value); }}
className={styles.codeInput}
/>
</Field>
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="lifecycle" className={styles.accordionItem}>
<AccordionHeader>Lifecycle commands</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<CommandField label="actionstart" value={actionstart} onChange={setActionstart} />
<CommandField label="actionstop" value={actionstop} onChange={setActionstop} />
<CommandField label="actioncheck" value={actioncheck} onChange={setActioncheck} />
<CommandField label="actionban" value={actionban} onChange={setActionban} />
<CommandField label="actionunban" value={actionunban} onChange={setActionunban} />
<CommandField label="actionflush" value={actionflush} onChange={setActionflush} />
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="definition_vars" className={styles.accordionItem}>
<AccordionHeader>{`Definition variables (${String(defVarCount)})`}</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={definitionVars} onChange={setDefinitionVars} />
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="init_vars" className={styles.accordionItem}>
<AccordionHeader>{`Init variables (${String(initVarCount)})`}</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={initVars} onChange={setInitVars} />
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -7,176 +7,31 @@
* "Remove from Jail" section, and a collapsible raw-config editor.
*
* A "Create Action" button at the top of the list pane opens a dialog for
* creating a new ``action.d/*.local`` file.
* creating a new `action.d/*.local` file.
*/
import { useCallback, useMemo, useState } from "react";
import {
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
tokens,
} from "@fluentui/react-components";
import { Add24Regular, Delete24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
import { Add24Regular } from "@fluentui/react-icons";
import type { ActionConfig } from "../../types/config";
import { ActionForm } from "./ActionForm";
import { useActionList } from "../../hooks/useActionList";
import { useActionRawFile } from "../../hooks/useActionRawFile";
import { AssignActionDialog } from "./AssignActionDialog";
import { ConfigListDetail } from "./ConfigListDetail";
import { CreateActionDialog } from "./CreateActionDialog";
import { RawConfigSection } from "./RawConfigSection";
import { useConfigStyles } from "./configStyles";
import { ActionDetail } from "./ActionDetail";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build the badge label text shown next to each action in the list pane.
*/
function actionBadgeLabel(a: ActionConfig): string {
if (!a.active) return "Inactive";
if (a.used_by_jails.length === 0) return "Active";
return `Active — ${a.used_by_jails.join(", ")}`;
}
// ---------------------------------------------------------------------------
// ActionDetail — right-pane detail for a selected action
// ---------------------------------------------------------------------------
interface ActionDetailProps {
action: ActionConfig;
onAssignClick: () => void;
onRemovedFromJail: (jailName: string) => Promise<void>;
}
/**
* Detail pane for a selected action: shows meta information, the structured
* editor, assign/remove jail actions, and a raw-config section.
*
* @param props - Component props.
* @returns JSX element.
*/
function ActionDetail({
action,
onAssignClick,
onRemovedFromJail,
}: ActionDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [removingJail, setRemovingJail] = useState<string | null>(null);
const [removeError, setRemoveError] = useState<string | null>(null);
const { fetchRawContent, saveRawContent } = useActionRawFile(action.name);
const handleRemoveFromJail = useCallback(
(jailName: string): void => {
setRemovingJail(jailName);
setRemoveError(null);
onRemovedFromJail(jailName)
.then(() => {
// No-op: parent refreshes the list.
})
.catch((err: unknown) => {
setRemoveError(
err instanceof Error ? err.message : "Failed to remove action from jail.",
);
})
.finally(() => {
setRemovingJail(null);
});
},
[onRemovedFromJail],
);
return (
<div>
{/* Meta information row */}
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
<Field label="Source file">
<Input
readOnly
value={action.source_file || action.filename}
className={styles.codeInput}
size="small"
/>
</Field>
</div>
{/* Structured editor */}
<ActionForm name={action.name} />
{/* Assign to jail action */}
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="secondary"
icon={<LinkEdit24Regular />}
onClick={onAssignClick}
>
Assign to Jail
</Button>
</div>
{/* Remove from jail section */}
{action.used_by_jails.length > 0 && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
{removeError !== null && (
<MessageBar
intent="error"
style={{ marginBottom: tokens.spacingVerticalXS }}
>
<MessageBarBody>{removeError}</MessageBarBody>
</MessageBar>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{action.used_by_jails.map((jailName) => (
<div
key={jailName}
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span style={{ fontFamily: "monospace", fontSize: 13 }}>
{jailName}
</span>
<Button
appearance="subtle"
size="small"
icon={<Delete24Regular />}
disabled={removingJail !== null}
onClick={() => { handleRemoveFromJail(jailName); }}
aria-label={`Remove action from ${jailName}`}
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
{/* Raw config */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRawContent}
saveContent={saveRawContent}
label="Raw Action Configuration"
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// ActionsTab (public export)
// ---------------------------------------------------------------------------
/**
* Tab component for exploring and editing fail2ban action definitions.
*
* @returns JSX element.
*/
export function ActionsTab(): React.JSX.Element {
const {
actions,
@@ -191,7 +46,6 @@ export function ActionsTab(): React.JSX.Element {
const [assignOpen, setAssignOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
/** The full ActionConfig for the currently selected name. */
const selectedAction = useMemo(
() => actions.find((a) => a.name === selectedName) ?? null,
[actions, selectedName],
@@ -290,4 +144,3 @@ export function ActionsTab(): React.JSX.Element {
</>
);
}

View File

@@ -0,0 +1,21 @@
import { Field, Textarea } from "@fluentui/react-components";
interface CommandFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
}
export function CommandField({ label, value, onChange }: CommandFieldProps): React.JSX.Element {
return (
<Field label={label}>
<Textarea
value={value}
onChange={(_e, d) => { onChange(d.value); }}
rows={value.split("\n").length + 1}
style={{ fontFamily: "monospace", width: "100%" }}
placeholder={`${label} command(s)`}
/>
</Field>
);
}

View File

@@ -0,0 +1,48 @@
import { Button, Field, Input } from "@fluentui/react-components";
import { LinkEdit24Regular } from "@fluentui/react-icons";
import type { FilterConfig } from "../../types/config";
import { useFilterRawFile } from "../../hooks/useFilterRawFile";
import { FilterForm } from "./FilterForm";
import { RawConfigSection } from "./RawConfigSection";
import { useConfigStyles } from "./configStyles";
interface FilterDetailProps {
filter: FilterConfig;
onAssignClick: () => void;
}
export function FilterDetail({ filter, onAssignClick }: FilterDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const { fetchRawContent, saveRawContent } = useFilterRawFile(filter.name);
return (
<div>
<div className={styles.fieldRow} style={{ marginBottom: "var(--spacingVerticalS)" }}>
<Field label="Source file">
<Input
readOnly
value={filter.source_file || filter.filename}
className={styles.codeInput}
size="small"
/>
</Field>
</div>
<FilterForm name={filter.name} />
<div style={{ marginTop: "var(--spacingVerticalM)" }}>
<Button appearance="secondary" icon={<LinkEdit24Regular />} onClick={onAssignClick}>
Assign to Jail
</Button>
</div>
<div style={{ marginTop: "var(--spacingVerticalL)" }}>
<RawConfigSection
fetchContent={fetchRawContent}
saveContent={saveRawContent}
label="Raw Filter Configuration"
/>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
/**
* FilterForm — structured form editor for a single ``filter.d/*.conf`` file.
* FilterForm — structured form editor for a single `filter.d/*.conf` file.
*
* Displays parsed fields grouped into collapsible sections:
* - Includes (before / after)
@@ -9,296 +9,15 @@
* Provides a Save button and shows saving/error state.
*/
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
Textarea,
} from "@fluentui/react-components";
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
import type { FilterConfig, FilterConfigUpdate } from "../../types/config";
import { MessageBar, MessageBarBody, Skeleton, SkeletonItem } from "@fluentui/react-components";
import { useFilterConfig } from "../../hooks/useFilterConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { RegexList } from "./RegexList";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Simple editable key-value table for [DEFAULT] variables. */
interface KVEditorProps {
entries: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const rows = Object.entries(entries);
const handleKeyChange = (oldKey: string, newKey: string): void => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const handleValueChange = (key: string, value: string): void => {
onChange({ ...entries, [key]: value });
};
const handleDelete = (key: string): void => {
const { [key]: _removed, ...rest } = entries;
onChange(rest);
};
const handleAdd = (): void => {
let newKey = "new_var";
let n = 1;
while (newKey in entries) {
newKey = `new_var_${String(n)}`;
n++;
}
onChange({ ...entries, [newKey]: "" });
};
return (
<div>
{rows.map(([key, value]) => (
<div key={key} className={styles.fieldRow}>
<Input
value={key}
size="small"
style={{ width: 160, fontFamily: "monospace" }}
aria-label={`Variable name: ${key}`}
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
/>
<Input
value={value}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
aria-label={`Value for ${key}`}
onChange={(_e, d) => { handleValueChange(key, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(key); }}
aria-label={`Delete variable ${key}`}
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
Add variable
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// FilterFormEditor — rendered once config is loaded
// ---------------------------------------------------------------------------
interface FilterFormEditorProps {
config: FilterConfig;
onSave: (update: FilterConfigUpdate) => Promise<void>;
}
function FilterFormEditor({
config,
onSave,
}: FilterFormEditorProps): React.JSX.Element {
const styles = useConfigStyles();
// Local draft state — initialised from the loaded config.
const [before, setBefore] = useState(config.before ?? "");
const [after, setAfter] = useState(config.after ?? "");
const [variables, setVariables] = useState<Record<string, string>>(config.variables);
const [prefregex, setPrefregex] = useState(config.prefregex ?? "");
const [failregex, setFailregex] = useState<string[]>(config.failregex);
const [ignoreregex, setIgnoreregex] = useState<string[]>(config.ignoreregex);
const [maxlines, setMaxlines] = useState(
config.maxlines !== null ? String(config.maxlines) : ""
);
const [datepattern, setDatepattern] = useState(config.datepattern ?? "");
const [journalmatch, setJournalmatch] = useState(config.journalmatch ?? "");
// Reset draft whenever a freshly-loaded config arrives.
useEffect(() => {
setBefore(config.before ?? "");
setAfter(config.after ?? "");
setVariables(config.variables);
setPrefregex(config.prefregex ?? "");
setFailregex(config.failregex);
setIgnoreregex(config.ignoreregex);
setMaxlines(config.maxlines !== null ? String(config.maxlines) : "");
setDatepattern(config.datepattern ?? "");
setJournalmatch(config.journalmatch ?? "");
}, [config]);
const autoSavePayload = useMemo<FilterConfigUpdate>(() => {
const parsedMax = maxlines.trim() !== "" ? parseInt(maxlines, 10) : null;
return {
before: before.trim() || null,
after: after.trim() || null,
variables,
prefregex: prefregex.trim() || null,
failregex,
ignoreregex,
maxlines: parsedMax !== null && !isNaN(parsedMax) ? parsedMax : null,
datepattern: datepattern.trim() || null,
journalmatch: journalmatch.trim() || null,
};
}, [after, before, datepattern, failregex, ignoreregex, journalmatch, maxlines, prefregex, variables]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, onSave);
const hasIncludes = config.before !== null || config.after !== null;
const varCount = Object.keys(variables).length;
return (
<div>
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">
{config.filename}
</Text>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
<Accordion multiple collapsible defaultOpenItems={["definition"]}>
{/* Includes */}
<AccordionItem value="includes" className={styles.accordionItem}>
<AccordionHeader>
{`Includes${hasIncludes ? "" : " (none)"}`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="before">
<Input
value={before}
onChange={(_e, d) => { setBefore(d.value); }}
placeholder="e.g. common.conf"
className={styles.codeInput}
/>
</Field>
<Field label="after">
<Input
value={after}
onChange={(_e, d) => { setAfter(d.value); }}
placeholder="e.g. sshd-aggressive.conf"
className={styles.codeInput}
/>
</Field>
</div>
</AccordionPanel>
</AccordionItem>
{/* Variables */}
<AccordionItem value="variables" className={styles.accordionItem}>
<AccordionHeader>
{`Variables (${String(varCount)})`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={variables} onChange={setVariables} />
</div>
</AccordionPanel>
</AccordionItem>
{/* Definition */}
<AccordionItem value="definition" className={styles.accordionItem}>
<AccordionHeader>Definition</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="prefregex">
<Textarea
value={prefregex}
onChange={(_e, d) => { setPrefregex(d.value); }}
placeholder="Prefix regex prepended to all failregex patterns"
className={styles.codeInput}
rows={2}
/>
</Field>
<RegexList label="failregex" patterns={failregex} onChange={setFailregex} />
<RegexList label="ignoreregex" patterns={ignoreregex} onChange={setIgnoreregex} />
{/* Advanced optional fields */}
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: "pointer", userSelect: "none", marginBottom: 8 }}>
Advanced options
</summary>
<Field label="maxlines">
<Input
type="number"
value={maxlines}
onChange={(_e, d) => { setMaxlines(d.value); }}
placeholder="e.g. 10"
style={{ width: 100 }}
/>
</Field>
<Field label="datepattern">
<Input
value={datepattern}
onChange={(_e, d) => { setDatepattern(d.value); }}
placeholder="e.g. %Y-%m-%d %H:%M:%S"
className={styles.codeInput}
/>
</Field>
<Field label="journalmatch">
<Input
value={journalmatch}
onChange={(_e, d) => { setJournalmatch(d.value); }}
placeholder="e.g. _SYSTEMD_UNIT=sshd.service"
className={styles.codeInput}
/>
</Field>
</details>
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}
// ---------------------------------------------------------------------------
// FilterForm (public export)
// ---------------------------------------------------------------------------
import { FilterFormEditor } from "./FilterFormEditor";
export interface FilterFormProps {
/** Filter base name (e.g. ``"sshd"``). */
/** Filter base name (e.g. `"sshd"`). */
name: string;
}
/**
* Loads and renders the structured form editor for one filter.
* Shows a spinner while loading and an error message on failure.
*/
export function FilterForm({ name }: FilterFormProps): React.JSX.Element {
const { config, loading, error, save } = useFilterConfig(name);
@@ -309,7 +28,7 @@ export function FilterForm({ name }: FilterFormProps): React.JSX.Element {
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
<SkeletonItem size={32} style={{ marginBottom: 8 }} />
<SkeletonItem size={72} style={{ marginBottom: 8 }} />
<SkeletonItem size={72} />
</Skeleton>
);
@@ -323,10 +42,5 @@ export function FilterForm({ name }: FilterFormProps): React.JSX.Element {
);
}
return (
<FilterFormEditor
config={config}
onSave={save}
/>
);
return <FilterFormEditor config={config} onSave={save} />;
}

View File

@@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Field,
Input,
Text,
Textarea,
} from "@fluentui/react-components";
import type { FilterConfig, FilterConfigUpdate } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { KVEditor } from "./KVEditor";
import { RegexList } from "./RegexList";
import { useConfigStyles } from "./configStyles";
interface FilterFormEditorProps {
config: FilterConfig;
onSave: (update: FilterConfigUpdate) => Promise<void>;
}
export function FilterFormEditor({ config, onSave }: FilterFormEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const [before, setBefore] = useState(config.before ?? "");
const [after, setAfter] = useState(config.after ?? "");
const [variables, setVariables] = useState<Record<string, string>>(config.variables);
const [prefregex, setPrefregex] = useState(config.prefregex ?? "");
const [failregex, setFailregex] = useState<string[]>(config.failregex);
const [ignoreregex, setIgnoreregex] = useState<string[]>(config.ignoreregex);
const [maxlines, setMaxlines] = useState(config.maxlines !== null ? String(config.maxlines) : "");
const [datepattern, setDatepattern] = useState(config.datepattern ?? "");
const [journalmatch, setJournalmatch] = useState(config.journalmatch ?? "");
useEffect(() => {
setBefore(config.before ?? "");
setAfter(config.after ?? "");
setVariables(config.variables);
setPrefregex(config.prefregex ?? "");
setFailregex(config.failregex);
setIgnoreregex(config.ignoreregex);
setMaxlines(config.maxlines !== null ? String(config.maxlines) : "");
setDatepattern(config.datepattern ?? "");
setJournalmatch(config.journalmatch ?? "");
}, [config]);
const autoSavePayload = useMemo<FilterConfigUpdate>(() => {
const parsedMax = maxlines.trim() !== "" ? parseInt(maxlines, 10) : null;
return {
before: before.trim() || null,
after: after.trim() || null,
variables,
prefregex: prefregex.trim() || null,
failregex,
ignoreregex,
maxlines: parsedMax !== null && !isNaN(parsedMax) ? parsedMax : null,
datepattern: datepattern.trim() || null,
journalmatch: journalmatch.trim() || null,
};
}, [after, before, datepattern, failregex, ignoreregex, journalmatch, maxlines, prefregex, variables]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = useAutoSave(autoSavePayload, onSave);
const hasIncludes = config.before !== null || config.after !== null;
const varCount = Object.keys(variables).length;
return (
<div>
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">{config.filename}</Text>
<AutoSaveIndicator status={saveStatus} errorText={saveErrorText} onRetry={retrySave} />
</div>
<Accordion multiple collapsible defaultOpenItems={["definition"]}>
<AccordionItem value="includes" className={styles.accordionItem}>
<AccordionHeader>{`Includes${hasIncludes ? "" : " (none)"}`}</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="before">
<Input
value={before}
onChange={(_e, d) => { setBefore(d.value); }}
placeholder="e.g. common.conf"
className={styles.codeInput}
/>
</Field>
<Field label="after">
<Input
value={after}
onChange={(_e, d) => { setAfter(d.value); }}
placeholder="e.g. sshd-aggressive.conf"
className={styles.codeInput}
/>
</Field>
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="variables" className={styles.accordionItem}>
<AccordionHeader>{`Variables (${String(varCount)})`}</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={variables} onChange={setVariables} />
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="definition" className={styles.accordionItem}>
<AccordionHeader>Definition</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="prefregex">
<Textarea
value={prefregex}
onChange={(_e, d) => { setPrefregex(d.value); }}
placeholder="Prefix regex prepended to all failregex patterns"
className={styles.codeInput}
rows={2}
/>
</Field>
<RegexList label="failregex" patterns={failregex} onChange={setFailregex} />
<RegexList label="ignoreregex" patterns={ignoreregex} onChange={setIgnoreregex} />
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: "pointer", userSelect: "none", marginBottom: 8 }}>
Advanced options
</summary>
<Field label="maxlines">
<Input
type="number"
value={maxlines}
onChange={(_e, d) => { setMaxlines(d.value); }}
placeholder="e.g. 10"
style={{ width: 100 }}
/>
</Field>
<Field label="datepattern">
<Input
value={datepattern}
onChange={(_e, d) => { setDatepattern(d.value); }}
placeholder="e.g. %Y-%m-%d %H:%M:%S"
className={styles.codeInput}
/>
</Field>
<Field label="journalmatch">
<Input
value={journalmatch}
onChange={(_e, d) => { setJournalmatch(d.value); }}
placeholder="e.g. _SYSTEMD_UNIT=sshd.service"
className={styles.codeInput}
/>
</Field>
</details>
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -7,119 +7,31 @@
* collapsible raw-config editor.
*
* A "Create Filter" button at the top of the list pane opens a dialog for
* creating a new ``filter.d/*.local`` file.
* creating a new `filter.d/*.local` file.
*/
import { useCallback, useMemo, useState } from "react";
import {
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
tokens,
} from "@fluentui/react-components";
import { Add24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
import { Add24Regular } from "@fluentui/react-icons";
import type { FilterConfig } from "../../types/config";
import { AssignFilterDialog } from "./AssignFilterDialog";
import { ConfigListDetail } from "./ConfigListDetail";
import { CreateFilterDialog } from "./CreateFilterDialog";
import { FilterForm } from "./FilterForm";
import { RawConfigSection } from "./RawConfigSection";
import { useFilterList } from "../../hooks/useFilterList";
import { useFilterRawFile } from "../../hooks/useFilterRawFile";
import { useConfigStyles } from "./configStyles";
import { FilterDetail } from "./FilterDetail";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build the badge label text shown next to each filter in the list pane.
*
* Active filters that are used by one or more jails display the jail names,
* e.g. ``"Active — sshd, apache-auth"``.
*/
function filterBadgeLabel(f: FilterConfig): string {
if (!f.active) return "Inactive";
if (f.used_by_jails.length === 0) return "Active";
return `Active — ${f.used_by_jails.join(", ")}`;
}
// ---------------------------------------------------------------------------
// FilterDetail — right-pane detail for a selected filter
// ---------------------------------------------------------------------------
interface FilterDetailProps {
filter: FilterConfig;
onAssignClick: () => void;
}
/**
* Detail pane for a selected filter: shows meta information, the structured
* editor, an "Assign to Jail" action, and a raw-config section.
*
* @param props - Component props.
* @returns JSX element.
*/
function FilterDetail({
filter,
onAssignClick,
}: FilterDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const { fetchRawContent, saveRawContent } = useFilterRawFile(filter.name);
return (
<div>
{/* Meta information row */}
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
<Field label="Source file">
<Input
readOnly
value={filter.source_file || filter.filename}
className={styles.codeInput}
size="small"
/>
</Field>
</div>
{/* Structured editor */}
<FilterForm name={filter.name} />
{/* Assign to jail action */}
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="secondary"
icon={<LinkEdit24Regular />}
onClick={onAssignClick}
>
Assign to Jail
</Button>
</div>
{/* Raw config */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRawContent}
saveContent={saveRawContent}
label="Raw Filter Configuration"
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// FiltersTab (public export)
// ---------------------------------------------------------------------------
/**
* Tab component for exploring and editing fail2ban filter definitions.
*
* @returns JSX element.
*/
export function FiltersTab(): React.JSX.Element {
const {
filters,
@@ -133,7 +45,6 @@ export function FiltersTab(): React.JSX.Element {
const [assignOpen, setAssignOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
/** The full FilterConfig for the currently selected name. */
const selectedFilter = useMemo(
() => filters.find((f) => f.name === selectedName) ?? null,
[filters, selectedName],

View File

@@ -1,446 +1,20 @@
/**
* JailFileForm — structured form editor for a single ``jail.d/*.conf`` file.
* JailFileForm — structured form editor for a single `jail.d/*.conf` file.
*
* Renders each jail section in the file as an accordion panel with fields for
* all common jail settings plus log paths, action references, and extra keys.
*
* All fields auto-save through ``useAutoSave``.
* All fields auto-save through `useAutoSave`.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Badge,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Skeleton,
SkeletonItem,
Switch,
Text,
tokens,
} from "@fluentui/react-components";
import { Add24Regular, Delete24Regular, DocumentAdd24Regular } from "@fluentui/react-icons";
import type { JailFileConfig, JailFileConfigUpdate, JailSectionConfig } from "../../types/config";
import { MessageBar, MessageBarBody, Skeleton, SkeletonItem } from "@fluentui/react-components";
import { useJailFileConfig } from "../../hooks/useJailFileConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// StringListEditor — add / remove lines in a list field
// ---------------------------------------------------------------------------
interface StringListEditorProps {
items: string[];
onChange: (next: string[]) => void;
placeholder?: string;
addLabel?: string;
}
function StringListEditor({
items,
onChange,
placeholder,
addLabel = "Add entry",
}: StringListEditorProps): React.JSX.Element {
const handleChange = (index: number, value: string): void => {
const next = [...items];
next[index] = value;
onChange(next);
};
const handleDelete = (index: number): void => {
onChange(items.filter((_, i) => i !== index));
};
const handleAdd = (): void => {
onChange([...items, ""]);
};
return (
<div>
{items.map((item, index) => (
<div key={index} style={{ display: "flex", gap: 4, marginBottom: 4, alignItems: "center" }}>
<Input
value={item}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
placeholder={placeholder}
aria-label={`Entry ${String(index + 1)}${item ? `: ${item}` : ""}`}
onChange={(_e, d) => { handleChange(index, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(index); }}
aria-label="Remove entry"
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
{addLabel}
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// KVEditor — key-value pair list (for extra settings)
// ---------------------------------------------------------------------------
interface KVEditorProps {
entries: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const rows = Object.entries(entries);
const handleKeyChange = (oldKey: string, newKey: string): void => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const handleValueChange = (key: string, value: string): void => {
onChange({ ...entries, [key]: value });
};
const handleDelete = (key: string): void => {
const { [key]: _removed, ...rest } = entries;
onChange(rest);
};
const handleAdd = (): void => {
let newKey = "key";
let n = 1;
while (newKey in entries) {
newKey = `key${String(n)}`;
n++;
}
onChange({ ...entries, [newKey]: "" });
};
return (
<div>
{rows.map(([key, value]) => (
<div key={key} className={styles.fieldRow}>
<Input
value={key}
size="small"
style={{ width: 140, fontFamily: "monospace" }}
aria-label={`Setting name: ${key}`}
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
/>
<Input
value={value}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
aria-label={`Value for ${key}`}
onChange={(_e, d) => { handleValueChange(key, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(key); }}
aria-label={`Delete key ${key}`}
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
Add setting
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// JailSectionPanel — form fields for one [jailname] section
// ---------------------------------------------------------------------------
const BACKENDS = ["", "auto", "polling", "gamin", "pyinotify", "systemd"] as const;
interface JailSectionPanelProps {
jailName: string;
section: JailSectionConfig;
onChange: (next: JailSectionConfig) => void;
}
function JailSectionPanel({
jailName,
section,
onChange,
}: JailSectionPanelProps): React.JSX.Element {
const styles = useConfigStyles();
const update = useCallback(
(patch: Partial<JailSectionConfig>): void => {
onChange({ ...section, ...patch });
},
[onChange, section]
);
return (
<div>
{/* Core fields grid */}
<div className={styles.sectionCard}>
<div className={styles.fieldRow}>
<Field label="Enabled">
<Switch
checked={section.enabled ?? false}
onChange={(_e, d) => { update({ enabled: d.checked }); }}
aria-label={`Enable jail ${jailName}`}
/>
</Field>
<Field label="Port">
<Input
value={section.port ?? ""}
size="small"
placeholder="e.g. ssh or 22"
onChange={(_e, d) => { update({ port: d.value || null }); }}
/>
</Field>
<Field label="Filter">
<Input
value={section.filter ?? ""}
size="small"
placeholder="e.g. sshd"
className={styles.codeInput}
onChange={(_e, d) => { update({ filter: d.value || null }); }}
/>
</Field>
<Field label="Backend">
<Select
size="small"
value={section.backend ?? ""}
onChange={(_e, d) => { update({ backend: d.value || null }); }}
>
{BACKENDS.map((b) => (
<option key={b} value={b}>{b === "" ? "(default)" : b}</option>
))}
</Select>
</Field>
<Field label="Max Retry">
<Input
value={section.maxretry !== null ? String(section.maxretry) : ""}
size="small"
type="number"
placeholder="e.g. 5"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ maxretry: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
<Field label="Find Time (s)">
<Input
value={section.findtime !== null ? String(section.findtime) : ""}
size="small"
type="number"
placeholder="e.g. 600"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ findtime: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
<Field label="Ban Time (s)">
<Input
value={section.bantime !== null ? String(section.bantime) : ""}
size="small"
type="number"
placeholder="e.g. 3600"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ bantime: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
</div>
</div>
{/* Log paths */}
<Accordion multiple collapsible defaultOpenItems={["logpath"]}>
<AccordionItem value="logpath" className={styles.accordionItem}>
<AccordionHeader>Log Paths ({section.logpath.length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<StringListEditor
items={section.logpath}
onChange={(next) => { update({ logpath: next }); }}
placeholder="e.g. /var/log/auth.log"
addLabel="Add log path"
/>
</div>
</AccordionPanel>
</AccordionItem>
{/* Actions */}
<AccordionItem value="actions" className={styles.accordionItem}>
<AccordionHeader>Actions ({section.action.length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<StringListEditor
items={section.action}
onChange={(next) => { update({ action: next }); }}
placeholder="e.g. iptables-multiport[name=SSH, port=ssh]"
addLabel="Add action"
/>
</div>
</AccordionPanel>
</AccordionItem>
{/* Extra settings */}
{(Object.keys(section.extra).length > 0) && (
<AccordionItem value="extra" className={styles.accordionItem}>
<AccordionHeader>Extra Settings ({Object.keys(section.extra).length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor
entries={section.extra}
onChange={(next) => { update({ extra: next }); }}
/>
</div>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
</div>
);
}
// ---------------------------------------------------------------------------
// JailFileFormInner — rendered once config is loaded
// ---------------------------------------------------------------------------
interface JailFileFormInnerProps {
config: JailFileConfig;
onSave: (update: JailFileConfigUpdate) => Promise<void>;
}
function JailFileFormInner({
config,
onSave,
}: JailFileFormInnerProps): React.JSX.Element {
const [jails, setJails] = useState<Record<string, JailSectionConfig>>(config.jails);
// Reset when a freshly-loaded config arrives.
useEffect(() => {
setJails(config.jails);
}, [config]);
const autoSavePayload = useMemo<JailFileConfigUpdate>(
() => ({ jails }),
[jails]
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, onSave);
const handleSectionChange = useCallback(
(jailName: string, next: JailSectionConfig): void => {
setJails((prev) => ({ ...prev, [jailName]: next }));
},
[]
);
const styles = useConfigStyles();
const jailNames = Object.keys(jails);
return (
<div>
{/* Header row */}
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">{config.filename}</Text>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
{jailNames.length === 0 ? (
<div className={styles.emptyState}>
<DocumentAdd24Regular
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No jail sections found in this file.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Add a <code>[jailname]</code> section to the file to define jails.
</Text>
</div>
) : (
<Accordion multiple collapsible defaultOpenItems={jailNames}>
{jailNames.map((jailName) => {
const section = jails[jailName];
if (section === undefined) return null;
return (
<AccordionItem key={jailName} value={jailName} className={styles.accordionItem}>
<AccordionHeader>
<Badge appearance="filled" color="informative" style={{ marginRight: 6 }}>
{jailName}
</Badge>
{section.enabled === true
? "(enabled)"
: section.enabled === false
? "(disabled)"
: ""}
</AccordionHeader>
<AccordionPanel>
<JailSectionPanel
jailName={jailName}
section={section}
onChange={(next) => { handleSectionChange(jailName, next); }}
/>
</AccordionPanel>
</AccordionItem>
);
})}
</Accordion>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// JailFileForm — public component
// ---------------------------------------------------------------------------
import { JailFileFormInner } from "./JailFileFormInner";
interface JailFileFormProps {
filename: string;
}
/**
* Load and render a structured form editor for a ``jail.d/*.conf`` file.
*
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/
export function JailFileForm({ filename }: JailFileFormProps): React.JSX.Element {
const { config, loading, error, save } = useJailFileConfig(filename);

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionItem,
AccordionHeader,
AccordionPanel,
Badge,
Text,
} from "@fluentui/react-components";
import { DocumentAdd24Regular } from "@fluentui/react-icons";
import type { JailFileConfig, JailFileConfigUpdate, JailSectionConfig } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
import { JailSectionPanel } from "./JailSectionPanel";
interface JailFileFormInnerProps {
config: JailFileConfig;
onSave: (update: JailFileConfigUpdate) => Promise<void>;
}
export function JailFileFormInner({ config, onSave }: JailFileFormInnerProps): React.JSX.Element {
const [jails, setJails] = useState<Record<string, JailSectionConfig>>(config.jails);
useEffect(() => {
setJails(config.jails);
}, [config]);
const autoSavePayload = useMemo<JailFileConfigUpdate>(() => ({ jails }), [jails]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = useAutoSave(autoSavePayload, onSave);
const handleSectionChange = useCallback((jailName: string, next: JailSectionConfig): void => {
setJails((prev) => ({ ...prev, [jailName]: next }));
}, []);
const styles = useConfigStyles();
const jailNames = Object.keys(jails);
return (
<div>
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">{config.filename}</Text>
<AutoSaveIndicator status={saveStatus} errorText={saveErrorText} onRetry={retrySave} />
</div>
{jailNames.length === 0 ? (
<div className={styles.emptyState}>
<DocumentAdd24Regular style={{ fontSize: 48, color: "var(--colorNeutralForeground3)" }} aria-hidden />
<Text size={400} style={{ color: "var(--colorNeutralForeground3)" }}>
No jail sections found in this file.
</Text>
<Text size={200} style={{ color: "var(--colorNeutralForeground3)" }}>
Add a <code>[jailname]</code> section to the file to define jails.
</Text>
</div>
) : (
<Accordion multiple collapsible defaultOpenItems={jailNames}>
{jailNames.map((jailName) => {
const section = jails[jailName];
if (section === undefined) return null;
return (
<AccordionItem key={jailName} value={jailName} className={styles.accordionItem}>
<AccordionHeader>
<Badge appearance="filled" color="informative" style={{ marginRight: 6 }}>
{jailName}
</Badge>
{section.enabled === true
? "(enabled)"
: section.enabled === false
? "(disabled)"
: ""}
</AccordionHeader>
<AccordionPanel>
<JailSectionPanel jailName={jailName} section={section} onChange={(next) => { handleSectionChange(jailName, next); }} />
</AccordionPanel>
</AccordionItem>
);
})}
</Accordion>
)}
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { useCallback } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Field,
Input,
Select,
Switch,
} from "@fluentui/react-components";
import { KVEditor } from "./KVEditor";
import { StringListEditor } from "./StringListEditor";
import { useConfigStyles } from "./configStyles";
import type { JailSectionConfig } from "../../types/config";
const BACKENDS = ["", "auto", "polling", "gamin", "pyinotify", "systemd"] as const;
interface JailSectionPanelProps {
jailName: string;
section: JailSectionConfig;
onChange: (next: JailSectionConfig) => void;
}
export function JailSectionPanel({ jailName, section, onChange }: JailSectionPanelProps): React.JSX.Element {
const styles = useConfigStyles();
const update = useCallback(
(patch: Partial<JailSectionConfig>): void => {
onChange({ ...section, ...patch });
},
[onChange, section],
);
return (
<div>
<div className={styles.sectionCard}>
<div className={styles.fieldRow}>
<Field label="Enabled">
<Switch
checked={section.enabled ?? false}
onChange={(_e, d) => { update({ enabled: d.checked }); }}
aria-label={`Enable jail ${jailName}`}
/>
</Field>
<Field label="Port">
<Input
value={section.port ?? ""}
size="small"
placeholder="e.g. ssh or 22"
onChange={(_e, d) => { update({ port: d.value || null }); }}
/>
</Field>
<Field label="Filter">
<Input
value={section.filter ?? ""}
size="small"
placeholder="e.g. sshd"
className={styles.codeInput}
onChange={(_e, d) => { update({ filter: d.value || null }); }}
/>
</Field>
<Field label="Backend">
<Select
size="small"
value={section.backend ?? ""}
onChange={(_e, d) => { update({ backend: d.value || null }); }}
>
{BACKENDS.map((b) => (
<option key={b} value={b}>
{b === "" ? "(default)" : b}
</option>
))}
</Select>
</Field>
<Field label="Max Retry">
<Input
value={section.maxretry !== null ? String(section.maxretry) : ""}
size="small"
type="number"
placeholder="e.g. 5"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ maxretry: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
<Field label="Find Time (s)">
<Input
value={section.findtime !== null ? String(section.findtime) : ""}
size="small"
type="number"
placeholder="e.g. 600"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ findtime: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
<Field label="Ban Time (s)">
<Input
value={section.bantime !== null ? String(section.bantime) : ""}
size="small"
type="number"
placeholder="e.g. 3600"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ bantime: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
</div>
</div>
<Accordion multiple collapsible defaultOpenItems={["logpath"]}>
<AccordionItem value="logpath" className={styles.accordionItem}>
<AccordionHeader>Log Paths ({section.logpath.length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<StringListEditor
items={section.logpath}
onChange={(next) => { update({ logpath: next }); }}
placeholder="e.g. /var/log/auth.log"
addLabel="Add log path"
/>
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="actions" className={styles.accordionItem}>
<AccordionHeader>Actions ({section.action.length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<StringListEditor
items={section.action}
onChange={(next) => { update({ action: next }); }}
placeholder="e.g. iptables-multiport[name=SSH, port=ssh]"
addLabel="Add action"
/>
</div>
</AccordionPanel>
</AccordionItem>
{Object.keys(section.extra).length > 0 && (
<AccordionItem value="extra" className={styles.accordionItem}>
<AccordionHeader>Extra Settings ({Object.keys(section.extra).length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={section.extra} onChange={(next) => { update({ extra: next }); }} />
</div>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { Button, Input, tokens } from "@fluentui/react-components";
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
import { useConfigStyles } from "./configStyles";
interface KVEditorProps {
entries: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
export function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const rows = Object.entries(entries);
const handleKeyChange = (oldKey: string, newKey: string): void => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const handleValueChange = (key: string, value: string): void => {
onChange({ ...entries, [key]: value });
};
const handleDelete = (key: string): void => {
const { [key]: _removed, ...rest } = entries;
onChange(rest);
};
const handleAdd = (): void => {
let newKey = "key";
let n = 1;
while (newKey in entries) {
newKey = `key${String(n)}`;
n += 1;
}
onChange({ ...entries, [newKey]: "" });
};
return (
<div>
{rows.map(([key, value]) => (
<div key={key} className={styles.fieldRow}>
<Input
value={key}
size="small"
style={{ width: 160, fontFamily: tokens.fontFamilyMonospace }}
aria-label={`Setting name: ${key}`}
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
/>
<Input
value={value}
size="small"
style={{ flex: 1, fontFamily: tokens.fontFamilyMonospace }}
aria-label={`Value for ${key}`}
onChange={(_e, d) => { handleValueChange(key, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(key); }}
aria-label={`Delete key ${key}`}
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
Add setting
</Button>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { Button, Input } from "@fluentui/react-components";
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
interface StringListEditorProps {
items: string[];
onChange: (next: string[]) => void;
placeholder?: string;
addLabel?: string;
}
export function StringListEditor({
items,
onChange,
placeholder,
addLabel = "Add entry",
}: StringListEditorProps): React.JSX.Element {
const handleChange = (index: number, value: string): void => {
const next = [...items];
next[index] = value;
onChange(next);
};
const handleDelete = (index: number): void => {
onChange(items.filter((_, i) => i !== index));
};
const handleAdd = (): void => {
onChange([...items, ""]);
};
return (
<div>
{items.map((item, index) => (
<div key={index} style={{ display: "flex", gap: 4, marginBottom: 4, alignItems: "center" }}>
<Input
value={item}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
placeholder={placeholder}
aria-label={`Entry ${String(index + 1)}${item ? `: ${item}` : ""}`}
onChange={(_e, d) => { handleChange(index, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(index); }}
aria-label="Remove entry"
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
{addLabel}
</Button>
</div>
);
}