From 7e1540f84bd433e8ef1859ff5775f8a14a5a1e08 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 15:47:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20/admin/jobs/settings=20page=20?= =?UTF-8?q?=E2=80=94=20inline-edit=20Celery=20job=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET+PUT table for all job_types (scrape_kn, nspd_geo, objective_etl, objective_sync). Per-row drafts, collapsible extra_config JSON editor, toast notifications. Tab added to admin layout. Hand-rolled types until codegen picks up new endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/app/admin/jobs/settings/page.tsx | 612 ++++ frontend/src/app/admin/layout.tsx | 1 + frontend/src/lib/api-types.ts | 2935 +++++++++++++++++ 3 files changed, 3548 insertions(+) create mode 100644 frontend/src/app/admin/jobs/settings/page.tsx create mode 100644 frontend/src/lib/api-types.ts diff --git a/frontend/src/app/admin/jobs/settings/page.tsx b/frontend/src/app/admin/jobs/settings/page.tsx new file mode 100644 index 00000000..fe8afafd --- /dev/null +++ b/frontend/src/app/admin/jobs/settings/page.tsx @@ -0,0 +1,612 @@ +"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 && ( +
+