refactor(frontend): decompose ConfigPage into dedicated config components
- Extract tab components: JailsTab, ActionsTab, FiltersTab, JailFilesTab, GlobalTab, ServerTab, ConfFilesTab, RegexTesterTab, MapTab, ExportTab - Add form components: JailFileForm, ActionForm, FilterForm - Add AutoSaveIndicator, RegexList, configStyles, and barrel index - ConfigPage now composes these components; greatly reduces file size - Add tests: ConfigPage.test.tsx, useAutoSave.test.ts
This commit is contained in:
474
frontend/src/components/config/JailFileForm.tsx
Normal file
474
frontend/src/components/config/JailFileForm.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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``.
|
||||
*/
|
||||
|
||||
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 { 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading jail file config…">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
<SkeletonItem size={32} />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<MessageBar intent="error" style={{ margin: "8px 0" }}>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
if (config === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <JailFileFormInner config={config} onSave={save} />;
|
||||
}
|
||||
Reference in New Issue
Block a user