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:
328
frontend/src/components/config/ActionForm.tsx
Normal file
328
frontend/src/components/config/ActionForm.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* ActionForm — structured form editor for a single ``action.d/*.conf`` file.
|
||||
*
|
||||
* Displays parsed fields grouped into collapsible sections:
|
||||
* - Includes (before / after)
|
||||
* - Lifecycle commands (actionstart, actionstop, actioncheck, actionban,
|
||||
* actionunban, actionflush)
|
||||
* - Definition variables (extra [Definition] key-value pairs)
|
||||
* - Init variables ([Init] section key-value pairs)
|
||||
*
|
||||
* 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 { 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ActionFormProps {
|
||||
/** 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);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label={`Loading ${name}…`}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
<SkeletonItem size={72} style={{ marginBottom: 8 }} />
|
||||
<SkeletonItem size={72} />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null || config === null) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error ?? "Failed to load action config"}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionFormEditor
|
||||
config={config}
|
||||
onSave={save}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user