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:
355
frontend/src/hooks/useConfig.ts
Normal file
355
frontend/src/hooks/useConfig.ts
Normal 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_ };
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user