Add log path to jail via inline form in ConfigPage
The JailAccordionPanel previously allowed deleting log paths but had no UI to add new ones. The backend endpoint, API helper, and hook all existed; only the UI was missing. Changes: - ConfigPage.tsx: import addLogPath/AddLogPathRequest; add state (newLogPath, newLogPathTail, addingLogPath) and handleAddLogPath callback to JailAccordionPanel; render inline form below the log-path list with Input, Switch (tail/head), and labeled Add button that appends on success and surfaces errors inline. - ConfigPageLogPath.test.tsx: 6 tests covering render, disabled state, enabled state, successful add, success feedback, and API error handling. All 33 frontend tests pass.
This commit is contained in:
229
frontend/src/components/__tests__/ConfigPageLogPath.test.tsx
Normal file
229
frontend/src/components/__tests__/ConfigPageLogPath.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Tests for the "Add Log Path" form inside JailAccordionPanel (ConfigPage).
|
||||
*
|
||||
* Verifies that:
|
||||
* - The add-log-path input and button are rendered inside the jail accordion.
|
||||
* - The Add button is disabled when the input is empty.
|
||||
* - Submitting a valid path calls `addLogPath` and appends the path to the list.
|
||||
* - An API error is surfaced as an error message bar.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { ConfigPage } from "../../pages/ConfigPage";
|
||||
import type { JailConfig } from "../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal jail fixture used across tests. */
|
||||
const MOCK_JAIL: JailConfig = {
|
||||
name: "sshd",
|
||||
ban_time: 600,
|
||||
max_retry: 3,
|
||||
find_time: 300,
|
||||
fail_regex: [],
|
||||
ignore_regex: [],
|
||||
log_paths: ["/var/log/auth.log"],
|
||||
date_pattern: null,
|
||||
log_encoding: "UTF-8",
|
||||
backend: "auto",
|
||||
actions: [],
|
||||
};
|
||||
|
||||
const mockAddLogPath = vi.fn().mockResolvedValue(undefined);
|
||||
const mockDeleteLogPath = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUpdateJailConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const mockReloadConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFetchGlobalConfig = vi.fn().mockResolvedValue({
|
||||
config: {
|
||||
ban_time: 600,
|
||||
max_retry: 5,
|
||||
find_time: 300,
|
||||
backend: "auto",
|
||||
},
|
||||
});
|
||||
const mockFetchServerSettings = vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
log_level: "INFO",
|
||||
log_target: "STDOUT",
|
||||
syslog_socket: null,
|
||||
db_path: "/var/lib/fail2ban/fail2ban.sqlite3",
|
||||
db_purge_age: 86400,
|
||||
db_max_matches: 10,
|
||||
},
|
||||
});
|
||||
const mockFetchJailConfigs = vi.fn().mockResolvedValue({
|
||||
jails: [MOCK_JAIL],
|
||||
total: 1,
|
||||
});
|
||||
const mockFetchMapColorThresholds = vi.fn().mockResolvedValue({
|
||||
threshold_high: 100,
|
||||
threshold_medium: 50,
|
||||
threshold_low: 20,
|
||||
});
|
||||
const mockFetchJailConfigFiles = vi.fn().mockResolvedValue({ files: [] });
|
||||
const mockFetchFilterFiles = vi.fn().mockResolvedValue({ files: [] });
|
||||
const mockFetchActionFiles = vi.fn().mockResolvedValue({ files: [] });
|
||||
const mockUpdateMapColorThresholds = vi.fn().mockResolvedValue({});
|
||||
const mockUpdateGlobalConfig = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUpdateServerSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFlushLogs = vi.fn().mockResolvedValue({ message: "ok" });
|
||||
const mockSetJailConfigFileEnabled = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("../../api/config", () => ({
|
||||
addLogPath: (...args: unknown[]) => mockAddLogPath(...args),
|
||||
deleteLogPath: (...args: unknown[]) => mockDeleteLogPath(...args),
|
||||
fetchJailConfigs: () => mockFetchJailConfigs(),
|
||||
fetchJailConfig: vi.fn(),
|
||||
updateJailConfig: (...args: unknown[]) => mockUpdateJailConfig(...args),
|
||||
reloadConfig: () => mockReloadConfig(),
|
||||
fetchGlobalConfig: () => mockFetchGlobalConfig(),
|
||||
updateGlobalConfig: (...args: unknown[]) => mockUpdateGlobalConfig(...args),
|
||||
fetchServerSettings: () => mockFetchServerSettings(),
|
||||
updateServerSettings: (...args: unknown[]) => mockUpdateServerSettings(...args),
|
||||
flushLogs: () => mockFlushLogs(),
|
||||
fetchMapColorThresholds: () => mockFetchMapColorThresholds(),
|
||||
updateMapColorThresholds: (...args: unknown[]) =>
|
||||
mockUpdateMapColorThresholds(...args),
|
||||
fetchJailConfigFiles: () => mockFetchJailConfigFiles(),
|
||||
fetchJailConfigFileContent: vi.fn(),
|
||||
setJailConfigFileEnabled: (...args: unknown[]) =>
|
||||
mockSetJailConfigFileEnabled(...args),
|
||||
fetchFilterFiles: () => mockFetchFilterFiles(),
|
||||
fetchFilterFile: vi.fn(),
|
||||
updateFilterFile: vi.fn(),
|
||||
createFilterFile: vi.fn(),
|
||||
fetchActionFiles: () => mockFetchActionFiles(),
|
||||
fetchActionFile: vi.fn(),
|
||||
updateActionFile: vi.fn(),
|
||||
createActionFile: vi.fn(),
|
||||
previewLog: vi.fn(),
|
||||
testRegex: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderConfigPage() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ConfigPage />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits for the sshd accordion button to appear and clicks it open. */
|
||||
async function openSshdAccordion(user: ReturnType<typeof userEvent.setup>) {
|
||||
const accordionBtn = await screen.findByRole("button", { name: /sshd/i });
|
||||
await user.click(accordionBtn);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ConfigPage — Add Log Path", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchJailConfigs.mockResolvedValue({ jails: [MOCK_JAIL], total: 1 });
|
||||
mockAddLogPath.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("renders the existing log path and the add-log-path input inside the accordion", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
// Existing path from fixture
|
||||
expect(screen.getByText("/var/log/auth.log")).toBeInTheDocument();
|
||||
|
||||
// Add-log-path input placeholder
|
||||
expect(
|
||||
screen.getByPlaceholderText("/var/log/example.log"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables the Add button when the path input is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const addBtn = screen.getByRole("button", { name: /add log path/i });
|
||||
expect(addBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables the Add button when the path input has content", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/nginx/access.log");
|
||||
|
||||
const addBtn = screen.getByRole("button", { name: /add log path/i });
|
||||
expect(addBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls addLogPath and appends the path on successful submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/nginx/access.log");
|
||||
|
||||
const addBtn = screen.getByRole("button", { name: /add log path/i });
|
||||
await user.click(addBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddLogPath).toHaveBeenCalledWith("sshd", {
|
||||
log_path: "/var/log/nginx/access.log",
|
||||
tail: true,
|
||||
});
|
||||
});
|
||||
|
||||
// New path should appear in the list
|
||||
expect(screen.getByText("/var/log/nginx/access.log")).toBeInTheDocument();
|
||||
|
||||
// Input should be cleared
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows a success message after adding a log path", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/nginx/access.log");
|
||||
await user.click(screen.getByRole("button", { name: /add log path/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/added log path.*\/var\/log\/nginx\/access\.log/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error message when addLogPath fails", async () => {
|
||||
mockAddLogPath.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
const user = userEvent.setup();
|
||||
renderConfigPage();
|
||||
await openSshdAccordion(user);
|
||||
|
||||
const input = screen.getByPlaceholderText("/var/log/example.log");
|
||||
await user.type(input, "/var/log/bad.log");
|
||||
await user.click(screen.getByRole("button", { name: /add log path/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Failed to add log path."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
Tab,
|
||||
TabList,
|
||||
Text,
|
||||
@@ -46,12 +47,28 @@ import {
|
||||
useServerSettings,
|
||||
} from "../hooks/useConfig";
|
||||
import {
|
||||
addLogPath,
|
||||
createActionFile,
|
||||
createFilterFile,
|
||||
deleteLogPath,
|
||||
fetchActionFile,
|
||||
fetchActionFiles,
|
||||
fetchFilterFile,
|
||||
fetchFilterFiles,
|
||||
fetchJailConfigFileContent,
|
||||
fetchJailConfigFiles,
|
||||
fetchMapColorThresholds,
|
||||
setJailConfigFileEnabled,
|
||||
updateActionFile,
|
||||
updateFilterFile,
|
||||
updateMapColorThresholds,
|
||||
} from "../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
ConfFileEntry,
|
||||
GlobalConfigUpdate,
|
||||
JailConfig,
|
||||
JailConfigFile,
|
||||
JailConfigUpdate,
|
||||
MapColorThresholdsUpdate,
|
||||
ServerSettingsUpdate,
|
||||
@@ -235,9 +252,55 @@ function JailAccordionPanel({
|
||||
const [maxRetry, setMaxRetry] = useState(String(jail.max_retry));
|
||||
const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex);
|
||||
const [ignoreRegex, setIgnoreRegex] = useState<string[]>(jail.ignore_regex);
|
||||
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
|
||||
const [deletingPath, setDeletingPath] = useState<string | null>(null);
|
||||
const [newLogPath, setNewLogPath] = useState("");
|
||||
const [newLogPathTail, setNewLogPathTail] = useState(true);
|
||||
const [addingLogPath, setAddingLogPath] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
const handleDeleteLogPath = useCallback(
|
||||
async (path: string) => {
|
||||
setDeletingPath(path);
|
||||
setMsg(null);
|
||||
try {
|
||||
await deleteLogPath(jail.name, path);
|
||||
setLogPaths((prev) => prev.filter((p) => p !== path));
|
||||
setMsg({ text: `Removed log path: ${path}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Delete failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setDeletingPath(null);
|
||||
}
|
||||
},
|
||||
[jail.name],
|
||||
);
|
||||
|
||||
const handleAddLogPath = useCallback(async () => {
|
||||
const trimmed = newLogPath.trim();
|
||||
if (!trimmed) return;
|
||||
setAddingLogPath(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail };
|
||||
await addLogPath(jail.name, req);
|
||||
setLogPaths((prev) => [...prev, trimmed]);
|
||||
setNewLogPath("");
|
||||
setMsg({ text: `Added log path: ${trimmed}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Failed to add log path.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setAddingLogPath(false);
|
||||
}
|
||||
}, [jail.name, newLogPath, newLogPathTail]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
@@ -314,17 +377,57 @@ function JailAccordionPanel({
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Log Paths">
|
||||
{jail.log_paths.length === 0 ? (
|
||||
{logPaths.length === 0 ? (
|
||||
<Text className={styles.infoText} size={200}>
|
||||
(none)
|
||||
</Text>
|
||||
) : (
|
||||
jail.log_paths.map((p) => (
|
||||
<div key={p} className={styles.codeFont}>
|
||||
{p}
|
||||
logPaths.map((p) => (
|
||||
<div key={p} className={styles.regexItem}>
|
||||
<span className={styles.codeFont} style={{ flexGrow: 1 }}>
|
||||
{p}
|
||||
</span>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<Dismiss24Regular />}
|
||||
size="small"
|
||||
disabled={deletingPath === p}
|
||||
title="Remove log path"
|
||||
onClick={() => void handleDeleteLogPath(p)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{/* Add log path inline form */}
|
||||
<div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}>
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
style={{ flexGrow: 1 }}
|
||||
placeholder="/var/log/example.log"
|
||||
value={newLogPath}
|
||||
disabled={addingLogPath}
|
||||
aria-label="New log path"
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPath(d.value);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={newLogPathTail ? "tail" : "head"}
|
||||
checked={newLogPathTail}
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPathTail(d.checked);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
aria-label="Add log path"
|
||||
disabled={addingLogPath || !newLogPath.trim()}
|
||||
onClick={() => void handleAddLogPath()}
|
||||
>
|
||||
{addingLogPath ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<RegexList
|
||||
@@ -922,6 +1025,500 @@ function MapTab(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailFilesTab — manage jail.d config files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JailFilesTab(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [files, setFiles] = useState<JailConfigFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openItems, setOpenItems] = useState<string[]>([]);
|
||||
const [fileContents, setFileContents] = useState<Record<string, string>>({});
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetchJailConfigFiles();
|
||||
setFiles(resp.files);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Failed to load jail config files.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleAccordionToggle = useCallback(
|
||||
(
|
||||
_e: React.SyntheticEvent,
|
||||
data: { openItems: (string | number)[] },
|
||||
) => {
|
||||
const next = data.openItems as string[];
|
||||
const newlyOpened = next.filter((v) => !openItems.includes(v));
|
||||
setOpenItems(next);
|
||||
for (const filename of newlyOpened) {
|
||||
if (!Object.prototype.hasOwnProperty.call(fileContents, filename)) {
|
||||
void fetchJailConfigFileContent(filename)
|
||||
.then((c) => {
|
||||
setFileContents((prev) => ({ ...prev, [filename]: c.content }));
|
||||
})
|
||||
.catch(() => {
|
||||
setFileContents((prev) => ({
|
||||
...prev,
|
||||
[filename]: "(failed to load)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[openItems, fileContents],
|
||||
);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (filename: string, enabled: boolean) => {
|
||||
setToggling(filename);
|
||||
setMsg(null);
|
||||
try {
|
||||
await setJailConfigFileEnabled(filename, { enabled });
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.filename === filename ? { ...f, enabled } : f)),
|
||||
);
|
||||
setMsg({
|
||||
text: `${filename} ${enabled ? "enabled" : "disabled"}.`,
|
||||
ok: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Toggle failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading) return <Spinner label="Loading jail config files…" />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Files in <code>jail.d/</code>. Toggle the switch to enable or disable a
|
||||
jail config file. Changes take effect on the next fail2ban reload.
|
||||
</Text>
|
||||
{msg && (
|
||||
<MessageBar
|
||||
intent={msg.ok ? "success" : "error"}
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={() => void loadFiles()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{files.length === 0 && (
|
||||
<Text
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
No files found in jail.d/.
|
||||
</Text>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
openItems={openItems}
|
||||
onToggle={handleAccordionToggle}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<AccordionItem key={file.filename} value={file.filename}>
|
||||
<AccordionHeader>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
}}
|
||||
>
|
||||
<span className={styles.codeFont}>{file.filename}</span>
|
||||
<Switch
|
||||
checked={file.enabled}
|
||||
disabled={toggling === file.filename}
|
||||
label={file.enabled ? "Enabled" : "Disabled"}
|
||||
onChange={(_e, d) => {
|
||||
void handleToggleEnabled(file.filename, d.checked);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
{openItems.includes(file.filename) &&
|
||||
(fileContents[file.filename] === undefined ? (
|
||||
<Spinner size="tiny" label="Loading…" />
|
||||
) : (
|
||||
<Textarea
|
||||
readOnly
|
||||
value={fileContents[file.filename]}
|
||||
rows={20}
|
||||
style={{ width: "100%", resize: "vertical" }}
|
||||
className={styles.codeFont}
|
||||
/>
|
||||
))}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfFilesTab — generic editable list for filter.d / action.d
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConfFilesTabProps {
|
||||
label: string;
|
||||
fetchList: () => Promise<{ files: ConfFileEntry[]; total: number }>;
|
||||
fetchFile: (name: string) => Promise<{
|
||||
name: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}>;
|
||||
updateFile: (
|
||||
name: string,
|
||||
req: { content: string },
|
||||
) => Promise<void>;
|
||||
createFile: (req: {
|
||||
name: string;
|
||||
content: string;
|
||||
}) => Promise<{ name: string; filename: string; content: string }>;
|
||||
}
|
||||
|
||||
function ConfFilesTab({
|
||||
label,
|
||||
fetchList,
|
||||
fetchFile,
|
||||
updateFile,
|
||||
createFile,
|
||||
}: ConfFilesTabProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openItems, setOpenItems] = useState<string[]>([]);
|
||||
const [contents, setContents] = useState<Record<string, string>>({});
|
||||
const [editedContents, setEditedContents] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetchList();
|
||||
setFiles(resp.files);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: `Failed to load ${label.toLowerCase()} files.`,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchList, label]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleAccordionToggle = useCallback(
|
||||
(
|
||||
_e: React.SyntheticEvent,
|
||||
data: { openItems: (string | number)[] },
|
||||
) => {
|
||||
const next = data.openItems as string[];
|
||||
const newlyOpened = next.filter((v) => !openItems.includes(v));
|
||||
setOpenItems(next);
|
||||
for (const name of newlyOpened) {
|
||||
if (!Object.prototype.hasOwnProperty.call(contents, name)) {
|
||||
void fetchFile(name)
|
||||
.then((c) => {
|
||||
setContents((prev) => ({ ...prev, [name]: c.content }));
|
||||
setEditedContents((prev) => ({ ...prev, [name]: c.content }));
|
||||
})
|
||||
.catch(() => {
|
||||
setContents((prev) => ({
|
||||
...prev,
|
||||
[name]: "(failed to load)",
|
||||
}));
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[name]: "(failed to load)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[openItems, contents, fetchFile],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (name: string) => {
|
||||
setSaving(name);
|
||||
setMsg(null);
|
||||
try {
|
||||
const content = editedContents[name] ?? contents[name] ?? "";
|
||||
await updateFile(name, { content });
|
||||
setContents((prev) => ({ ...prev, [name]: content }));
|
||||
setMsg({ text: `${name} saved.`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Save failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
},
|
||||
[editedContents, contents, updateFile],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setCreating(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const created = await createFile({ name, content: newContent });
|
||||
setFiles((prev) => [
|
||||
...prev,
|
||||
{ name: created.name, filename: created.filename },
|
||||
]);
|
||||
setContents((prev) => ({ ...prev, [created.name]: created.content }));
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[created.name]: created.content,
|
||||
}));
|
||||
setNewName("");
|
||||
setNewContent("");
|
||||
setMsg({ text: `${created.filename} created.`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Create failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newName, newContent, createFile]);
|
||||
|
||||
if (loading) return <Spinner label={`Loading ${label.toLowerCase()} files…`} />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && (
|
||||
<MessageBar
|
||||
intent={msg.ok ? "success" : "error"}
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={() => void loadFiles()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{files.length === 0 && (
|
||||
<Text
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
No {label.toLowerCase()} files found.
|
||||
</Text>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
openItems={openItems}
|
||||
onToggle={handleAccordionToggle}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<AccordionItem key={file.name} value={file.name}>
|
||||
<AccordionHeader>
|
||||
<Text className={styles.codeFont}>{file.filename}</Text>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
{openItems.includes(file.name) &&
|
||||
(contents[file.name] === undefined ? (
|
||||
<Spinner size="tiny" label="Loading…" />
|
||||
) : (
|
||||
<div>
|
||||
<Textarea
|
||||
value={editedContents[file.name] ?? ""}
|
||||
rows={20}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onChange={(_e, d) => {
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[file.name]: d.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Save24Regular />}
|
||||
disabled={saving === file.name}
|
||||
onClick={() => void handleSave(file.name)}
|
||||
>
|
||||
{saving === file.name ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
{/* Create new file */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: tokens.spacingVerticalXL,
|
||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
paddingTop: tokens.spacingVerticalM,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="h3"
|
||||
size={400}
|
||||
weight="semibold"
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
New {label} File
|
||||
</Text>
|
||||
<Field label="Name (without .conf extension)">
|
||||
<Input
|
||||
value={newName}
|
||||
placeholder={`e.g. my-${label.toLowerCase()}`}
|
||||
className={styles.codeFont}
|
||||
onChange={(_e, d) => {
|
||||
setNewName(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Content" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Textarea
|
||||
value={newContent}
|
||||
rows={10}
|
||||
placeholder={`[Definition]\n# …`}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onChange={(_e, d) => {
|
||||
setNewContent(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={creating || !newName.trim()}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? "Creating…" : `Create ${label} File`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersTab(): React.JSX.Element {
|
||||
return (
|
||||
<ConfFilesTab
|
||||
label="Filter"
|
||||
fetchList={fetchFilterFiles}
|
||||
fetchFile={fetchFilterFile}
|
||||
updateFile={updateFilterFile}
|
||||
createFile={createFilterFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsTab(): React.JSX.Element {
|
||||
return (
|
||||
<ConfFilesTab
|
||||
label="Action"
|
||||
fetchList={fetchActionFiles}
|
||||
fetchFile={fetchActionFile}
|
||||
updateFile={updateActionFile}
|
||||
createFile={createActionFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RegexTesterTab
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1114,7 +1711,15 @@ function RegexTesterTab(): React.JSX.Element {
|
||||
// ConfigPage (root)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabValue = "jails" | "global" | "server" | "map" | "regex";
|
||||
type TabValue =
|
||||
| "jails"
|
||||
| "jailfiles"
|
||||
| "filters"
|
||||
| "actions"
|
||||
| "global"
|
||||
| "server"
|
||||
| "map"
|
||||
| "regex";
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
@@ -1139,6 +1744,9 @@ export function ConfigPage(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<Tab value="jails">Jails</Tab>
|
||||
<Tab value="jailfiles">Jail Files</Tab>
|
||||
<Tab value="filters">Filters</Tab>
|
||||
<Tab value="actions">Actions</Tab>
|
||||
<Tab value="global">Global</Tab>
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
@@ -1147,6 +1755,9 @@ export function ConfigPage(): React.JSX.Element {
|
||||
|
||||
<div className={styles.tabContent}>
|
||||
{tab === "jails" && <JailsTab />}
|
||||
{tab === "jailfiles" && <JailFilesTab />}
|
||||
{tab === "filters" && <FiltersTab />}
|
||||
{tab === "actions" && <ActionsTab />}
|
||||
{tab === "global" && <GlobalTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
|
||||
Reference in New Issue
Block a user