feat(admin): unify job settings + bulk geo trigger under /admin/scrape/all

Move JobSettings UI from /admin/jobs/settings into JobSettingsPanel component,
render it alongside new BulkGeoPanel on /admin/scrape/all. BulkGeoPanel has a
parallelism input (1-10, default 5) and POSTs /admin/scrape/geo/bulk. Remove
the standalone /admin/jobs/ route and the nav tab pointing to it.
This commit is contained in:
lekss361 2026-05-11 16:02:52 +03:00
parent fe2a881cec
commit 8724a4731b
4 changed files with 211 additions and 67 deletions

View file

@ -9,7 +9,6 @@ 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({

View file

@ -4,6 +4,8 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel";
import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel";
interface UnifiedRunRow {
scraper_type: "kn" | "nspd" | "objective" | "nspd_geo";
@ -351,6 +353,16 @@ export default function ScrapeAllAdminPage() {
</div>
</section>
{/* Job Settings */}
<div style={{ marginTop: 16 }}>
<JobSettingsPanel token={token} />
</div>
{/* Bulk geo backfill */}
<div style={{ marginTop: 16 }}>
<BulkGeoPanel token={token} />
</div>
{/* Logs */}
<section style={{ ...cardStyle, marginTop: 16 }}>
<h3 style={sectionTitle}>

View file

@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
interface BulkGeoResponse {
job_ids: number[];
targets_total: number;
parallelism: number;
targets_per_job: number;
}
interface BulkGeoError {
detail: string;
}
export function BulkGeoPanel({ token }: { token: string }) {
const [parallelism, setParallelism] = useState(5);
const [result, setResult] = useState<BulkGeoResponse | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const bulkMutation = useMutation({
mutationFn: () =>
apiFetch<BulkGeoResponse>("/api/v1/admin/scrape/geo/bulk", {
method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({ parallelism, thematic_id: 2 }),
}),
onSuccess: (data) => {
setResult(data);
setErrorMsg(null);
},
onError: (err: unknown) => {
setResult(null);
const msg = err instanceof Error ? err.message : "Неизвестная ошибка";
setErrorMsg(msg);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
bulkMutation.mutate();
};
return (
<section style={cardStyle}>
<h3 style={sectionTitle}>Bulk Свердловская geo backfill</h3>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 0 }}>
Запускает пакетное geo-обогащение участков без координат (thematic_id=2,
регион 66). Разбивает на N параллельных job-ов через{" "}
<code>POST /api/v1/admin/scrape/geo/bulk</code>.
</p>
<form
onSubmit={handleSubmit}
style={{
display: "flex",
gap: 12,
alignItems: "flex-end",
flexWrap: "wrap",
}}
>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={labelStyle}>Parallelism (110)</span>
<input
type="number"
value={parallelism}
min={1}
max={10}
onChange={(e) => setParallelism(Number(e.target.value))}
style={{ ...numInput }}
/>
</label>
<button
type="submit"
disabled={!token || bulkMutation.isPending}
style={
!token || bulkMutation.isPending
? { ...triggerBtn, opacity: 0.6 }
: triggerBtn
}
>
{bulkMutation.isPending ? "Запуск…" : "Запустить bulk-job"}
</button>
</form>
{!token && (
<p style={{ color: "#a16207", fontSize: 13, marginTop: 12 }}>
Введите Admin Token выше для запуска.
</p>
)}
{errorMsg && (
<div style={errorBox}>
<strong>Ошибка:</strong> {errorMsg}
</div>
)}
{result && (
<div style={successBox}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
Создано job-ов: {result.job_ids.length}
</div>
<div style={{ fontSize: 13, color: "#374151", marginBottom: 6 }}>
Targets: <strong>{result.targets_total}</strong> участков ÷{" "}
{result.parallelism} воркеров ={" "}
<strong>~{result.targets_per_job}</strong> на job
</div>
<div
style={{
fontFamily: "ui-monospace,SFMono-Regular,monospace",
fontSize: 12,
color: "#5b6066",
}}
>
job_ids: [{result.job_ids.join(", ")}]
</div>
</div>
)}
</section>
);
}
// ── Styles ──────────────────────────────────────────────────────────────────
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const sectionTitle = {
margin: "0 0 8px",
fontSize: 16,
fontWeight: 600 as const,
};
const labelStyle = {
fontSize: 12,
color: "#5b6066",
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const numInput = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: 80,
boxSizing: "border-box" as const,
};
const triggerBtn = {
padding: "8px 18px",
background: "#7c3aed",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 600 as const,
cursor: "pointer",
};
const errorBox = {
marginTop: 16,
padding: "12px 16px",
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 8,
color: "#991b1b",
fontSize: 13,
};
const successBox = {
marginTop: 16,
padding: "12px 16px",
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 8,
color: "#065f46",
};

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
@ -42,7 +42,6 @@ interface RowDraft {
extra_config_error: string | null;
}
// Toast state type
interface Toast {
id: number;
message: string;
@ -65,27 +64,13 @@ 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
export function JobSettingsPanel({ token }: { token: string }) {
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 }]);
@ -106,7 +91,7 @@ export default function JobSettingsPage() {
});
// Initialise drafts when settings data first arrives (or new job_types appear).
// Using useEffect for syncing external data → local draft state, per codebase pattern.
// Using useEffect for syncing external data -> local draft state.
useEffect(() => {
if (!settings.data) return;
setDrafts((prev: Record<string, RowDraft>) => {
@ -165,7 +150,6 @@ export default function JobSettingsPage() {
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 {
@ -248,37 +232,12 @@ export default function JobSettingsPage() {
))}
</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 }}>
<h3 style={sectionTitle}>Настройки jobs</h3>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 0 }}>
Централизованные настройки Celery jobs. Изменения применяются с
следующего beat-цикла.
</p>
{!token ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token выше чтобы загрузить настройки.
@ -373,7 +332,7 @@ export default function JobSettingsPage() {
/>
</td>
{/* queue_name — read-only (infra config) */}
{/* queue_name — read-only */}
<td style={{ ...td, color: "#6b7280" }}>
<code style={{ fontSize: 12 }}>{row.queue_name}</code>
</td>
@ -553,22 +512,10 @@ const cardStyle = {
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 sectionTitle = {
margin: "0 0 8px",
fontSize: 16,
fontWeight: 600 as const,
};
const compactInput = {