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:
parent
2fee7739e8
commit
7e1540f84b
3 changed files with 3548 additions and 0 deletions
612
frontend/src/app/admin/jobs/settings/page.tsx
Normal file
612
frontend/src/app/admin/jobs/settings/page.tsx
Normal 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,
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
2935
frontend/src/lib/api-types.ts
Normal file
2935
frontend/src/lib/api-types.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue