Stage 7: configuration view — backend service, routers, tests, and frontend

- config_service.py: read/write jail config via asyncio.gather, global
  settings, in-process regex validation, log preview via _read_tail_lines
- server_service.py: read/write server settings, flush logs
- config router: 9 endpoints for jail/global config, regex-test,
  logpath management, log preview
- server router: GET/PUT settings, POST flush-logs
- models/config.py expanded with JailConfig, GlobalConfigUpdate,
  LogPreview* models
- 285 tests pass (68 new), ruff clean, mypy clean (44 files)
- Frontend: types/config.ts, api/config.ts, hooks/useConfig.ts,
  ConfigPage.tsx full implementation (Jails accordion editor,
  Global config, Server settings, Regex Tester with preview)
- Fixed pre-existing frontend lint: JSX.Element → React.JSX.Element
  (10 files), void/promise patterns in useServerStatus + useJails,
  no-misused-spread in client.ts, eslint.config.ts self-excluded
This commit is contained in:
2026-03-01 14:37:55 +01:00
parent ebec5e0f58
commit 7f81f0614b
33 changed files with 4488 additions and 82 deletions

View File

@@ -4,7 +4,7 @@ import reactHooks from "eslint-plugin-react-hooks";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ["dist"] },
{ ignores: ["dist", "eslint.config.ts"] },
{
extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked],
files: ["**/*.{ts,tsx}"],

View File

@@ -38,7 +38,7 @@ import { BlocklistsPage } from "./pages/BlocklistsPage";
/**
* Root application component — mounts providers and top-level routes.
*/
function App(): JSX.Element {
function App(): React.JSX.Element {
return (
<FluentProvider theme={lightTheme}>
<BrowserRouter>

View File

@@ -57,7 +57,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
credentials: "include",
headers: {
"Content-Type": "application/json",
...options.headers,
...(options.headers as Record<string, string> | undefined),
},
});

121
frontend/src/api/config.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* API functions for the configuration and server settings endpoints.
*/
import { get, post, put } from "./client";
import { ENDPOINTS } from "./endpoints";
import type {
AddLogPathRequest,
GlobalConfig,
GlobalConfigUpdate,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
LogPreviewRequest,
LogPreviewResponse,
RegexTestRequest,
RegexTestResponse,
ServerSettingsResponse,
ServerSettingsUpdate,
} from "../types/config";
// ---------------------------------------------------------------------------
// Jail configuration
// ---------------------------------------------------------------------------
export async function fetchJailConfigs(
): Promise<JailConfigListResponse> {
return get<JailConfigListResponse>(ENDPOINTS.configJails);
}
export async function fetchJailConfig(
name: string
): Promise<JailConfigResponse> {
return get<JailConfigResponse>(ENDPOINTS.configJail(name));
}
export async function updateJailConfig(
name: string,
update: JailConfigUpdate
): Promise<void> {
await put<undefined>(ENDPOINTS.configJail(name), update);
}
// ---------------------------------------------------------------------------
// Global configuration
// ---------------------------------------------------------------------------
export async function fetchGlobalConfig(
): Promise<GlobalConfig> {
return get<GlobalConfig>(ENDPOINTS.configGlobal);
}
export async function updateGlobalConfig(
update: GlobalConfigUpdate
): Promise<void> {
await put<undefined>(ENDPOINTS.configGlobal, update);
}
// ---------------------------------------------------------------------------
// Reload
// ---------------------------------------------------------------------------
export async function reloadConfig(
): Promise<void> {
await post<undefined>(ENDPOINTS.configReload, undefined);
}
// ---------------------------------------------------------------------------
// Regex tester
// ---------------------------------------------------------------------------
export async function testRegex(
req: RegexTestRequest
): Promise<RegexTestResponse> {
return post<RegexTestResponse>(ENDPOINTS.configRegexTest, req);
}
// ---------------------------------------------------------------------------
// Log path management
// ---------------------------------------------------------------------------
export async function addLogPath(
jailName: string,
req: AddLogPathRequest
): Promise<void> {
await post<undefined>(ENDPOINTS.configJailLogPath(jailName), req);
}
// ---------------------------------------------------------------------------
// Log preview
// ---------------------------------------------------------------------------
export async function previewLog(
req: LogPreviewRequest
): Promise<LogPreviewResponse> {
return post<LogPreviewResponse>(ENDPOINTS.configPreviewLog, req);
}
// ---------------------------------------------------------------------------
// Server settings
// ---------------------------------------------------------------------------
export async function fetchServerSettings(
): Promise<ServerSettingsResponse> {
return get<ServerSettingsResponse>(ENDPOINTS.serverSettings);
}
export async function updateServerSettings(
update: ServerSettingsUpdate
): Promise<void> {
await put<undefined>(ENDPOINTS.serverSettings, update);
}
export async function flushLogs(
): Promise<string> {
const resp = await post<{ message: string }>(
ENDPOINTS.serverFlushLogs,
undefined,
);
return resp.message;
}

View File

@@ -58,9 +58,12 @@ export const ENDPOINTS = {
// -------------------------------------------------------------------------
configJails: "/config/jails",
configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`,
configJailLogPath: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/logpath`,
configGlobal: "/config/global",
configReload: "/config/reload",
configRegexTest: "/config/regex-test",
configPreviewLog: "/config/preview-log",
// -------------------------------------------------------------------------
// Server settings

View File

@@ -11,7 +11,7 @@ import { useAuth } from "../providers/AuthProvider";
interface RequireAuthProps {
/** The protected page content to render when authenticated. */
children: JSX.Element;
children: React.JSX.Element;
}
/**
@@ -20,7 +20,7 @@ interface RequireAuthProps {
* Redirects to `/login?next=<path>` otherwise so the intended destination is
* preserved and honoured after a successful login.
*/
export function RequireAuth({ children }: RequireAuthProps): JSX.Element {
export function RequireAuth({ children }: RequireAuthProps): React.JSX.Element {
const { isAuthenticated } = useAuth();
const location = useLocation();

View File

@@ -81,7 +81,7 @@ const useStyles = makeStyles({
* Render this at the top of the dashboard page (and any page that should
* show live server status).
*/
export function ServerStatusBar(): JSX.Element {
export function ServerStatusBar(): React.JSX.Element {
const styles = useStyles();
const { status, loading, error, refresh } = useServerStatus();

View File

@@ -0,0 +1,355 @@
/**
* React hooks for the configuration and server settings data.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
addLogPath,
fetchGlobalConfig,
fetchJailConfig,
fetchJailConfigs,
fetchServerSettings,
flushLogs,
previewLog,
reloadConfig,
testRegex,
updateGlobalConfig,
updateJailConfig,
updateServerSettings,
} from "../api/config";
import type {
AddLogPathRequest,
GlobalConfig,
GlobalConfigUpdate,
JailConfig,
JailConfigUpdate,
LogPreviewRequest,
LogPreviewResponse,
RegexTestRequest,
RegexTestResponse,
ServerSettings,
ServerSettingsUpdate,
} from "../types/config";
// ---------------------------------------------------------------------------
// useJailConfigs — list all jail configs
// ---------------------------------------------------------------------------
interface UseJailConfigsResult {
jails: JailConfig[];
total: number;
loading: boolean;
error: string | null;
refresh: () => void;
updateJail: (name: string, update: JailConfigUpdate) => Promise<void>;
reloadAll: () => Promise<void>;
}
export function useJailConfigs(): UseJailConfigsResult {
const [jails, setJails] = useState<JailConfig[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchJailConfigs()
.then((resp) => {
setJails(resp.jails);
setTotal(resp.total);
})
.catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
})
.finally(() => {
setLoading(false);
});
}, []);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const updateJail = useCallback(
async (name: string, update: JailConfigUpdate): Promise<void> => {
await updateJailConfig(name, update);
load();
},
[load],
);
const reloadAll = useCallback(async (): Promise<void> => {
await reloadConfig();
load();
}, [load]);
return { jails, total, loading, error, refresh: load, updateJail, reloadAll };
}
// ---------------------------------------------------------------------------
// useJailConfigDetail — single jail config with mutation
// ---------------------------------------------------------------------------
interface UseJailConfigDetailResult {
jail: JailConfig | null;
loading: boolean;
error: string | null;
refresh: () => void;
updateJail: (update: JailConfigUpdate) => Promise<void>;
addLog: (req: AddLogPathRequest) => Promise<void>;
}
export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
const [jail, setJail] = useState<JailConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchJailConfig(name)
.then((resp) => {
setJail(resp.jail);
})
.catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
})
.finally(() => {
setLoading(false);
});
}, [name]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const updateJail = useCallback(
async (update: JailConfigUpdate): Promise<void> => {
await updateJailConfig(name, update);
load();
},
[name, load],
);
const addLog = useCallback(
async (req: AddLogPathRequest): Promise<void> => {
await addLogPath(name, req);
load();
},
[name, load],
);
return { jail, loading, error, refresh: load, updateJail, addLog };
}
// ---------------------------------------------------------------------------
// useGlobalConfig
// ---------------------------------------------------------------------------
interface UseGlobalConfigResult {
config: GlobalConfig | null;
loading: boolean;
error: string | null;
refresh: () => void;
updateConfig: (update: GlobalConfigUpdate) => Promise<void>;
}
export function useGlobalConfig(): UseGlobalConfigResult {
const [config, setConfig] = useState<GlobalConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchGlobalConfig()
.then(setConfig)
.catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
})
.finally(() => {
setLoading(false);
});
}, []);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const updateConfig = useCallback(
async (update: GlobalConfigUpdate): Promise<void> => {
await updateGlobalConfig(update);
load();
},
[load],
);
return { config, loading, error, refresh: load, updateConfig };
}
// ---------------------------------------------------------------------------
// useServerSettings
// ---------------------------------------------------------------------------
interface UseServerSettingsResult {
settings: ServerSettings | null;
loading: boolean;
error: string | null;
refresh: () => void;
updateSettings: (update: ServerSettingsUpdate) => Promise<void>;
flush: () => Promise<string>;
}
export function useServerSettings(): UseServerSettingsResult {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchServerSettings()
.then((resp) => {
setSettings(resp.settings);
})
.catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
})
.finally(() => {
setLoading(false);
});
}, []);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const updateSettings_ = useCallback(
async (update: ServerSettingsUpdate): Promise<void> => {
await updateServerSettings(update);
load();
},
[load],
);
const flush = useCallback(async (): Promise<string> => {
return flushLogs();
}, []);
return {
settings,
loading,
error,
refresh: load,
updateSettings: updateSettings_,
flush,
};
}
// ---------------------------------------------------------------------------
// useRegexTester — lazy, triggered by test(req)
// ---------------------------------------------------------------------------
interface UseRegexTesterResult {
result: RegexTestResponse | null;
testing: boolean;
test: (req: RegexTestRequest) => Promise<void>;
}
export function useRegexTester(): UseRegexTesterResult {
const [result, setResult] = useState<RegexTestResponse | null>(null);
const [testing, setTesting] = useState(false);
const test_ = useCallback(async (req: RegexTestRequest): Promise<void> => {
setTesting(true);
try {
const resp = await testRegex(req);
setResult(resp);
} catch (err: unknown) {
if (err instanceof Error) {
setResult({ matched: false, groups: [], error: err.message });
}
} finally {
setTesting(false);
}
}, []);
return { result, testing, test: test_ };
}
// ---------------------------------------------------------------------------
// useLogPreview — lazy, triggered by preview(req)
// ---------------------------------------------------------------------------
interface UseLogPreviewResult {
preview: LogPreviewResponse | null;
loading: boolean;
run: (req: LogPreviewRequest) => Promise<void>;
}
export function useLogPreview(): UseLogPreviewResult {
const [preview, setPreview] = useState<LogPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const run_ = useCallback(async (req: LogPreviewRequest): Promise<void> => {
setLoading(true);
try {
const resp = await previewLog(req);
setPreview(resp);
} catch (err: unknown) {
if (err instanceof Error) {
setPreview({
lines: [],
total_lines: 0,
matched_count: 0,
regex_error: err.message,
});
}
} finally {
setLoading(false);
}
}, []);
return { preview, loading, run: run_ };
}

View File

@@ -100,7 +100,7 @@ export function useJails(): UseJailsResult {
useEffect(() => {
load();
return () => {
return (): void => {
abortRef.current?.abort();
};
}, [load]);
@@ -120,9 +120,9 @@ export function useJails(): UseJailsResult {
refresh: load,
startJail: withRefresh(startJail),
stopJail: withRefresh(stopJail),
setIdle: (name, on) => setJailIdle(name, on).then(() => load()),
setIdle: (name, on) => setJailIdle(name, on).then((): void => { load(); }),
reloadJail: withRefresh(reloadJail),
reloadAll: () => reloadAllJails().then(() => load()),
reloadAll: () => reloadAllJails().then((): void => { load(); }),
};
}
@@ -191,7 +191,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
useEffect(() => {
load();
return () => {
return (): void => {
abortRef.current?.abort();
};
}, [load]);
@@ -278,7 +278,7 @@ export function useActiveBans(): UseActiveBansResult {
useEffect(() => {
load();
return () => {
return (): void => {
abortRef.current?.abort();
};
}, [load]);

View File

@@ -36,7 +36,7 @@ export function useServerStatus(): UseServerStatusResult {
const [error, setError] = useState<string | null>(null);
// Use a ref so the fetch function identity is stable.
const fetchRef = useRef<() => void>(() => undefined);
const fetchRef = useRef<() => Promise<void>>(async () => Promise.resolve());
const doFetch = useCallback(async (): Promise<void> => {
setLoading(true);
@@ -54,14 +54,14 @@ export function useServerStatus(): UseServerStatusResult {
fetchRef.current = doFetch;
// Initial fetch + polling interval.
useEffect(() => {
void doFetch();
useEffect((): (() => void) => {
void doFetch().catch((): void => undefined);
const id = setInterval(() => {
void fetchRef.current();
const id = setInterval((): void => {
void fetchRef.current().catch((): void => undefined);
}, POLL_INTERVAL_MS);
return () => clearInterval(id);
return (): void => { clearInterval(id); };
}, [doFetch]);
// Refetch on window focus.
@@ -70,11 +70,11 @@ export function useServerStatus(): UseServerStatusResult {
void fetchRef.current();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
return (): void => { window.removeEventListener("focus", onFocus); };
}, []);
const refresh = useCallback((): void => {
void doFetch();
void doFetch().catch((): void => undefined);
}, [doFetch]);
return { status, loading, error, refresh };

View File

@@ -176,7 +176,7 @@ const NAV_ITEMS: NavItem[] = [
* Renders child routes via `<Outlet />`. Use inside React Router
* as a layout route wrapping all authenticated pages.
*/
export function MainLayout(): JSX.Element {
export function MainLayout(): React.JSX.Element {
const styles = useStyles();
const { logout } = useAuth();
const navigate = useNavigate();

View File

@@ -8,7 +8,7 @@ const useStyles = makeStyles({
root: { padding: tokens.spacingVerticalXXL },
});
export function BlocklistsPage(): JSX.Element {
export function BlocklistsPage(): React.JSX.Element {
const styles = useStyles();
return (
<div className={styles.root}>

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ const useStyles = makeStyles({
root: { padding: tokens.spacingVerticalXXL },
});
export function HistoryPage(): JSX.Element {
export function HistoryPage(): React.JSX.Element {
const styles = useStyles();
return (
<div className={styles.root}>

View File

@@ -71,7 +71,7 @@ const useStyles = makeStyles({
/**
* Login page — single password input, no username.
*/
export function LoginPage(): JSX.Element {
export function LoginPage(): React.JSX.Element {
const styles = useStyles();
const navigate = useNavigate();
const [searchParams] = useSearchParams();

View File

@@ -8,7 +8,7 @@ const useStyles = makeStyles({
root: { padding: tokens.spacingVerticalXXL },
});
export function MapPage(): JSX.Element {
export function MapPage(): React.JSX.Element {
const styles = useStyles();
return (
<div className={styles.root}>

View File

@@ -96,7 +96,7 @@ const DEFAULT_VALUES: FormValues = {
* First-run setup wizard page.
* Collects master password and server preferences.
*/
export function SetupPage(): JSX.Element {
export function SetupPage(): React.JSX.Element {
const styles = useStyles();
const navigate = useNavigate();

View File

@@ -60,7 +60,7 @@ export function AuthProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
}): React.JSX.Element {
const [auth, setAuth] = useState<AuthState>(() => ({
token: sessionStorage.getItem(SESSION_KEY),
expiresAt: sessionStorage.getItem(SESSION_EXPIRES_KEY),

View File

@@ -0,0 +1,130 @@
/**
* TypeScript interfaces for the configuration and server settings API.
*/
// ---------------------------------------------------------------------------
// Jail Configuration
// ---------------------------------------------------------------------------
export interface JailConfig {
name: string;
ban_time: number;
max_retry: number;
find_time: number;
fail_regex: string[];
ignore_regex: string[];
log_paths: string[];
date_pattern: string | null;
log_encoding: string;
backend: string;
actions: string[];
}
export interface JailConfigResponse {
jail: JailConfig;
}
export interface JailConfigListResponse {
jails: JailConfig[];
total: number;
}
export interface JailConfigUpdate {
ban_time?: number | null;
max_retry?: number | null;
find_time?: number | null;
fail_regex?: string[] | null;
ignore_regex?: string[] | null;
date_pattern?: string | null;
dns_mode?: string | null;
enabled?: boolean | null;
}
// ---------------------------------------------------------------------------
// Global Configuration
// ---------------------------------------------------------------------------
export interface GlobalConfig {
log_level: string;
log_target: string;
db_purge_age: number;
db_max_matches: number;
}
export interface GlobalConfigUpdate {
log_level?: string | null;
log_target?: string | null;
db_purge_age?: number | null;
db_max_matches?: number | null;
}
// ---------------------------------------------------------------------------
// Server Settings
// ---------------------------------------------------------------------------
export interface ServerSettings {
log_level: string;
log_target: string;
syslog_socket: string | null;
db_path: string;
db_purge_age: number;
db_max_matches: number;
}
export interface ServerSettingsResponse {
settings: ServerSettings;
}
export interface ServerSettingsUpdate {
log_level?: string | null;
log_target?: string | null;
db_purge_age?: number | null;
db_max_matches?: number | null;
}
// ---------------------------------------------------------------------------
// Regex Tester
// ---------------------------------------------------------------------------
export interface RegexTestRequest {
log_line: string;
fail_regex: string;
}
export interface RegexTestResponse {
matched: boolean;
groups: string[];
error: string | null;
}
// ---------------------------------------------------------------------------
// Log Preview
// ---------------------------------------------------------------------------
export interface LogPreviewRequest {
log_path: string;
fail_regex: string;
num_lines?: number;
}
export interface LogPreviewLine {
line: string;
matched: boolean;
groups: string[];
}
export interface LogPreviewResponse {
lines: LogPreviewLine[];
total_lines: number;
matched_count: number;
regex_error: string | null;
}
// ---------------------------------------------------------------------------
// Add Log Path
// ---------------------------------------------------------------------------
export interface AddLogPathRequest {
log_path: string;
tail?: boolean;
}