"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; // Hand-rolled types — replace with codegen once backend job_settings endpoints // appear in /openapi.json (run `npm run codegen` then delete this block). interface JobSettingRead { job_type: string; enabled: boolean; queue_name: string; cron_schedule: string | null; rate_ms: number | null; max_retries: number; max_concurrency: number; extra_config: Record; updated_at: string; updated_by: string | null; description: string | null; } interface JobSettingUpdate { enabled?: boolean | null; queue_name?: string | null; cron_schedule?: string | null; rate_ms?: number | null; max_retries?: number | null; max_concurrency?: number | null; extra_config?: Record | null; } // Per-row draft state (mirrors JobSettingRead fields that are editable) interface RowDraft { enabled: boolean; cron_schedule: string; rate_ms: string; // kept as string for controlled input, parsed on save max_retries: string; max_concurrency: string; extra_config_text: string; // raw textarea JSON extra_config_error: string | null; } // Toast state type interface Toast { id: number; message: string; kind: "success" | "error"; } function toRowDraft(s: JobSettingRead): RowDraft { return { enabled: s.enabled, cron_schedule: s.cron_schedule ?? "", rate_ms: s.rate_ms != null ? String(s.rate_ms) : "", max_retries: String(s.max_retries), max_concurrency: String(s.max_concurrency), extra_config_text: JSON.stringify(s.extra_config, null, 2), extra_config_error: null, }; } function formatIso(s: string): string { return new Date(s).toLocaleString("ru-RU"); } export default function JobSettingsPage() { const [token, setToken] = useState(() => typeof window === "undefined" ? "" : (localStorage.getItem("admin_token") ?? ""), ); // Per-row drafts keyed by job_type const [drafts, setDrafts] = useState>({}); // Which rows have the extra_config textarea expanded const [expanded, setExpanded] = useState>({}); const [toasts, setToasts] = useState([]); const toastSeqRef = useRef(0); const queryClient = useQueryClient(); const saveToken = (v: string) => { setToken(v); if (typeof window !== "undefined") { localStorage.setItem("admin_token", v); } }; const addToast = useCallback((message: string, kind: "success" | "error") => { const id = ++toastSeqRef.current; setToasts((prev: Toast[]) => [...prev, { id, message, kind }]); window.setTimeout( () => setToasts((prev: Toast[]) => prev.filter((t: Toast) => t.id !== id)), 4000, ); }, []); const settings = useQuery({ queryKey: ["job-settings", token], queryFn: () => apiFetch("/api/v1/admin/jobs/settings", { headers: { "X-Admin-Token": token }, }), enabled: !!token, }); // Initialise drafts when settings data first arrives (or new job_types appear). // Using useEffect for syncing external data → local draft state, per codebase pattern. useEffect(() => { if (!settings.data) return; setDrafts((prev: Record) => { const next = { ...prev }; let changed = false; for (const s of settings.data) { if (!(s.job_type in next)) { next[s.job_type] = toRowDraft(s); changed = true; } } return changed ? next : prev; }); }, [settings.data]); const updateMutation = useMutation({ mutationFn: ({ job_type, update, }: { job_type: string; update: JobSettingUpdate; }) => apiFetch(`/api/v1/admin/jobs/settings/${job_type}`, { method: "PUT", headers: { "X-Admin-Token": token }, body: JSON.stringify(update), }), onSuccess: (data: JobSettingRead) => { setDrafts((prev: Record) => ({ ...prev, [data.job_type]: toRowDraft(data), })); void queryClient.invalidateQueries({ queryKey: ["job-settings"] }); addToast(`${data.job_type}: сохранено`, "success"); }, onError: ( err: unknown, variables: { job_type: string; update: JobSettingUpdate }, ) => { addToast( `${variables.job_type}: ошибка — ${(err as Error).message}`, "error", ); }, }); const patchDraft = (job_type: string, patch: Partial) => { setDrafts((prev: Record) => ({ ...prev, [job_type]: { ...(prev[job_type] ?? {}), ...patch } as RowDraft, })); }; const handleSave = (job_type: string) => { const draft = drafts[job_type]; if (!draft) return; // Parse extra_config JSON only when the textarea is expanded let extra_config: Record | undefined; if (expanded[job_type]) { try { const parsed = JSON.parse(draft.extra_config_text) as unknown; if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { patchDraft(job_type, { extra_config_error: "Должен быть JSON-объект (не массив и не null)", }); return; } extra_config = parsed as Record; patchDraft(job_type, { extra_config_error: null }); } catch (e) { patchDraft(job_type, { extra_config_error: `Невалидный JSON: ${(e as Error).message}`, }); return; } } const update: JobSettingUpdate = { enabled: draft.enabled, cron_schedule: draft.cron_schedule.trim() || null, rate_ms: draft.rate_ms !== "" ? Number(draft.rate_ms) : null, max_retries: Number(draft.max_retries), max_concurrency: Number(draft.max_concurrency), }; if (extra_config !== undefined) { update.extra_config = extra_config; } updateMutation.mutate({ job_type, update }); }; const isPendingFor = (job_type: string) => updateMutation.isPending && ( updateMutation.variables as | { job_type: string; update: JobSettingUpdate } | undefined )?.job_type === job_type; return ( <> {/* Toast notifications */}
{toasts.map((t: Toast) => (
{t.kind === "success" ? "✓ " : "✕ "} {t.message}
))}

Job Settings

Централизованные настройки Celery jobs. Изменения применяются с следующего beat-цикла. Manual trigger остаётся в{" "} /admin/scrape .

{/* Token */}
{/* Table */}
{!token ? (

Введите Admin Token выше чтобы загрузить настройки.

) : settings.isLoading ? (

Загрузка…

) : settings.isError ? (

Ошибка: {String(settings.error)}

) : (
{[ "job_type", "enabled", "queue_name", "cron_schedule", "rate_ms", "max_retries", "max_concurrency", "extra_config", "updated_at", "", ].map((h) => ( ))} {(settings.data ?? []).map((row: JobSettingRead) => { const draft = drafts[row.job_type]; if (!draft) return null; const isExpanded = expanded[row.job_type] ?? false; const saving = isPendingFor(row.job_type); return ( {/* job_type — read-only */} {/* enabled — checkbox */} {/* queue_name — read-only (infra config) */} {/* cron_schedule */} {/* rate_ms */} {/* max_retries */} {/* max_concurrency */} {/* extra_config — collapsible textarea */}
{h}
{row.job_type} {row.description && (
{row.description}
)}
) => patchDraft(row.job_type, { enabled: e.target.checked, }) } style={{ width: 16, height: 16, cursor: "pointer" }} /> {row.queue_name} ) => patchDraft(row.job_type, { cron_schedule: e.target.value, }) } placeholder="* * * * *" style={{ ...compactInput, fontFamily: "ui-monospace,SFMono-Regular,monospace", width: 130, }} /> ) => patchDraft(row.job_type, { rate_ms: e.target.value, }) } placeholder="—" style={{ ...compactInput, width: 80 }} /> ) => patchDraft(row.job_type, { max_retries: e.target.value, }) } style={{ ...compactInput, width: 60 }} /> ) => patchDraft(row.job_type, { max_concurrency: e.target.value, }) } style={{ ...compactInput, width: 60 }} /> {isExpanded && (