Clean up Config page: remove Export tab, add CreateJailDialog, fix UI details

- Remove Export tab and all its imports from ConfigPage.tsx
- Remove Refresh and Reload fail2ban buttons from JailsTab; clean up
  associated state (reloading, reloadMsg, deactivating) and handlers
- Add Create Config button to Jails tab list pane (listHeader pattern);
  create CreateJailDialog component that calls createJailConfigFile API
- Remove Active/Inactive and 'Has local override' badges from FilterDetail
  and ActionDetail; remove now-unused Badge imports
- Replace read-only log path spans with editable Input fields in JailConfigDetail
- Export CreateJailDialog from components/config/index.ts
- Mark all 5 tasks done in Docs/Tasks.md
This commit is contained in:
2026-03-14 08:33:46 +01:00
parent 6e4797d71e
commit 201cca8b66
7 changed files with 322 additions and 402 deletions

View File

@@ -12,7 +12,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Badge,
Button,
Field,
Input,
@@ -121,19 +120,6 @@ function ActionDetail({
size="small"
/>
</Field>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Badge
appearance={action.active ? "filled" : "outline"}
color={action.active ? "success" : "informative"}
>
{action.active ? "Active" : "Inactive"}
</Badge>
{action.has_local_override && (
<Badge appearance="tint" color="warning" size="small">
Has local override
</Badge>
)}
</div>
</div>
{/* Structured editor */}

View File

@@ -0,0 +1,164 @@
/**
* CreateJailDialog — dialog for creating a new jail configuration file.
*
* Asks for a config file name and calls ``POST /api/config/jail-files`` on
* confirmation, seeding the file with a minimal comment header.
*/
import { useCallback, useEffect, useState } from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
} from "@fluentui/react-components";
import { createJailConfigFile } from "../../api/config";
import type { ConfFileCreateRequest } from "../../types/config";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CreateJailDialogProps {
/** Whether the dialog is currently open. */
open: boolean;
/** Called when the dialog should close without taking action. */
onClose: () => void;
/** Called after the jail config file has been successfully created. */
onCreated: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Dialog for creating a new jail configuration file at
* ``jail.d/{name}.conf``.
*
* The name field accepts a plain base name (e.g. ``my-custom-jail``); the
* backend appends the ``.conf`` extension.
*
* @param props - Component props.
* @returns JSX element.
*/
export function CreateJailDialog({
open,
onClose,
onCreated,
}: CreateJailDialogProps): React.JSX.Element {
const [name, setName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset form when the dialog opens.
useEffect(() => {
if (open) {
setName("");
setError(null);
}
}, [open]);
const handleClose = useCallback((): void => {
if (submitting) return;
onClose();
}, [submitting, onClose]);
const handleConfirm = useCallback((): void => {
const trimmedName = name.trim();
if (!trimmedName || submitting) return;
const req: ConfFileCreateRequest = {
name: trimmedName,
content: `# ${trimmedName}\n`,
};
setSubmitting(true);
setError(null);
createJailConfigFile(req)
.then(() => {
onCreated();
})
.catch((err: unknown) => {
setError(
err instanceof ApiError ? err.message : "Failed to create jail config.",
);
})
.finally(() => {
setSubmitting(false);
});
}, [name, submitting, onCreated]);
const canConfirm = name.trim() !== "" && !submitting;
return (
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
<DialogSurface>
<DialogBody>
<DialogTitle>Create Config</DialogTitle>
<DialogContent>
<Text
as="p"
size={300}
style={{ marginBottom: tokens.spacingVerticalM }}
>
Creates a new jail configuration file at{" "}
<code>jail.d/&lt;name&gt;.conf</code>.
</Text>
{error !== null && (
<MessageBar
intent="error"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<Field
label="Config name"
required
hint='Base name without extension, e.g. "my-custom-jail". Must not already exist.'
>
<Input
value={name}
onChange={(_e, d) => { setName(d.value); }}
placeholder="my-custom-jail"
disabled={submitting}
/>
</Field>
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={handleConfirm}
disabled={!canConfirm}
icon={submitting ? <Spinner size="extra-small" /> : undefined}
>
{submitting ? "Creating…" : "Create Config"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}

View File

@@ -12,7 +12,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Badge,
Button,
Field,
Input,
@@ -95,19 +94,6 @@ function FilterDetail({
size="small"
/>
</Field>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Badge
appearance={filter.active ? "filled" : "outline"}
color={filter.active ? "success" : "informative"}
>
{filter.active ? "Active" : "Inactive"}
</Badge>
{filter.has_local_override && (
<Badge appearance="tint" color="warning" size="small">
Has local override
</Badge>
)}
</div>
</div>
{/* Structured editor */}

View File

@@ -22,7 +22,7 @@ import {
tokens,
} from "@fluentui/react-components";
import {
ArrowClockwise24Regular,
Add24Regular,
Dismiss24Regular,
LockClosed24Regular,
LockOpen24Regular,
@@ -50,6 +50,7 @@ import { useJailConfigs } from "../../hooks/useConfig";
import { ActivateJailDialog } from "./ActivateJailDialog";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ConfigListDetail } from "./ConfigListDetail";
import { CreateJailDialog } from "./CreateJailDialog";
import { RawConfigSection } from "./RawConfigSection";
import { RegexList } from "./RegexList";
import { useConfigStyles } from "./configStyles";
@@ -295,11 +296,16 @@ function JailConfigDetail({
(none)
</Text>
) : (
logPaths.map((p) => (
<div key={p} className={styles.regexItem}>
<span className={styles.codeFont} style={{ flexGrow: 1 }}>
{p}
</span>
logPaths.map((p, i) => (
<div key={i} className={styles.regexItem}>
<Input
className={styles.codeFont}
style={{ flexGrow: 1 }}
value={p}
onChange={(_e, d) => {
setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v)));
}}
/>
<Button
appearance="subtle"
icon={<Dismiss24Regular />}
@@ -583,18 +589,16 @@ function InactiveJailDetail({
*/
export function JailsTab(): React.JSX.Element {
const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail, reloadAll } =
const { jails, loading, error, refresh, updateJail } =
useJailConfigs();
const { activeJails } = useConfigActiveStatus();
const [selectedName, setSelectedName] = useState<string | null>(null);
const [reloading, setReloading] = useState(false);
const [reloadMsg, setReloadMsg] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Inactive jails
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [inactiveLoading, setInactiveLoading] = useState(false);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
const [deactivating, setDeactivating] = useState(false);
const loadInactive = useCallback((): void => {
setInactiveLoading(true);
@@ -608,42 +612,14 @@ export function JailsTab(): React.JSX.Element {
loadInactive();
}, [loadInactive]);
const handleRefresh = useCallback((): void => {
refresh();
loadInactive();
}, [refresh, loadInactive]);
const handleReload = useCallback(async () => {
setReloading(true);
setReloadMsg(null);
try {
await reloadAll();
setReloadMsg("fail2ban reloaded.");
refresh();
loadInactive();
} catch (err: unknown) {
setReloadMsg(err instanceof ApiError ? err.message : "Reload failed.");
} finally {
setReloading(false);
}
}, [reloadAll, refresh, loadInactive]);
const handleDeactivate = useCallback((name: string): void => {
setDeactivating(true);
setReloadMsg(null);
deactivateJail(name)
.then(() => {
setReloadMsg(`Jail "${name}" deactivated.`);
setSelectedName(null);
refresh();
loadInactive();
})
.catch((err: unknown) => {
setReloadMsg(
err instanceof ApiError ? err.message : "Deactivation failed."
);
})
.finally(() => { setDeactivating(false); });
.catch(() => { /* non-critical — list refreshes on next load */ });
}, [refresh, loadInactive]);
const handleActivated = useCallback((): void => {
@@ -717,34 +693,19 @@ export function JailsTab(): React.JSX.Element {
);
}
const listHeader = (
<Button
appearance="outline"
icon={<Add24Regular />}
size="small"
onClick={() => { setCreateDialogOpen(true); }}
>
Create Config
</Button>
);
return (
<div className={styles.tabContent}>
<div className={styles.buttonRow}>
<Button
appearance="secondary"
icon={<ArrowClockwise24Regular />}
onClick={handleRefresh}
>
Refresh
</Button>
<Button
appearance="secondary"
icon={<ArrowClockwise24Regular />}
disabled={reloading || deactivating}
onClick={() => void handleReload()}
>
{reloading ? "Reloading…" : "Reload fail2ban"}
</Button>
</div>
{reloadMsg && (
<MessageBar
style={{ marginTop: tokens.spacingVerticalS }}
intent="info"
>
<MessageBarBody>{reloadMsg}</MessageBarBody>
</MessageBar>
)}
<div style={{ marginTop: tokens.spacingVerticalM }}>
<ConfigListDetail
items={listItems}
@@ -753,6 +714,7 @@ export function JailsTab(): React.JSX.Element {
onSelect={setSelectedName}
loading={false}
error={null}
listHeader={listHeader}
>
{selectedActiveJail !== undefined ? (
<JailConfigDetail
@@ -775,6 +737,16 @@ export function JailsTab(): React.JSX.Element {
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
/>
<CreateJailDialog
open={createDialogOpen}
onClose={() => { setCreateDialogOpen(false); }}
onCreated={() => {
setCreateDialogOpen(false);
refresh();
loadInactive();
}}
/>
</div>
);
}

View File

@@ -24,6 +24,8 @@ export { CreateActionDialog } from "./CreateActionDialog";
export type { CreateActionDialogProps } from "./CreateActionDialog";
export { CreateFilterDialog } from "./CreateFilterDialog";
export type { CreateFilterDialogProps } from "./CreateFilterDialog";
export { CreateJailDialog } from "./CreateJailDialog";
export type { CreateJailDialogProps } from "./CreateJailDialog";
export { ExportTab } from "./ExportTab";
export { FilterForm } from "./FilterForm";
export type { FilterFormProps } from "./FilterForm";

View File

@@ -17,10 +17,8 @@
import { useState } from "react";
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
import { DocumentArrowDown24Regular } from "@fluentui/react-icons";
import {
ActionsTab,
ExportTab,
FiltersTab,
GlobalTab,
JailsTab,
@@ -62,8 +60,7 @@ type TabValue =
| "global"
| "server"
| "map"
| "regex"
| "export";
| "regex";
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
@@ -94,7 +91,6 @@ export function ConfigPage(): React.JSX.Element {
<Tab value="server">Server</Tab>
<Tab value="map">Map</Tab>
<Tab value="regex">Regex Tester</Tab>
<Tab value="export" icon={<DocumentArrowDown24Regular />}>Export</Tab>
</TabList>
<div className={styles.tabContent} key={tab}>
@@ -105,7 +101,6 @@ export function ConfigPage(): React.JSX.Element {
{tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />}
{tab === "regex" && <RegexTesterTab />}
{tab === "export" && <ExportTab />}
</div>
</div>
);