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/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
|
||||||
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
||||||
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
||||||
{ href: "/admin/jobs/settings", label: "⚙️ Job Settings" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel";
|
||||||
|
import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel";
|
||||||
|
|
||||||
interface UnifiedRunRow {
|
interface UnifiedRunRow {
|
||||||
scraper_type: "kn" | "nspd" | "objective" | "nspd_geo";
|
scraper_type: "kn" | "nspd" | "objective" | "nspd_geo";
|
||||||
|
|
@ -351,6 +353,16 @@ export default function ScrapeAllAdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Job Settings */}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<JobSettingsPanel token={token} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk geo backfill */}
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<BulkGeoPanel token={token} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Logs */}
|
{/* Logs */}
|
||||||
<section style={{ ...cardStyle, marginTop: 16 }}>
|
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||||
<h3 style={sectionTitle}>
|
<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";
|
"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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
@ -42,7 +42,6 @@ interface RowDraft {
|
||||||
extra_config_error: string | null;
|
extra_config_error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toast state type
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -65,27 +64,13 @@ function formatIso(s: string): string {
|
||||||
return new Date(s).toLocaleString("ru-RU");
|
return new Date(s).toLocaleString("ru-RU");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JobSettingsPage() {
|
export function JobSettingsPanel({ token }: { token: string }) {
|
||||||
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>>({});
|
const [drafts, setDrafts] = useState<Record<string, RowDraft>>({});
|
||||||
// Which rows have the extra_config textarea expanded
|
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
const toastSeqRef = useRef(0);
|
const toastSeqRef = useRef(0);
|
||||||
const queryClient = useQueryClient();
|
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 addToast = useCallback((message: string, kind: "success" | "error") => {
|
||||||
const id = ++toastSeqRef.current;
|
const id = ++toastSeqRef.current;
|
||||||
setToasts((prev: Toast[]) => [...prev, { id, message, kind }]);
|
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).
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!settings.data) return;
|
if (!settings.data) return;
|
||||||
setDrafts((prev: Record<string, RowDraft>) => {
|
setDrafts((prev: Record<string, RowDraft>) => {
|
||||||
|
|
@ -165,7 +150,6 @@ export default function JobSettingsPage() {
|
||||||
const draft = drafts[job_type];
|
const draft = drafts[job_type];
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
// Parse extra_config JSON only when the textarea is expanded
|
|
||||||
let extra_config: Record<string, unknown> | undefined;
|
let extra_config: Record<string, unknown> | undefined;
|
||||||
if (expanded[job_type]) {
|
if (expanded[job_type]) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -248,37 +232,12 @@ export default function JobSettingsPage() {
|
||||||
))}
|
))}
|
||||||
</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}>
|
<section style={cardStyle}>
|
||||||
<label style={{ display: "block" }}>
|
<h3 style={sectionTitle}>Настройки jobs</h3>
|
||||||
<span style={labelStyle}>Admin Token (X-Admin-Token)</span>
|
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 0 }}>
|
||||||
<input
|
Централизованные настройки Celery jobs. Изменения применяются с
|
||||||
type="password"
|
следующего beat-цикла.
|
||||||
value={token}
|
</p>
|
||||||
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 ? (
|
{!token ? (
|
||||||
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
||||||
Введите Admin Token выше чтобы загрузить настройки.
|
Введите Admin Token выше чтобы загрузить настройки.
|
||||||
|
|
@ -373,7 +332,7 @@ export default function JobSettingsPage() {
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* queue_name — read-only (infra config) */}
|
{/* queue_name — read-only */}
|
||||||
<td style={{ ...td, color: "#6b7280" }}>
|
<td style={{ ...td, color: "#6b7280" }}>
|
||||||
<code style={{ fontSize: 12 }}>{row.queue_name}</code>
|
<code style={{ fontSize: 12 }}>{row.queue_name}</code>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -553,22 +512,10 @@ const cardStyle = {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
const labelStyle = {
|
const sectionTitle = {
|
||||||
display: "block",
|
margin: "0 0 8px",
|
||||||
fontSize: 12,
|
fontSize: 16,
|
||||||
color: "#5b6066",
|
fontWeight: 600 as const,
|
||||||
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 = {
|
const compactInput = {
|
||||||
Loading…
Add table
Reference in a new issue