refactoring-backend #3
@@ -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;
|
||||
|
||||
83
frontend/src/components/blocklist/PreviewDialog.tsx
Normal file
83
frontend/src/components/blocklist/PreviewDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/components/blocklist/SourceFormDialog.tsx
Normal file
102
frontend/src/components/blocklist/SourceFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/config/ActionDetail.tsx
Normal file
94
frontend/src/components/config/ActionDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
131
frontend/src/components/config/ActionFormEditor.tsx
Normal file
131
frontend/src/components/config/ActionFormEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
21
frontend/src/components/config/CommandField.tsx
Normal file
21
frontend/src/components/config/CommandField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/config/FilterDetail.tsx
Normal file
48
frontend/src/components/config/FilterDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
162
frontend/src/components/config/FilterFormEditor.tsx
Normal file
162
frontend/src/components/config/FilterFormEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
84
frontend/src/components/config/JailFileFormInner.tsx
Normal file
84
frontend/src/components/config/JailFileFormInner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/config/JailSectionPanel.tsx
Normal file
157
frontend/src/components/config/JailSectionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/config/KVEditor.tsx
Normal file
79
frontend/src/components/config/KVEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/config/StringListEditor.tsx
Normal file
63
frontend/src/components/config/StringListEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,41 +5,13 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
|
||||
import { MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
|
||||
import { useBlocklistStyles } from "../components/blocklist/blocklistStyles";
|
||||
|
||||
import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
|
||||
import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";
|
||||
import { BlocklistImportLogSection } from "../components/blocklist/BlocklistImportLogSection";
|
||||
import { useRunImport } from "../hooks/useBlocklist";
|
||||
import type { ImportRunResult } from "../types/blocklist";
|
||||
|
||||
interface ImportResultDialogProps {
|
||||
open: boolean;
|
||||
result: ImportRunResult | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
|
||||
if (!open || !result) return <></>;
|
||||
return (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 }}>
|
||||
<div style={{ background: "white", padding: "24px", borderRadius: "8px", maxWidth: "520px", minWidth: "300px" }}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Import Complete
|
||||
</Text>
|
||||
<Text size={200} style={{ marginTop: "12px" }}>
|
||||
Total imported: {result.total_imported} | Skipped: {result.total_skipped} | Sources with errors: {result.errors_count}
|
||||
</Text>
|
||||
<div style={{ marginTop: "16px", textAlign: "right" }}>
|
||||
<Button appearance="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { ImportResultDialog } from "./blocklists/ImportResultDialog";
|
||||
|
||||
export function BlocklistsPage(): React.JSX.Element {
|
||||
const safeUseBlocklistStyles = useBlocklistStyles as unknown as () => { root: string };
|
||||
|
||||
@@ -1,545 +1,23 @@
|
||||
/**
|
||||
* Jail detail page.
|
||||
*
|
||||
* Displays full configuration and state for a single fail2ban jail:
|
||||
* - Status badges and control buttons (start, stop, idle, reload)
|
||||
* - Log paths, fail-regex, ignore-regex patterns
|
||||
* - Date pattern, encoding, and actions
|
||||
* - Ignore list management (add / remove IPs)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCommonSectionStyles } from "../theme/commonStyles";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowLeftRegular,
|
||||
ArrowSyncRegular,
|
||||
DismissRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { useJailDetail, useJailBannedIps } from "../hooks/useJails";
|
||||
import { formatSeconds } from "../utils/formatDate";
|
||||
import type { Jail } from "../types/jail";
|
||||
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
breadcrumb: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
|
||||
headerRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
controlRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
grid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
gap: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM}`,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
label: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
codeList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXXS,
|
||||
paddingTop: tokens.spacingVerticalXS,
|
||||
},
|
||||
codeItem: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
padding: `2px ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
ignoreRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
formField: { minWidth: "200px", flexGrow: 1 },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CodeList({ items, empty }: { items: string[]; empty: string }): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
if (items.length === 0) {
|
||||
return <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>{empty}</Text>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.codeList}>
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className={styles.codeItem}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Jail info card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailInfoProps {
|
||||
jail: Jail;
|
||||
onRefresh: () => void;
|
||||
onStart: () => Promise<void>;
|
||||
onStop: () => Promise<void>;
|
||||
onSetIdle: (on: boolean) => Promise<void>;
|
||||
onReload: () => Promise<void>;
|
||||
}
|
||||
|
||||
function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const navigate = useNavigate();
|
||||
const [ctrlError, setCtrlError] = useState<string | null>(null);
|
||||
|
||||
const handle =
|
||||
(fn: () => Promise<unknown>, postNavigate = false) =>
|
||||
(): void => {
|
||||
setCtrlError(null);
|
||||
fn()
|
||||
.then(() => {
|
||||
if (postNavigate) {
|
||||
navigate("/jails");
|
||||
} else {
|
||||
onRefresh();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setCtrlError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<Text
|
||||
size={600}
|
||||
weight="semibold"
|
||||
style={{ fontFamily: "Consolas, 'Courier New', monospace" }}
|
||||
>
|
||||
{jail.name}
|
||||
</Text>
|
||||
{jail.running ? (
|
||||
jail.idle ? (
|
||||
<Badge appearance="filled" color="warning">idle</Badge>
|
||||
) : (
|
||||
<Badge appearance="filled" color="success">running</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge appearance="filled" color="danger">stopped</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ctrlError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{ctrlError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className={styles.controlRow}>
|
||||
{jail.running ? (
|
||||
<Tooltip content="Stop jail" relationship="label">
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<StopRegular />}
|
||||
onClick={handle(onStop)}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content="Start jail" relationship="label">
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<PlayRegular />}
|
||||
onClick={handle(onStart)}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
content={jail.idle ? "Resume from idle mode" : "Pause monitoring (idle mode)"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<PauseRegular />}
|
||||
onClick={handle(() => onSetIdle(!jail.idle))}
|
||||
disabled={!jail.running}
|
||||
>
|
||||
{jail.idle ? "Resume" : "Set Idle"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Reload jail configuration" relationship="label">
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={handle(onReload)}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
{jail.status && (
|
||||
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Text className={styles.label}>Currently banned:</Text>
|
||||
<Text>{String(jail.status.currently_banned)}</Text>
|
||||
<Text className={styles.label}>Total banned:</Text>
|
||||
<Text>{String(jail.status.total_banned)}</Text>
|
||||
<Text className={styles.label}>Currently failed:</Text>
|
||||
<Text>{String(jail.status.currently_failed)}</Text>
|
||||
<Text className={styles.label}>Total failed:</Text>
|
||||
<Text>{String(jail.status.total_failed)}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config grid */}
|
||||
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Text className={styles.label}>Backend:</Text>
|
||||
<Text className={styles.mono}>{jail.backend}</Text>
|
||||
<Text className={styles.label}>Find time:</Text>
|
||||
<Text>{formatSeconds(jail.find_time)}</Text>
|
||||
<Text className={styles.label}>Ban time:</Text>
|
||||
<Text>{formatSeconds(jail.ban_time)}</Text>
|
||||
<Text className={styles.label}>Max retry:</Text>
|
||||
<Text>{String(jail.max_retry)}</Text>
|
||||
{jail.date_pattern && (
|
||||
<>
|
||||
<Text className={styles.label}>Date pattern:</Text>
|
||||
<Text className={styles.mono}>{jail.date_pattern}</Text>
|
||||
</>
|
||||
)}
|
||||
{jail.log_encoding && (
|
||||
<>
|
||||
<Text className={styles.label}>Log encoding:</Text>
|
||||
<Text className={styles.mono}>{jail.log_encoding}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Patterns section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Log Paths & Patterns
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text size={300} weight="semibold">Log Paths</Text>
|
||||
<CodeList items={jail.log_paths} empty="No log paths configured." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Fail Regex
|
||||
</Text>
|
||||
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Ignore Regex
|
||||
</Text>
|
||||
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
|
||||
|
||||
{jail.actions.length > 0 && (
|
||||
<>
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Actions
|
||||
</Text>
|
||||
<CodeList items={jail.actions} empty="" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Ban-time escalation section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element | null {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const esc = jail.bantime_escalation;
|
||||
if (!esc?.increment) return null;
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban-time Escalation
|
||||
</Text>
|
||||
<Badge appearance="filled" color="informative">enabled</Badge>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{esc.factor !== null && (
|
||||
<>
|
||||
<Text className={styles.label}>Factor:</Text>
|
||||
<Text className={styles.mono}>{String(esc.factor)}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.formula && (
|
||||
<>
|
||||
<Text className={styles.label}>Formula:</Text>
|
||||
<Text className={styles.mono}>{esc.formula}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.multipliers && (
|
||||
<>
|
||||
<Text className={styles.label}>Multipliers:</Text>
|
||||
<Text className={styles.mono}>{esc.multipliers}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.max_time !== null && (
|
||||
<>
|
||||
<Text className={styles.label}>Max time:</Text>
|
||||
<Text>{formatSeconds(esc.max_time)}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.rnd_time !== null && (
|
||||
<>
|
||||
<Text className={styles.label}>Random jitter:</Text>
|
||||
<Text>{formatSeconds(esc.rnd_time)}</Text>
|
||||
</>
|
||||
)}
|
||||
<Text className={styles.label}>Count across all jails:</Text>
|
||||
<Text>{esc.overall_jails ? "yes" : "no"}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Ignore list section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IgnoreListSectionProps {
|
||||
jailName: string;
|
||||
ignoreList: string[];
|
||||
ignoreSelf: boolean;
|
||||
onAdd: (ip: string) => Promise<void>;
|
||||
onRemove: (ip: string) => Promise<void>;
|
||||
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
function IgnoreListSection({
|
||||
jailName: _jailName,
|
||||
ignoreList,
|
||||
ignoreSelf,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onToggleIgnoreSelf,
|
||||
}: IgnoreListSectionProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = (): void => {
|
||||
if (!inputVal.trim()) return;
|
||||
setOpError(null);
|
||||
onAdd(inputVal.trim())
|
||||
.then(() => {
|
||||
setInputVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (ip: string): void => {
|
||||
setOpError(null);
|
||||
onRemove(ip).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ignore List (IP Whitelist)
|
||||
</Text>
|
||||
</div>
|
||||
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Ignore-self toggle */}
|
||||
<Switch
|
||||
label="Ignore self — exclude this server's own IP addresses from banning"
|
||||
checked={ignoreSelf}
|
||||
onChange={(_e, data): void => {
|
||||
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Add IP or CIDR network">
|
||||
<Input
|
||||
placeholder="e.g. 10.0.0.0/8 or 192.168.1.1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleAdd}
|
||||
disabled={!inputVal.trim()}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{ignoreList.length === 0 ? (
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
The ignore list is empty.
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{ignoreList.map((ip) => (
|
||||
<div key={ip} className={styles.ignoreRow}>
|
||||
<Text className={styles.mono}>{ip}</Text>
|
||||
<Tooltip content={`Remove ${ip} from ignore list`} relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => {
|
||||
handleRemove(ip);
|
||||
}}
|
||||
aria-label={`Remove ${ip}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Jail detail page.
|
||||
*
|
||||
* Fetches and displays the full configuration and state of a single jail
|
||||
* identified by the `:name` route parameter.
|
||||
*/
|
||||
|
||||
import { Button, MessageBar, MessageBarBody, Spinner, Text } from "@fluentui/react-components";
|
||||
import { ArrowLeftRegular } from "@fluentui/react-icons";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useJailDetail, useJailBannedIps } from "../hooks/useJails";
|
||||
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
||||
import { JailInfoSection } from "./jail/JailInfoSection";
|
||||
import { PatternsSection } from "./jail/PatternsSection";
|
||||
import { BantimeEscalationSection } from "./jail/BantimeEscalationSection";
|
||||
import { IgnoreListSection } from "./jail/IgnoreListSection";
|
||||
import { useJailDetailPageStyles } from "./jail/jailDetailPageStyles";
|
||||
|
||||
export function JailDetailPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const styles = useJailDetailPageStyles();
|
||||
const { name = "" } = useParams<{ name: string }>();
|
||||
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } =
|
||||
useJailDetail(name);
|
||||
@@ -586,14 +64,13 @@ export function JailDetailPage(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* Breadcrumb */}
|
||||
<div className={styles.breadcrumb}>
|
||||
<Link to="/jails" style={{ textDecoration: "none" }}>
|
||||
<Button appearance="subtle" size="small" icon={<ArrowLeftRegular />}>
|
||||
Jails
|
||||
</Button>
|
||||
</Link>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
<Text size={200} style={{ color: "var(--colorNeutralForeground3)" }}>
|
||||
/
|
||||
</Text>
|
||||
<Text size={200} className={styles.mono}>
|
||||
|
||||
@@ -1,627 +1,12 @@
|
||||
/**
|
||||
* Jails management page.
|
||||
*
|
||||
* Provides three sections in a vertically-stacked layout:
|
||||
* 1. **Jail Overview** — table of all jails with quick status badges and
|
||||
* per-row start/stop/idle/reload controls.
|
||||
* 2. **Ban / Unban IP** — form to manually ban or unban an IP address.
|
||||
* 3. **IP Lookup** — check whether an IP is currently banned and view its
|
||||
* geo-location details.
|
||||
*/
|
||||
import { Text } from "@fluentui/react-components";
|
||||
import { useJailsPageStyles } from "./jails/jailsPageStyles";
|
||||
import { JailOverviewSection } from "./jails/JailOverviewSection";
|
||||
import { BanUnbanForm } from "./jails/BanUnbanForm";
|
||||
import { IpLookupSection } from "./jails/IpLookupSection";
|
||||
import { useActiveBans, useJails } from "../hooks/useJails";
|
||||
|
||||
import { useState } from "react";
|
||||
import { formatSeconds } from "../utils/formatDate";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
DataGridHeader,
|
||||
DataGridHeaderCell,
|
||||
DataGridRow,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
type TableColumnDefinition,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { useCardStyles, useCommonSectionStyles } from "../theme/commonStyles";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowSyncRegular,
|
||||
LockClosedRegular,
|
||||
LockOpenRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
SearchRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
||||
import type { JailSummary } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
|
||||
tableWrapper: { overflowX: "auto" },
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
formField: { minWidth: "180px", flexGrow: 1 },
|
||||
actionRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
lookupResult: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
padding: tokens.spacingVerticalS,
|
||||
},
|
||||
lookupRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
},
|
||||
lookupLabel: { fontWeight: tokens.fontWeightSemibold },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail overview columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jailColumns: TableColumnDefinition<JailSummary>[] = [
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (j) => (
|
||||
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
|
||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
||||
{j.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "status",
|
||||
renderHeaderCell: () => "Status",
|
||||
renderCell: (j) => {
|
||||
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
||||
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
||||
return <Badge appearance="filled" color="success">running</Badge>;
|
||||
},
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "backend",
|
||||
renderHeaderCell: () => "Backend",
|
||||
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banned",
|
||||
renderHeaderCell: () => "Banned",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "failed",
|
||||
renderHeaderCell: () => "Failed",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "findTime",
|
||||
renderHeaderCell: () => "Find Time",
|
||||
renderCell: (j) => <Text size={200}>{formatSeconds(j.find_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banTime",
|
||||
renderHeaderCell: () => "Ban Time",
|
||||
renderCell: (j) => <Text size={200}>{formatSeconds(j.ban_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "maxRetry",
|
||||
renderHeaderCell: () => "Max Retry",
|
||||
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Jail overview section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JailOverviewSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
||||
useJails();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Jail Overview
|
||||
{total > 0 && (
|
||||
<Badge appearance="tint" style={{ marginLeft: "8px" }}>
|
||||
{String(total)}
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => { handle(reloadAll); }}
|
||||
>
|
||||
Reload All
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<ArrowClockwiseRegular />} onClick={refresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load jails: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && jails.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label="Loading jails…" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<DataGrid
|
||||
items={jails}
|
||||
columns={jailColumns}
|
||||
getRowId={(j: JailSummary) => j.name}
|
||||
focusMode="composite"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<JailSummary>>
|
||||
{({ item }) => (
|
||||
<DataGridRow<JailSummary> key={item.name}>
|
||||
{({ renderCell, columnId }) => {
|
||||
if (columnId === "status") {
|
||||
return (
|
||||
<DataGridCell>
|
||||
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
|
||||
{renderCell(item)}
|
||||
<Tooltip
|
||||
content={item.running ? "Stop jail" : "Start jail"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={item.running ? <StopRegular /> : <PlayRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => {
|
||||
if (item.running) await stopJail(item.name);
|
||||
else await startJail(item.name);
|
||||
});
|
||||
}}
|
||||
aria-label={
|
||||
item.running ? `Stop ${item.name}` : `Start ${item.name}`
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={item.idle ? "Resume from idle" : "Set idle"}
|
||||
relationship="label"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<PauseRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => setIdle(item.name, !item.idle));
|
||||
}}
|
||||
disabled={!item.running}
|
||||
aria-label={`Toggle idle for ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Reload jail" relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => reloadJail(item.name));
|
||||
}}
|
||||
aria-label={`Reload ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DataGridCell>
|
||||
);
|
||||
}
|
||||
return <DataGridCell>{renderCell(item)}</DataGridCell>;
|
||||
}}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Ban / Unban IP form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BanUnbanFormProps {
|
||||
jailNames: string[];
|
||||
onBan: (jail: string, ip: string) => Promise<void>;
|
||||
onUnban: (ip: string, jail?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const [banIpVal, setBanIpVal] = useState("");
|
||||
const [banJail, setBanJail] = useState("");
|
||||
const [unbanIpVal, setUnbanIpVal] = useState("");
|
||||
const [unbanJail, setUnbanJail] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSuccess, setFormSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleBan = (): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!banIpVal.trim() || !banJail) {
|
||||
setFormError("Both IP address and jail are required.");
|
||||
return;
|
||||
}
|
||||
onBan(banJail, banIpVal.trim())
|
||||
.then(() => {
|
||||
setFormSuccess(`${banIpVal.trim()} banned in ${banJail}.`);
|
||||
setBanIpVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnban = (fromAllJails: boolean): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!unbanIpVal.trim()) {
|
||||
setFormError("IP address is required.");
|
||||
return;
|
||||
}
|
||||
const jail = fromAllJails ? undefined : unbanJail || undefined;
|
||||
onUnban(unbanIpVal.trim(), jail)
|
||||
.then(() => {
|
||||
const scope = jail ?? "all jails";
|
||||
setFormSuccess(`${unbanIpVal.trim()} unbanned from ${scope}.`);
|
||||
setUnbanIpVal("");
|
||||
setUnbanJail("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban / Unban IP
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{formError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{formSuccess && (
|
||||
<MessageBar intent="success">
|
||||
<MessageBarBody>{formSuccess}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Ban row */}
|
||||
<Text size={300} weight="semibold">
|
||||
Ban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={banIpVal}
|
||||
onChange={(_, d) => {
|
||||
setBanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail">
|
||||
<Select
|
||||
value={banJail}
|
||||
onChange={(_, d) => {
|
||||
setBanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Select jail…</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<LockClosedRegular />}
|
||||
onClick={handleBan}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Ban
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Unban row */}
|
||||
<Text
|
||||
size={300}
|
||||
weight="semibold"
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
Unban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={unbanIpVal}
|
||||
onChange={(_, d) => {
|
||||
setUnbanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail (optional — leave blank for all)">
|
||||
<Select
|
||||
value={unbanJail}
|
||||
onChange={(_, d) => {
|
||||
setUnbanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">All jails</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<LockOpenRegular />}
|
||||
onClick={() => {
|
||||
handleUnban(false);
|
||||
}}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<LockOpenRegular />}
|
||||
onClick={() => {
|
||||
handleUnban(true);
|
||||
}}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Unban from All Jails
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: IP Lookup section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IpLookupSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const cardStyles = useCardStyles();
|
||||
const { result, loading, error, lookup, clear } = useIpLookup();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
|
||||
const handleLookup = (): void => {
|
||||
if (inputVal.trim()) {
|
||||
lookup(inputVal.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
IP Lookup
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 1.2.3.4 or 2001:db8::1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
clear();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleLookup();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={loading ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
onClick={handleLookup}
|
||||
disabled={loading || !inputVal.trim()}
|
||||
style={{ marginBottom: "4px" }}
|
||||
>
|
||||
Look up
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={`${cardStyles.card} ${styles.lookupResult}`}>
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>IP:</Text>
|
||||
<Text className={styles.mono}>{result.ip}</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Currently banned in:</Text>
|
||||
{result.currently_banned_in.length === 0 ? (
|
||||
<Badge appearance="tint" color="success">
|
||||
not banned
|
||||
</Badge>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{result.currently_banned_in.map((j) => (
|
||||
<Badge key={j} appearance="filled" color="danger">
|
||||
{j}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.geo && (
|
||||
<>
|
||||
{result.geo.country_name && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Country:</Text>
|
||||
<Text>
|
||||
{result.geo.country_name}
|
||||
{result.geo.country_code ? ` (${result.geo.country_code})` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.org && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Organisation:</Text>
|
||||
<Text>{result.geo.org}</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.asn && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>ASN:</Text>
|
||||
<Text className={styles.mono}>{result.geo.asn}</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Jails management page.
|
||||
*
|
||||
* Renders three sections: Jail Overview, Ban/Unban IP, and IP Lookup.
|
||||
*/
|
||||
export function JailsPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const styles = useJailsPageStyles();
|
||||
const { jails } = useJails();
|
||||
const { banIp, unbanIp } = useActiveBans();
|
||||
|
||||
|
||||
32
frontend/src/pages/blocklists/ImportResultDialog.tsx
Normal file
32
frontend/src/pages/blocklists/ImportResultDialog.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Text } from "@fluentui/react-components";
|
||||
import type { ImportRunResult } from "../../types/blocklist";
|
||||
|
||||
interface ImportResultDialogProps {
|
||||
open: boolean;
|
||||
result: ImportRunResult | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
|
||||
if (!open || !result) return <></>;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) onClose(); }}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Import Complete</DialogTitle>
|
||||
<DialogContent>
|
||||
<Text size={200}>
|
||||
Total imported: {result.total_imported} | Skipped: {result.total_skipped} | Sources with errors: {result.errors_count}
|
||||
</Text>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
61
frontend/src/pages/jail/BantimeEscalationSection.tsx
Normal file
61
frontend/src/pages/jail/BantimeEscalationSection.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Badge, Text } from "@fluentui/react-components";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
|
||||
import type { Jail } from "../../types/jail";
|
||||
import { formatSeconds } from "../../utils/formatDate";
|
||||
|
||||
interface BantimeEscalationSectionProps {
|
||||
jail: Jail;
|
||||
}
|
||||
|
||||
export function BantimeEscalationSection({ jail }: BantimeEscalationSectionProps): React.JSX.Element | null {
|
||||
const styles = useJailDetailPageStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const esc = jail.bantime_escalation;
|
||||
if (!esc?.increment) return null;
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban-time Escalation
|
||||
</Text>
|
||||
<Badge appearance="filled" color="informative">enabled</Badge>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{esc.factor !== null && (
|
||||
<>
|
||||
<Text className={styles.label}>Factor:</Text>
|
||||
<Text className={styles.mono}>{String(esc.factor)}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.formula && (
|
||||
<>
|
||||
<Text className={styles.label}>Formula:</Text>
|
||||
<Text className={styles.mono}>{esc.formula}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.multipliers && (
|
||||
<>
|
||||
<Text className={styles.label}>Multipliers:</Text>
|
||||
<Text className={styles.mono}>{esc.multipliers}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.max_time !== null && (
|
||||
<>
|
||||
<Text className={styles.label}>Max time:</Text>
|
||||
<Text>{formatSeconds(esc.max_time)}</Text>
|
||||
</>
|
||||
)}
|
||||
{esc.rnd_time !== null && (
|
||||
<>
|
||||
<Text className={styles.label}>Random jitter:</Text>
|
||||
<Text>{formatSeconds(esc.rnd_time)}</Text>
|
||||
</>
|
||||
)}
|
||||
<Text className={styles.label}>Count across all jails:</Text>
|
||||
<Text>{esc.overall_jails ? "yes" : "no"}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/src/pages/jail/CodeList.tsx
Normal file
24
frontend/src/pages/jail/CodeList.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Text } from "@fluentui/react-components";
|
||||
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
|
||||
|
||||
interface CodeListProps {
|
||||
items: string[];
|
||||
empty: string;
|
||||
}
|
||||
|
||||
export function CodeList({ items, empty }: CodeListProps): React.JSX.Element {
|
||||
const styles = useJailDetailPageStyles();
|
||||
if (items.length === 0) {
|
||||
return <Text size={200} style={{ color: "var(--colorNeutralForeground3)" }}>{empty}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.codeList}>
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className={styles.codeItem}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/pages/jail/IgnoreListSection.tsx
Normal file
137
frontend/src/pages/jail/IgnoreListSection.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { DismissRegular } from "@fluentui/react-icons";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
|
||||
|
||||
interface IgnoreListSectionProps {
|
||||
jailName: string;
|
||||
ignoreList: string[];
|
||||
ignoreSelf: boolean;
|
||||
onAdd: (ip: string) => Promise<void>;
|
||||
onRemove: (ip: string) => Promise<void>;
|
||||
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function IgnoreListSection({
|
||||
jailName: _jailName,
|
||||
ignoreList,
|
||||
ignoreSelf,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onToggleIgnoreSelf,
|
||||
}: IgnoreListSectionProps): React.JSX.Element {
|
||||
const styles = useJailDetailPageStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = (): void => {
|
||||
if (!inputVal.trim()) return;
|
||||
setOpError(null);
|
||||
onAdd(inputVal.trim())
|
||||
.then(() => {
|
||||
setInputVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (ip: string): void => {
|
||||
setOpError(null);
|
||||
onRemove(ip).catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacingHorizontalM)" }}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ignore List (IP Whitelist)
|
||||
</Text>
|
||||
</div>
|
||||
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
label="Ignore self — exclude this server's own IP addresses from banning"
|
||||
checked={ignoreSelf}
|
||||
onChange={(_e, data): void => {
|
||||
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Add IP or CIDR network">
|
||||
<Input
|
||||
placeholder="e.g. 10.0.0.0/8 or 192.168.1.1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleAdd}
|
||||
disabled={!inputVal.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ignoreList.length === 0 ? (
|
||||
<Text size={200} style={{ color: "var(--colorNeutralForeground3)" }}>
|
||||
The ignore list is empty.
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
{ignoreList.map((ip) => (
|
||||
<div key={ip} className={styles.ignoreRow}>
|
||||
<Text className={styles.mono}>{ip}</Text>
|
||||
<Tooltip content={`Remove ${ip} from ignore list`} relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => {
|
||||
handleRemove(ip);
|
||||
}}
|
||||
aria-label={`Remove ${ip}`}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/pages/jail/JailInfoSection.tsx
Normal file
146
frontend/src/pages/jail/JailInfoSection.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Text,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowSyncRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
|
||||
import type { Jail } from "../../types/jail";
|
||||
|
||||
interface JailInfoProps {
|
||||
jail: Jail;
|
||||
onRefresh: () => void;
|
||||
onStart: () => Promise<void>;
|
||||
onStop: () => Promise<void>;
|
||||
onSetIdle: (on: boolean) => Promise<void>;
|
||||
onReload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
|
||||
const styles = useJailDetailPageStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const navigate = useNavigate();
|
||||
const [ctrlError, setCtrlError] = useState<string | null>(null);
|
||||
|
||||
const handle =
|
||||
(fn: () => Promise<unknown>, postNavigate = false) =>
|
||||
(): void => {
|
||||
setCtrlError(null);
|
||||
fn()
|
||||
.then(() => {
|
||||
if (postNavigate) {
|
||||
navigate("/jails");
|
||||
} else {
|
||||
onRefresh();
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setCtrlError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<Text size={600} weight="semibold" style={{ fontFamily: "Consolas, 'Courier New', monospace" }}>
|
||||
{jail.name}
|
||||
</Text>
|
||||
{jail.running ? (
|
||||
jail.idle ? (
|
||||
<Badge appearance="filled" color="warning">idle</Badge>
|
||||
) : (
|
||||
<Badge appearance="filled" color="success">running</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge appearance="filled" color="danger">stopped</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ctrlError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{ctrlError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.controlRow}>
|
||||
{jail.running ? (
|
||||
<Button appearance="secondary" icon={<StopRegular />} onClick={handle(onStop)}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button appearance="primary" icon={<PlayRegular />} onClick={handle(onStart)}>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<PauseRegular />}
|
||||
onClick={handle(() => onSetIdle(!jail.idle))}
|
||||
disabled={!jail.running}
|
||||
>
|
||||
{jail.idle ? "Resume" : "Set Idle"}
|
||||
</Button>
|
||||
<Button appearance="outline" icon={<ArrowSyncRegular />} onClick={handle(onReload)}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{jail.status && (
|
||||
<div className={styles.grid} style={{ marginTop: "var(--spacingVerticalS)" }}>
|
||||
<Text className={styles.label}>Currently banned:</Text>
|
||||
<Text>{String(jail.status.currently_banned)}</Text>
|
||||
<Text className={styles.label}>Total banned:</Text>
|
||||
<Text>{String(jail.status.total_banned)}</Text>
|
||||
<Text className={styles.label}>Currently failed:</Text>
|
||||
<Text>{String(jail.status.currently_failed)}</Text>
|
||||
<Text className={styles.label}>Total failed:</Text>
|
||||
<Text>{String(jail.status.total_failed)}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.grid} style={{ marginTop: "var(--spacingVerticalS)" }}>
|
||||
<Text className={styles.label}>Backend:</Text>
|
||||
<Text className={styles.mono}>{jail.backend}</Text>
|
||||
<Text className={styles.label}>Find time:</Text>
|
||||
<Text>{String(jail.find_time)}</Text>
|
||||
<Text className={styles.label}>Ban time:</Text>
|
||||
<Text>{String(jail.ban_time)}</Text>
|
||||
<Text className={styles.label}>Max retry:</Text>
|
||||
<Text>{String(jail.max_retry)}</Text>
|
||||
{jail.date_pattern && (
|
||||
<>
|
||||
<Text className={styles.label}>Date pattern:</Text>
|
||||
<Text className={styles.mono}>{jail.date_pattern}</Text>
|
||||
</>
|
||||
)}
|
||||
{jail.log_encoding && (
|
||||
<>
|
||||
<Text className={styles.label}>Log encoding:</Text>
|
||||
<Text className={styles.mono}>{jail.log_encoding}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/pages/jail/PatternsSection.tsx
Normal file
46
frontend/src/pages/jail/PatternsSection.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Text } from "@fluentui/react-components";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import type { Jail } from "../../types/jail";
|
||||
import { CodeList } from "./CodeList";
|
||||
|
||||
interface PatternsSectionProps {
|
||||
jail: Jail;
|
||||
}
|
||||
|
||||
export function PatternsSection({ jail }: PatternsSectionProps): React.JSX.Element {
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Log Paths & Patterns
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Text size={300} weight="semibold">
|
||||
Log Paths
|
||||
</Text>
|
||||
<CodeList items={jail.log_paths} empty="No log paths configured." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: "var(--spacingVerticalS)" }}>
|
||||
Fail Regex
|
||||
</Text>
|
||||
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: "var(--spacingVerticalS)" }}>
|
||||
Ignore Regex
|
||||
</Text>
|
||||
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
|
||||
|
||||
{jail.actions.length > 0 && (
|
||||
<>
|
||||
<Text size={300} weight="semibold" style={{ marginTop: "var(--spacingVerticalS)" }}>
|
||||
Actions
|
||||
</Text>
|
||||
<CodeList items={jail.actions} empty="" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/pages/jail/jailDetailPageStyles.ts
Normal file
75
frontend/src/pages/jail/jailDetailPageStyles.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useJailDetailPageStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
breadcrumb: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
headerRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
controlRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
grid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content 1fr",
|
||||
gap: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM}`,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
label: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: tokens.fontFamilyMonospace,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
codeList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXXS,
|
||||
paddingTop: tokens.spacingVerticalXS,
|
||||
},
|
||||
codeItem: {
|
||||
fontFamily: tokens.fontFamilyMonospace,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
padding: `2px ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
ignoreRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
formField: { minWidth: "200px", flexGrow: 1 },
|
||||
});
|
||||
179
frontend/src/pages/jails/BanUnbanForm.tsx
Normal file
179
frontend/src/pages/jails/BanUnbanForm.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { LockClosedRegular, LockOpenRegular } from "@fluentui/react-icons";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import { useJailsPageStyles } from "./jailsPageStyles";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
interface BanUnbanFormProps {
|
||||
jailNames: string[];
|
||||
onBan: (jail: string, ip: string) => Promise<void>;
|
||||
onUnban: (ip: string, jail?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element {
|
||||
const styles = useJailsPageStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const [banIpVal, setBanIpVal] = useState("");
|
||||
const [banJail, setBanJail] = useState("");
|
||||
const [unbanIpVal, setUnbanIpVal] = useState("");
|
||||
const [unbanJail, setUnbanJail] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSuccess, setFormSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleBan = (): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!banIpVal.trim() || !banJail) {
|
||||
setFormError("Both IP address and jail are required.");
|
||||
return;
|
||||
}
|
||||
onBan(banJail, banIpVal.trim())
|
||||
.then(() => {
|
||||
setFormSuccess(`${banIpVal.trim()} banned in ${banJail}.`);
|
||||
setBanIpVal("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnban = (fromAllJails: boolean): void => {
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
if (!unbanIpVal.trim()) {
|
||||
setFormError("IP address is required.");
|
||||
return;
|
||||
}
|
||||
const jail = fromAllJails ? undefined : unbanJail || undefined;
|
||||
onUnban(unbanIpVal.trim(), jail)
|
||||
.then(() => {
|
||||
const scope = jail ?? "all jails";
|
||||
setFormSuccess(`${unbanIpVal.trim()} unbanned from ${scope}.`);
|
||||
setUnbanIpVal("");
|
||||
setUnbanJail("");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setFormError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Ban / Unban IP
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{formError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{formSuccess && (
|
||||
<MessageBar intent="success">
|
||||
<MessageBarBody>{formSuccess}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<Text size={300} weight="semibold">
|
||||
Ban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={banIpVal}
|
||||
onChange={(_, d) => {
|
||||
setBanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail">
|
||||
<Select
|
||||
value={banJail}
|
||||
onChange={(_, d) => {
|
||||
setBanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Select jail…</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button appearance="primary" icon={<LockClosedRegular />} onClick={handleBan}>
|
||||
Ban
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
Unban an IP
|
||||
</Text>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
value={unbanIpVal}
|
||||
onChange={(_, d) => {
|
||||
setUnbanIpVal(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<Field label="Jail (optional — leave blank for all)">
|
||||
<Select
|
||||
value={unbanJail}
|
||||
onChange={(_, d) => {
|
||||
setUnbanJail(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="">All jails</option>
|
||||
{jailNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Button appearance="secondary" icon={<LockOpenRegular />} onClick={() => { handleUnban(false); }}>
|
||||
Unban
|
||||
</Button>
|
||||
<Button appearance="outline" icon={<LockOpenRegular />} onClick={() => { handleUnban(true); }}>
|
||||
Unban from All Jails
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
frontend/src/pages/jails/IpLookupSection.tsx
Normal file
123
frontend/src/pages/jails/IpLookupSection.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
} from "@fluentui/react-components";
|
||||
import { SearchRegular } from "@fluentui/react-icons";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import { useJailsPageStyles } from "./jailsPageStyles";
|
||||
import { useIpLookup } from "../../hooks/useJails";
|
||||
|
||||
export function IpLookupSection(): React.JSX.Element {
|
||||
const styles = useJailsPageStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const cardStyles = useCommonSectionStyles();
|
||||
const { result, loading, error, lookup, clear } = useIpLookup();
|
||||
const [inputVal, setInputVal] = useState("");
|
||||
|
||||
const handleLookup = (): void => {
|
||||
if (inputVal.trim()) {
|
||||
lookup(inputVal.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
IP Lookup
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<Field label="IP Address">
|
||||
<Input
|
||||
placeholder="e.g. 1.2.3.4 or 2001:db8::1"
|
||||
value={inputVal}
|
||||
onChange={(_, d) => {
|
||||
setInputVal(d.value);
|
||||
clear();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleLookup();
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={loading ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
onClick={handleLookup}
|
||||
disabled={loading || !inputVal.trim()}
|
||||
>
|
||||
Look up
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={`${cardStyles.section} ${styles.lookupResult}`}>
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>IP:</Text>
|
||||
<Text className={styles.mono}>{result.ip}</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Currently banned in:</Text>
|
||||
{result.currently_banned_in.length === 0 ? (
|
||||
<Badge appearance="tint" color="success">
|
||||
not banned
|
||||
</Badge>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{result.currently_banned_in.map((j) => (
|
||||
<Badge key={j} appearance="filled" color="danger">
|
||||
{j}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.geo && (
|
||||
<>
|
||||
{result.geo.country_name && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Country:</Text>
|
||||
<Text>
|
||||
{result.geo.country_name}
|
||||
{result.geo.country_code ? ` (${result.geo.country_code})` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.org && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>Organisation:</Text>
|
||||
<Text>{result.geo.org}</Text>
|
||||
</div>
|
||||
)}
|
||||
{result.geo.asn && (
|
||||
<div className={styles.lookupRow}>
|
||||
<Text className={styles.lookupLabel}>ASN:</Text>
|
||||
<Text className={styles.mono}>{result.geo.asn}</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
frontend/src/pages/jails/JailOverviewSection.tsx
Normal file
218
frontend/src/pages/jails/JailOverviewSection.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { formatSeconds } from "../../utils/formatDate";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
DataGridHeader,
|
||||
DataGridHeaderCell,
|
||||
DataGridRow,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwiseRegular,
|
||||
ArrowSyncRegular,
|
||||
PauseRegular,
|
||||
PlayRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { useCommonSectionStyles } from "../../theme/commonStyles";
|
||||
import { useJailsPageStyles } from "./jailsPageStyles";
|
||||
import { useJails } from "../../hooks/useJails";
|
||||
import type { JailSummary } from "../../types/jail";
|
||||
|
||||
const jailColumns = [
|
||||
{
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (j: JailSummary) => (
|
||||
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
|
||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
||||
{j.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
compare: (a: JailSummary, b: JailSummary) => a.name.localeCompare(b.name),
|
||||
},
|
||||
{
|
||||
columnId: "status",
|
||||
renderHeaderCell: () => "Status",
|
||||
renderCell: (j: JailSummary) => {
|
||||
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
||||
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
||||
return <Badge appearance="filled" color="success">running</Badge>;
|
||||
},
|
||||
compare: (a: JailSummary, b: JailSummary) => {
|
||||
if (a.running !== b.running) return a.running ? -1 : 1;
|
||||
if (a.idle !== b.idle) return a.idle ? 1 : -1;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: "backend",
|
||||
renderHeaderCell: () => "Backend",
|
||||
renderCell: (j: JailSummary) => <Text size={200}>{j.backend}</Text>,
|
||||
compare: (a: JailSummary, b: JailSummary) => a.backend.localeCompare(b.backend),
|
||||
},
|
||||
{
|
||||
columnId: "banned",
|
||||
renderHeaderCell: () => "Banned",
|
||||
renderCell: (j: JailSummary) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||
),
|
||||
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_banned ?? 0) - (b.status?.currently_banned ?? 0),
|
||||
},
|
||||
{
|
||||
columnId: "failed",
|
||||
renderHeaderCell: () => "Failed",
|
||||
renderCell: (j: JailSummary) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
||||
),
|
||||
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_failed ?? 0) - (b.status?.currently_failed ?? 0),
|
||||
},
|
||||
{
|
||||
columnId: "findTime",
|
||||
renderHeaderCell: () => "Find Time",
|
||||
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.find_time)}</Text>,
|
||||
compare: (a: JailSummary, b: JailSummary) => a.find_time - b.find_time,
|
||||
},
|
||||
{
|
||||
columnId: "banTime",
|
||||
renderHeaderCell: () => "Ban Time",
|
||||
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.ban_time)}</Text>,
|
||||
compare: (a: JailSummary, b: JailSummary) => a.ban_time - b.ban_time,
|
||||
},
|
||||
{
|
||||
columnId: "maxRetry",
|
||||
renderHeaderCell: () => "Max Retry",
|
||||
renderCell: (j: JailSummary) => <Text size={200}>{String(j.max_retry)}</Text>,
|
||||
compare: (a: JailSummary, b: JailSummary) => a.max_retry - b.max_retry,
|
||||
},
|
||||
];
|
||||
|
||||
export function JailOverviewSection(): React.JSX.Element {
|
||||
const styles = useJailsPageStyles();
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = useJails();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={sectionStyles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Jail Overview
|
||||
{total > 0 && (
|
||||
<Badge appearance="tint" style={{ marginLeft: "8px" }}>
|
||||
{String(total)}
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => { handle(reloadAll); }}
|
||||
>
|
||||
Reload All
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<ArrowClockwiseRegular />} onClick={refresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{opError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Failed to load jails: {error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && jails.length === 0 ? (
|
||||
<div className={styles.centred}>
|
||||
<Spinner label="Loading jails…" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableWrapper}>
|
||||
<DataGrid items={jails} columns={jailColumns} getRowId={(j: JailSummary) => j.name} focusMode="composite">
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<JailSummary>>
|
||||
{({ item }) => (
|
||||
<DataGridRow<JailSummary> key={item.name}>
|
||||
{({ renderCell, columnId }) => {
|
||||
if (columnId === "status") {
|
||||
return (
|
||||
<DataGridCell>
|
||||
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
|
||||
{renderCell(item)}
|
||||
<Tooltip content={item.running ? "Stop jail" : "Start jail"} relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={item.running ? <StopRegular /> : <PlayRegular />}
|
||||
onClick={() => {
|
||||
handle(async () => {
|
||||
if (item.running) await stopJail(item.name);
|
||||
else await startJail(item.name);
|
||||
});
|
||||
}}
|
||||
aria-label={item.running ? `Stop ${item.name}` : `Start ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={item.idle ? "Resume from idle" : "Set idle"} relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<PauseRegular />}
|
||||
onClick={() => { handle(async () => setIdle(item.name, !item.idle)); }}
|
||||
disabled={!item.running}
|
||||
aria-label={`Toggle idle for ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Reload jail" relationship="label">
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => { handle(async () => reloadJail(item.name)); }}
|
||||
aria-label={`Reload ${item.name}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DataGridCell>
|
||||
);
|
||||
}
|
||||
return <DataGridCell>{renderCell(item)}</DataGridCell>;
|
||||
}}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/pages/jails/jailsPageStyles.ts
Normal file
46
frontend/src/pages/jails/jailsPageStyles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useJailsPageStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
tableWrapper: { overflowX: "auto" },
|
||||
centred: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
},
|
||||
mono: {
|
||||
fontFamily: tokens.fontFamilyMonospace,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
formRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
formField: { minWidth: "180px", flexGrow: 1 },
|
||||
actionRow: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
lookupResult: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
padding: tokens.spacingVerticalS,
|
||||
},
|
||||
lookupRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
},
|
||||
lookupLabel: { fontWeight: tokens.fontWeightSemibold },
|
||||
});
|
||||
Reference in New Issue
Block a user