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>
612 lines
20 KiB
TypeScript
612 lines
20 KiB
TypeScript
"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,
|
||
};
|