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:
parent
fe2a881cec
commit
8724a4731b
4 changed files with 211 additions and 67 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
186
frontend/src/components/admin/BulkGeoPanel.tsx
Normal file
186
frontend/src/components/admin/BulkGeoPanel.tsx
Normal 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 (1–10)</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",
|
||||
};
|
||||
|
|
@ -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 = {
|
||||
Loading…
Add table
Reference in a new issue