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>
This commit is contained in:
lekss361 2026-05-11 15:47:39 +03:00
parent 2fee7739e8
commit 7e1540f84b
3 changed files with 3548 additions and 0 deletions

View file

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

View file

@ -9,6 +9,7 @@ const TABS = [
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
{ href: "/admin/scrape/objective", label: "ETL Objective" },
{ href: "/admin/leads", label: "Лиды PRINZIP" },
{ href: "/admin/jobs/settings", label: "⚙️ Job Settings" },
];
export default function AdminLayout({

File diff suppressed because it is too large Load diff