"use client"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; interface UnifiedRunRow { scraper_type: "kn" | "nspd" | "objective" | "nspd_geo"; run_id: number; started_at: string | null; finished_at: string | null; heartbeat_at: string | null; status: string; error: string | null; triggered_by: string | null; scope: string | null; requests_count: number | null; items_ok: number | null; items_failed: number | null; sub_items_ok: number | null; progress_idx: number | null; progress_total: number | null; extra: Record | null; } interface UnifiedLogRow { scraper_type: "kn" | "nspd" | "nspd_geo"; log_id: number; run_id: number | null; ts: string | null; level: "debug" | "info" | "warn" | "error"; stage: string | null; entity_id: string | null; message: string; } const SCRAPER_BADGE: Record = { kn: { bg: "#dbeafe", fg: "#1d4ed8", label: "DOM.РФ kn" }, nspd: { bg: "#fef3c7", fg: "#a16207", label: "NSPD" }, objective: { bg: "#d1fae5", fg: "#059669", label: "Objective" }, nspd_geo: { bg: "#ede9fe", fg: "#6d28d9", label: "🗺 NSPD geo" }, }; const STATUS_COLOR: Record = { done: "#16a34a", running: "#1d4ed8", failed: "#b3261e", zombie: "#9333ea", }; function formatIso(s: string | null): string { if (!s) return "—"; return new Date(s).toLocaleString("ru-RU"); } function diffSecs(a: string | null, b: string | null): string { if (!a || !b) return "—"; const dt = (new Date(b).getTime() - new Date(a).getTime()) / 1000; if (dt < 60) return `${dt.toFixed(0)}с`; if (dt < 3600) return `${(dt / 60).toFixed(1)} мин`; return `${(dt / 3600).toFixed(1)} ч`; } export default function ScrapeAllAdminPage() { const [token, setToken] = useState(() => typeof window === "undefined" ? "" : (localStorage.getItem("admin_token") ?? ""), ); const [filter, setFilter] = useState< "all" | "kn" | "nspd" | "objective" | "nspd_geo" >("all"); const [logsRunId, setLogsRunId] = useState(""); const saveToken = (v: string) => { setToken(v); localStorage.setItem("admin_token", v); }; const runs = useQuery({ queryKey: ["unified-runs", token, filter], queryFn: () => { const q = filter !== "all" ? `?scraper_type=${filter}&limit=30` : "?limit=30"; return apiFetch(`/api/v1/admin/scrape/all/runs${q}`, { headers: { "X-Admin-Token": token }, }); }, enabled: !!token, refetchInterval: 5000, }); const logs = useQuery({ queryKey: ["unified-logs", token, filter, logsRunId], queryFn: () => { const params = new URLSearchParams({ limit: "100" }); if (filter !== "all") params.set("scraper_type", filter); if (logsRunId !== "") params.set("run_id", String(logsRunId)); return apiFetch( `/api/v1/admin/scrape/all/logs?${params}`, { headers: { "X-Admin-Token": token } }, ); }, enabled: !!token, refetchInterval: 8000, }); // Сводка по statusам const summary = (() => { const s = { running: 0, done: 0, failed: 0, zombie: 0 } as Record< string, number >; runs.data?.forEach((r) => { s[r.status] = (s[r.status] ?? 0) + 1; }); return s; })(); return ( <>

Скраперы — сводный dashboard

Все скраперы в одной таблице (через v_scrape_runs_unified). Для запуска / триггеров — отдельные страницы:{" "} DOM.РФ kn {" "} ·{" "} 🗺 NSPD geo {" "} ·{" "} Objective . Старый Playwright-based NSPD-скрапер снят с эксплуатации 2026-05-11 — история его прогонов осталась под фильтром nspd только для аудита.

{/* Сводка-плитки */}
{/* Фильтр + табы */}
Filter: {(["all", "kn", "nspd", "nspd_geo", "objective"] as const).map( (t) => ( ), )}
{/* Таблица прогонов */}

📜 Последние прогоны (top 30, обновляется каждые 5с)

{runs.data?.map((r) => { const heartbeatLag = r.heartbeat_at ? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000 : null; const isStale = r.status === "running" && heartbeatLag !== null && heartbeatLag > 60; const badge = SCRAPER_BADGE[r.scraper_type]; const statusColor = STATUS_COLOR[r.status] ?? "#6b7280"; return ( ); })} {runs.data?.length === 0 && ( )}
scraper run_id started scope duration items_ok failed sub_ok status trigger
{badge && ( {badge.label} )} {r.run_id} {formatIso(r.started_at)} {r.scope ?? "—"} {diffSecs(r.started_at, r.finished_at ?? r.heartbeat_at)} {r.items_ok?.toLocaleString("ru-RU") ?? "—"} {r.items_failed?.toLocaleString("ru-RU") ?? "—"} {r.sub_items_ok?.toLocaleString("ru-RU") ?? "—"} {isStale ? "stale" : r.status} {r.triggered_by ?? "—"} {(r.scraper_type === "kn" || r.scraper_type === "nspd" || r.scraper_type === "nspd_geo") && ( )}
Прогонов с фильтром {filter} нет
{/* Logs */}

📋 Логи (top 100){" "} {logsRunId !== "" && ( · фильтр run_id={logsRunId}{" "} )}

{logs.data?.map((l) => ( ))} {logs.data?.length === 0 && ( )}
ts scraper run level stage entity message
{formatIso(l.ts)} {l.scraper_type} {l.run_id ?? "—"} {l.level} {l.stage ?? "—"} {l.entity_id ?? "—"} {l.message}
Логов нет (objective пока не пишет в log — только runs)
); } function Stat({ label, value, color, }: { label: string; value: number; color: string; }) { return (
{label}
{value}
); } const cardStyle = { background: "#fff", border: "1px solid #e6e8ec", borderRadius: 12, padding: 20, }; const sectionTitle = { margin: "0 0 12px", fontSize: 16, fontWeight: 600 as const, }; 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 filterTab = { padding: "6px 14px", border: "none", borderRadius: 6, fontSize: 13, fontWeight: 500 as const, cursor: "pointer", }; const secondaryBtn = { padding: "8px 14px", background: "#f3f4f6", color: "#1f2937", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 13, cursor: "pointer", }; const tableStyle = { width: "100%", borderCollapse: "collapse" as const, fontSize: 12, }; const th = { padding: "8px 10px", textAlign: "left" as const, fontWeight: 600 as const, borderBottom: "1px solid #e6e8ec", }; const td = { padding: "8px 10px" };