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

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