gendesign/frontend/src/app/admin/jobs/settings/page.tsx
lekss361 7e1540f84b feat(admin): /admin/jobs/settings page — inline-edit Celery job config
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) <noreply@anthropic.com>
2026-05-11 15:47:39 +03:00

612 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, unknown>;
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<string, unknown> | 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<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
// Per-row drafts keyed by job_type
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
// Which rows have the extra_config textarea expanded
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [toasts, setToasts] = useState<Toast[]>([]);
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<JobSettingRead[]>("/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<string, RowDraft>) => {
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<JobSettingRead>(`/api/v1/admin/jobs/settings/${job_type}`, {
method: "PUT",
headers: { "X-Admin-Token": token },
body: JSON.stringify(update),
}),
onSuccess: (data: JobSettingRead) => {
setDrafts((prev: Record<string, RowDraft>) => ({
...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<RowDraft>) => {
setDrafts((prev: Record<string, RowDraft>) => ({
...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<string, unknown> | 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<string, unknown>;
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 */}
<div
style={{
position: "fixed",
top: 20,
right: 20,
zIndex: 9999,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{toasts.map((t: Toast) => (
<div
key={t.id}
style={{
padding: "10px 16px",
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
background: t.kind === "success" ? "#f0fdf4" : "#fef2f2",
border: `1px solid ${t.kind === "success" ? "#bbf7d0" : "#fecaca"}`,
color: t.kind === "success" ? "#065f46" : "#991b1b",
boxShadow: "0 2px 8px rgba(0,0,0,.08)",
maxWidth: 360,
wordBreak: "break-word",
}}
>
{t.kind === "success" ? "✓ " : "✕ "}
{t.message}
</div>
))}
</div>
<h2 style={{ marginTop: 0, fontSize: 18 }}>Job Settings</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Централизованные настройки Celery jobs. Изменения применяются с
следующего beat-цикла. Manual trigger остаётся в{" "}
<a
href="/admin/scrape"
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
/admin/scrape
</a>
.
</p>
{/* Token */}
<section style={cardStyle}>
<label style={{ display: "block" }}>
<span style={labelStyle}>Admin Token (X-Admin-Token)</span>
<input
type="password"
value={token}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
saveToken(e.target.value)
}
placeholder="SCRAPE_ADMIN_TOKEN — сохранится в localStorage"
style={{ ...inputStyle, maxWidth: 400 }}
/>
</label>
</section>
{/* Table */}
<section style={{ ...cardStyle, marginTop: 16 }}>
{!token ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token выше чтобы загрузить настройки.
</p>
) : settings.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p>
) : settings.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(settings.error)}</p>
) : (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"job_type",
"enabled",
"queue_name",
"cron_schedule",
"rate_ms",
"max_retries",
"max_concurrency",
"extra_config",
"updated_at",
"",
].map((h) => (
<th key={h} style={th}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(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 (
<tr
key={row.job_type}
style={{
borderBottom: "1px solid #eef0f3",
verticalAlign: "top",
opacity: saving ? 0.6 : 1,
transition: "opacity .15s",
}}
>
{/* job_type — read-only */}
<td style={{ ...td, fontWeight: 600 }}>
<code
style={{
fontFamily: "ui-monospace,SFMono-Regular,monospace",
background: "#f3f4f6",
padding: "2px 6px",
borderRadius: 4,
}}
>
{row.job_type}
</code>
{row.description && (
<div
style={{
fontSize: 11,
color: "#6b7280",
marginTop: 4,
maxWidth: 160,
}}
>
{row.description}
</div>
)}
</td>
{/* enabled — checkbox */}
<td style={{ ...td, textAlign: "center" }}>
<input
type="checkbox"
checked={draft.enabled}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
patchDraft(row.job_type, {
enabled: e.target.checked,
})
}
style={{ width: 16, height: 16, cursor: "pointer" }}
/>
</td>
{/* queue_name — read-only (infra config) */}
<td style={{ ...td, color: "#6b7280" }}>
<code style={{ fontSize: 12 }}>{row.queue_name}</code>
</td>
{/* cron_schedule */}
<td style={td}>
<input
type="text"
value={draft.cron_schedule}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
patchDraft(row.job_type, {
cron_schedule: e.target.value,
})
}
placeholder="* * * * *"
style={{
...compactInput,
fontFamily: "ui-monospace,SFMono-Regular,monospace",
width: 130,
}}
/>
</td>
{/* rate_ms */}
<td style={td}>
<input
type="number"
value={draft.rate_ms}
min={0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
patchDraft(row.job_type, {
rate_ms: e.target.value,
})
}
placeholder="—"
style={{ ...compactInput, width: 80 }}
/>
</td>
{/* max_retries */}
<td style={td}>
<input
type="number"
value={draft.max_retries}
min={0}
max={10}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
patchDraft(row.job_type, {
max_retries: e.target.value,
})
}
style={{ ...compactInput, width: 60 }}
/>
</td>
{/* max_concurrency */}
<td style={td}>
<input
type="number"
value={draft.max_concurrency}
min={1}
max={20}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
patchDraft(row.job_type, {
max_concurrency: e.target.value,
})
}
style={{ ...compactInput, width: 60 }}
/>
</td>
{/* extra_config — collapsible textarea */}
<td style={td}>
<button
type="button"
onClick={() =>
setExpanded((prev: Record<string, boolean>) => ({
...prev,
[row.job_type]: !isExpanded,
}))
}
style={toggleBtn}
>
{isExpanded ? "▼ скрыть" : "▶ JSON"}
</button>
{isExpanded && (
<div style={{ marginTop: 6 }}>
<textarea
value={draft.extra_config_text}
onChange={(
e: React.ChangeEvent<HTMLTextAreaElement>,
) => {
patchDraft(row.job_type, {
extra_config_text: e.target.value,
extra_config_error: null,
});
}}
rows={6}
style={{
width: 280,
fontFamily:
"ui-monospace,SFMono-Regular,monospace",
fontSize: 11,
padding: "6px 8px",
border: draft.extra_config_error
? "1px solid #fca5a5"
: "1px solid #d1d5db",
borderRadius: 6,
resize: "vertical",
boxSizing: "border-box",
}}
spellCheck={false}
/>
{draft.extra_config_error && (
<div
style={{
color: "#b3261e",
fontSize: 11,
marginTop: 2,
}}
>
{draft.extra_config_error}
</div>
)}
</div>
)}
</td>
{/* updated_at — read-only */}
<td
style={{
...td,
color: "#6b7280",
fontSize: 11,
whiteSpace: "nowrap",
}}
>
{formatIso(row.updated_at)}
{row.updated_by && (
<div style={{ color: "#9ca3af" }}>
{row.updated_by}
</div>
)}
</td>
{/* Save button */}
<td style={{ ...td, whiteSpace: "nowrap" }}>
<button
type="button"
onClick={() => handleSave(row.job_type)}
disabled={saving || !token}
style={
saving ? { ...saveBtn, opacity: 0.6 } : saveBtn
}
>
{saving ? "…" : "Сохранить"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</>
);
}
// ── Styles ──────────────────────────────────────────────────────────────────
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const labelStyle = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box" as const,
};
const compactInput = {
padding: "6px 8px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
boxSizing: "border-box" as const,
};
const th = {
padding: "8px 10px",
textAlign: "left" as const,
fontWeight: 600 as const,
borderBottom: "1px solid #e6e8ec",
whiteSpace: "nowrap" as const,
};
const td = { padding: "8px 10px" };
const saveBtn = {
padding: "6px 14px",
background: "#1d4ed8",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 600 as const,
cursor: "pointer",
};
const toggleBtn = {
padding: "3px 8px",
background: "#f3f4f6",
color: "#374151",
border: "1px solid #d1d5db",
borderRadius: 4,
fontSize: 11,
cursor: "pointer",
whiteSpace: "nowrap" as const,
};