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

@@ -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>
);
}