Config page tasks 1-4: dropdowns, key props, inactive jail full GUI, banaction fix

Task 1: Backend/LogEncoding/DatePattern dropdowns in JailConfigDetail
- Added BACKENDS, LOG_ENCODINGS, DATE_PATTERN_PRESETS constants
- Backend and Log Encoding: <Input readOnly> → <Select> (editable, auto-saves)
- Date Pattern: <Input> → <Combobox freeform> with presets
- Extended JailConfigUpdate model (backend, log_encoding) and service
- Added readOnly prop to JailConfigDetail (all fields, toggles, buttons)
- Extended RegexList with readOnly prop

Task 2: Fix raw action/filter config always blank
- Added key={selectedAction.name} to ActionDetail in ActionsTab
- Added key={selectedFilter.name} to FilterDetail in FiltersTab

Task 3: Inactive jail full GUI same as active jails
- Extended InactiveJail Pydantic model with all config fields
- Added _parse_time_to_seconds helper to config_file_service
- Updated _build_inactive_jail to populate all extended fields
- Extended InactiveJail TypeScript type to match
- Rewrote InactiveJailDetail to reuse JailConfigDetail (readOnly=true)

Task 4: Fix banaction interpolation error when activating jails
- _write_local_override_sync now includes banaction=iptables-multiport
  and banaction_allports=iptables-allports in every .local file
This commit is contained in:
2026-03-14 09:28:30 +01:00
parent 201cca8b66
commit c110352e9e
9 changed files with 541 additions and 246 deletions

View File

@@ -299,6 +299,7 @@ export function ActionsTab(): React.JSX.Element {
>
{selectedAction !== null && (
<ActionDetail
key={selectedAction.name}
action={selectedAction}
onAssignClick={() => { setAssignOpen(true); }}
onRemovedFromJail={handleRemovedFromJail}

View File

@@ -233,6 +233,7 @@ export function FiltersTab(): React.JSX.Element {
>
{selectedFilter !== null && (
<FilterDetail
key={selectedFilter.name}
filter={selectedFilter}
onAssignClick={() => { setAssignOpen(true); }}
/>

View File

@@ -10,10 +10,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import {
Badge,
Button,
Combobox,
Field,
Input,
MessageBar,
MessageBarBody,
Option,
Select,
Skeleton,
SkeletonItem,
@@ -55,6 +57,36 @@ import { RawConfigSection } from "./RawConfigSection";
import { RegexList } from "./RegexList";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const BACKENDS = [
{ value: "auto", label: "auto — pyinotify, then polling" },
{ value: "polling", label: "polling — standard polling algorithm" },
{ value: "pyinotify", label: "pyinotify — requires pyinotify library" },
{ value: "systemd", label: "systemd — uses systemd journal" },
{ value: "gamin", label: "gamin — legacy file alteration monitor" },
] as const;
const LOG_ENCODINGS = [
{ value: "auto", label: "auto — use system locale" },
{ value: "ascii", label: "ascii" },
{ value: "utf-8", label: "utf-8" },
{ value: "latin-1", label: "latin-1 (ISO 8859-1)" },
] as const;
const DATE_PATTERN_PRESETS = [
{ value: "", label: "auto-detect (leave blank)" },
{ value: "{^LN-BEG}", label: "{^LN-BEG} — line beginning" },
{ value: "%%Y-%%m-%%d %%H:%%M:%%S", label: "YYYY-MM-DD HH:MM:SS" },
{ value: "%%d/%%b/%%Y:%%H:%%M:%%S", label: "DD/Mon/YYYY:HH:MM:SS (Apache)" },
{ value: "%%b %%d %%H:%%M:%%S", label: "Mon DD HH:MM:SS (syslog)" },
{ value: "EPOCH", label: "EPOCH — Unix timestamp" },
{ value: "%%Y-%%m-%%dT%%H:%%M:%%S", label: "ISO 8601" },
{ value: "TAI64N", label: "TAI64N" },
] as const;
// ---------------------------------------------------------------------------
// JailConfigDetail
// ---------------------------------------------------------------------------
@@ -63,6 +95,10 @@ interface JailConfigDetailProps {
jail: JailConfig;
onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
onDeactivate?: () => void;
/** When true all fields are read-only and auto-save is suppressed. */
readOnly?: boolean;
/** When provided (and readOnly=true) shows an Activate Jail button. */
onActivate?: () => void;
}
/**
@@ -78,6 +114,8 @@ function JailConfigDetail({
jail,
onSave,
onDeactivate,
readOnly = false,
onActivate,
}: JailConfigDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [banTime, setBanTime] = useState(String(jail.ban_time));
@@ -88,6 +126,8 @@ function JailConfigDetail({
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
const [datePattern, setDatePattern] = useState(jail.date_pattern ?? "");
const [dnsMode, setDnsMode] = useState(jail.use_dns);
const [backend, setBackend] = useState(jail.backend);
const [logEncoding, setLogEncoding] = useState(jail.log_encoding);
const [prefRegex, setPrefRegex] = useState(jail.prefregex);
const [deletingPath, setDeletingPath] = useState<string | null>(null);
const [newLogPath, setNewLogPath] = useState("");
@@ -165,6 +205,8 @@ function JailConfigDetail({
ignore_regex: ignoreRegex,
date_pattern: datePattern !== "" ? datePattern : null,
dns_mode: dnsMode,
backend,
log_encoding: logEncoding,
prefregex: prefRegex !== "" ? prefRegex : null,
bantime_escalation: {
increment: escEnabled,
@@ -178,17 +220,18 @@ function JailConfigDetail({
}),
[
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
dnsMode, prefRegex, escEnabled, escFactor, escFormula, escMultipliers,
escMaxTime, escRndTime, escOverallJails,
dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor,
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
jail.ban_time, jail.find_time, jail.max_retry,
],
);
const saveCurrent = useCallback(
async (update: JailConfigUpdate): Promise<void> => {
if (readOnly) return;
await onSave(jail.name, update);
},
[jail.name, onSave],
[jail.name, onSave, readOnly],
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
@@ -220,6 +263,7 @@ function JailConfigDetail({
<Input
type="number"
value={banTime}
readOnly={readOnly}
onChange={(_e, d) => {
setBanTime(d.value);
}}
@@ -229,6 +273,7 @@ function JailConfigDetail({
<Input
type="number"
value={findTime}
readOnly={readOnly}
onChange={(_e, d) => {
setFindTime(d.value);
}}
@@ -238,6 +283,7 @@ function JailConfigDetail({
<Input
type="number"
value={maxRetry}
readOnly={readOnly}
onChange={(_e, d) => {
setMaxRetry(d.value);
}}
@@ -246,26 +292,57 @@ function JailConfigDetail({
</div>
<div className={styles.fieldRow}>
<Field label="Backend">
<Input readOnly value={jail.backend} />
<Select
value={backend}
disabled={readOnly}
onChange={(_e, d) => {
setBackend(d.value);
}}
>
{BACKENDS.map((b) => (
<option key={b.value} value={b.value}>{b.label}</option>
))}
</Select>
</Field>
<Field label="Log Encoding">
<Input readOnly value={jail.log_encoding} />
<Select
value={logEncoding.toLowerCase()}
disabled={readOnly}
onChange={(_e, d) => {
setLogEncoding(d.value);
}}
>
{LOG_ENCODINGS.map((e) => (
<option key={e.value} value={e.value}>{e.label}</option>
))}
</Select>
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="Date Pattern" hint="Leave blank for auto-detect.">
<Input
<Combobox
className={styles.codeFont}
placeholder="auto-detect"
value={datePattern}
onChange={(_e, d) => {
setDatePattern(d.value);
selectedOptions={[datePattern]}
freeform
disabled={readOnly}
onOptionSelect={(_e, d) => {
setDatePattern(d.optionValue ?? "");
}}
/>
onChange={(e) => {
setDatePattern(e.target.value);
}}
>
{DATE_PATTERN_PRESETS.map((p) => (
<Option key={p.value} value={p.value}>{p.label}</Option>
))}
</Combobox>
</Field>
<Field label="DNS Mode">
<Select
value={dnsMode}
disabled={readOnly}
onChange={(_e, d) => {
setDnsMode(d.value);
}}
@@ -285,6 +362,7 @@ function JailConfigDetail({
className={styles.codeFont}
placeholder="e.g. ^%(__prefix_line)s"
value={prefRegex}
readOnly={readOnly}
onChange={(_e, d) => {
setPrefRegex(d.value);
}}
@@ -302,22 +380,26 @@ function JailConfigDetail({
className={styles.codeFont}
style={{ flexGrow: 1 }}
value={p}
readOnly={readOnly}
onChange={(_e, d) => {
setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v)));
}}
/>
<Button
appearance="subtle"
icon={<Dismiss24Regular />}
size="small"
disabled={deletingPath === p}
title="Remove log path"
onClick={() => void handleDeleteLogPath(p)}
/>
{!readOnly && (
<Button
appearance="subtle"
icon={<Dismiss24Regular />}
size="small"
disabled={deletingPath === p}
title="Remove log path"
onClick={() => void handleDeleteLogPath(p)}
/>
)}
</div>
))
)}
{/* Add log path inline form */}
{/* Add log path inline form — hidden in read-only mode */}
{!readOnly && (
<div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}>
<Input
className={styles.codeFont}
@@ -347,12 +429,14 @@ function JailConfigDetail({
{addingLogPath ? "Adding…" : "Add"}
</Button>
</div>
)}
</Field>
<div style={{ marginTop: tokens.spacingVerticalS }}>
<RegexList
label="Fail Regex"
patterns={failRegex}
onChange={setFailRegex}
readOnly={readOnly}
/>
</div>
<div style={{ marginTop: tokens.spacingVerticalS }}>
@@ -360,6 +444,7 @@ function JailConfigDetail({
label="Ignore Regex"
patterns={ignoreRegex}
onChange={setIgnoreRegex}
readOnly={readOnly}
/>
</div>
{jail.actions.length > 0 && (
@@ -387,6 +472,7 @@ function JailConfigDetail({
<Switch
label="Enable incremental banning"
checked={escEnabled}
disabled={readOnly}
onChange={(_e, d) => {
setEscEnabled(d.checked);
}}
@@ -398,6 +484,7 @@ function JailConfigDetail({
<Input
type="number"
value={escFactor}
readOnly={readOnly}
onChange={(_e, d) => {
setEscFactor(d.value);
}}
@@ -407,6 +494,7 @@ function JailConfigDetail({
<Input
type="number"
value={escMaxTime}
readOnly={readOnly}
onChange={(_e, d) => {
setEscMaxTime(d.value);
}}
@@ -416,6 +504,7 @@ function JailConfigDetail({
<Input
type="number"
value={escRndTime}
readOnly={readOnly}
onChange={(_e, d) => {
setEscRndTime(d.value);
}}
@@ -425,6 +514,7 @@ function JailConfigDetail({
<Field label="Formula">
<Input
value={escFormula}
readOnly={readOnly}
onChange={(_e, d) => {
setEscFormula(d.value);
}}
@@ -433,6 +523,7 @@ function JailConfigDetail({
<Field label="Multipliers (space-separated)">
<Input
value={escMultipliers}
readOnly={readOnly}
onChange={(_e, d) => {
setEscMultipliers(d.value);
}}
@@ -441,6 +532,7 @@ function JailConfigDetail({
<Switch
label="Count repeat offences across all jails"
checked={escOverallJails}
disabled={readOnly}
onChange={(_e, d) => {
setEscOverallJails(d.checked);
}}
@@ -449,15 +541,17 @@ function JailConfigDetail({
)}
</div>
<div style={{ marginTop: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
{!readOnly && (
<div style={{ marginTop: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
)}
{onDeactivate !== undefined && (
{!readOnly && onDeactivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="secondary"
@@ -469,14 +563,28 @@ function JailConfigDetail({
</div>
)}
{/* Raw Configuration */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
label="Raw Jail Configuration"
/>
</div>
{readOnly && onActivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
</div>
)}
{/* Raw Configuration — hidden in read-only (inactive jail) mode */}
{!readOnly && (
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
label="Raw Jail Configuration"
/>
</div>
)}
</div>
);
}
@@ -491,7 +599,12 @@ interface InactiveJailDetailProps {
}
/**
* Read-only detail view for an inactive jail, with an Activate button.
* Detail view for an inactive jail.
*
* Maps the parsed config fields to a JailConfig-compatible object and renders
* JailConfigDetail in read-only mode, so the UI is identical to the active
* jail view but with all fields disabled and an Activate button instead of
* a Deactivate button.
*
* @param props - Component props.
* @returns JSX element.
@@ -502,13 +615,29 @@ function InactiveJailDetail({
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
return (
<div className={styles.detailPane}>
<Text weight="semibold" size={500} block style={{ marginBottom: tokens.spacingVerticalM }}>
{jail.name}
</Text>
const jailConfig = useMemo<JailConfig>(
() => ({
name: jail.name,
ban_time: jail.ban_time_seconds,
find_time: jail.find_time_seconds,
max_retry: jail.maxretry ?? 5,
fail_regex: jail.fail_regex,
ignore_regex: jail.ignore_regex,
log_paths: jail.logpath,
date_pattern: jail.date_pattern,
log_encoding: jail.log_encoding,
backend: jail.backend,
use_dns: jail.use_dns,
prefregex: jail.prefregex,
actions: jail.actions,
bantime_escalation: jail.bantime_escalation,
}),
[jail],
);
<div className={styles.fieldRow}>
return (
<div>
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
<Field label="Filter">
<Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} />
</Field>
@@ -516,63 +645,15 @@ function InactiveJailDetail({
<Input readOnly value={jail.port ?? "(auto)"} />
</Field>
</div>
<div className={styles.fieldRowThree}>
<Field label="Ban time">
<Input readOnly value={jail.bantime ?? "(default)"} />
</Field>
<Field label="Find time">
<Input readOnly value={jail.findtime ?? "(default)"} />
</Field>
<Field label="Max retry">
<Input readOnly value={jail.maxretry != null ? String(jail.maxretry) : "(default)"} />
</Field>
</div>
<Field label="Log path(s)">
{jail.logpath.length === 0 ? (
<Text size={200} className={styles.infoText}>(none)</Text>
) : (
<div>
{jail.logpath.map((p) => (
<Text key={p} block size={200} className={styles.codeFont}>
{p}
</Text>
))}
</div>
)}
</Field>
{jail.actions.length > 0 && (
<Field label="Actions">
<div>
{jail.actions.map((a) => (
<Badge
key={a}
appearance="tint"
color="informative"
style={{ marginRight: tokens.spacingHorizontalXS }}
>
{a}
</Badge>
))}
</div>
</Field>
)}
<Field label="Source file">
<Field label="Source file" style={{ marginBottom: tokens.spacingVerticalM }}>
<Input readOnly value={jail.source_file} className={styles.codeFont} />
</Field>
<div style={{ marginTop: tokens.spacingVerticalL }}>
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
</div>
<JailConfigDetail
jail={jailConfig}
onSave={async () => { /* read-only — never called */ }}
readOnly
onActivate={onActivate}
/>
</div>
);
}

View File

@@ -17,6 +17,8 @@ export interface RegexListProps {
patterns: string[];
/** Called when the list changes (add, delete, or edit). */
onChange: (next: string[]) => void;
/** When true, patterns are displayed read-only with no add/delete controls. */
readOnly?: boolean;
}
/**
@@ -29,6 +31,7 @@ export function RegexList({
label,
patterns,
onChange,
readOnly = false,
}: RegexListProps): React.JSX.Element {
const styles = useConfigStyles();
const [newPattern, setNewPattern] = useState("");
@@ -64,6 +67,7 @@ export function RegexList({
<Input
className={styles.regexInput}
value={p}
readOnly={readOnly}
aria-label={`${label} pattern ${String(i + 1)}`}
onChange={(_e, d) => {
const next = [...patterns];
@@ -71,33 +75,37 @@ export function RegexList({
onChange(next);
}}
/>
<Button
appearance="subtle"
icon={<Dismiss24Regular />}
size="small"
aria-label={`Remove ${label} pattern ${String(i + 1)}`}
onClick={() => {
handleDelete(i);
}}
/>
{!readOnly && (
<Button
appearance="subtle"
icon={<Dismiss24Regular />}
size="small"
aria-label={`Remove ${label} pattern ${String(i + 1)}`}
onClick={() => {
handleDelete(i);
}}
/>
)}
</div>
))}
<div className={styles.regexItem}>
<Input
className={styles.regexInput}
placeholder="New pattern…"
value={newPattern}
onChange={(_e, d) => {
setNewPattern(d.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
<Button size="small" onClick={handleAdd}>
Add
</Button>
</div>
{!readOnly && (
<div className={styles.regexItem}>
<Input
className={styles.regexInput}
placeholder="New pattern…"
value={newPattern}
onChange={(_e, d) => {
setNewPattern(d.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
<Button size="small" onClick={handleAdd}>
Add
</Button>
</div>
)}
</div>
);
}

View File

@@ -77,6 +77,8 @@ export interface JailConfigUpdate {
prefregex?: string | null;
date_pattern?: string | null;
dns_mode?: string | null;
backend?: string | null;
log_encoding?: string | null;
enabled?: boolean | null;
bantime_escalation?: BantimeEscalationUpdate | null;
}
@@ -498,6 +500,26 @@ export interface InactiveJail {
findtime: string | null;
/** Number of failures before a ban is issued, or null. */
maxretry: number | null;
/** Ban duration in seconds, parsed from bantime. */
ban_time_seconds: number;
/** Failure-counting window in seconds, parsed from findtime. */
find_time_seconds: number;
/** Log encoding, e.g. ``"auto"`` or ``"utf-8"``. */
log_encoding: string;
/** Log-monitoring backend. */
backend: string;
/** Date pattern for log parsing, or null for auto-detect. */
date_pattern: string | null;
/** DNS resolution mode. */
use_dns: string;
/** Prefix regex prepended to every failregex. */
prefregex: string;
/** List of failure regex patterns. */
fail_regex: string[];
/** List of ignore regex patterns. */
ignore_regex: string[];
/** Ban-time escalation configuration, or null. */
bantime_escalation: BantimeEscalation | null;
/** Absolute path to the config file where this jail is defined. */
source_file: string;
/** Effective ``enabled`` value — always ``false`` for inactive jails. */