"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface PhaseState { phase?: string; parcels_meta?: number; parcels_fetched?: number; buildings_meta?: number; buildings_fetched?: number; retries?: number; } interface CadastreJob { job_id: number; name: string | null; job_kind: string; scope: string; status: | "queued" | "running" | "done" | "failed" | "zombie" | "cancelled" | "paused"; triggered_by: string | null; rate_ms: number; created_at: string | null; started_at: string | null; finished_at: string | null; heartbeat_at: string | null; targets_total: number; targets_done: number; targets_failed: number; targets_skipped: number; requests_count: number; waf_blocked_count: number; error: string | null; phase_state: PhaseState | null; progress_pct?: number; } interface CreateCadastreJobRequest { scope: "pilot" | "ekb_full" | "manual_list"; name?: string; quarters?: string[]; limit?: number; } interface CreateCadastreJobResponse { job_id: number; scope: string; targets_total: number; estimate_minutes: number; } type ScopeOption = "pilot" | "ekb_full" | "manual_list"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const STATUS_COLOR: Record = { queued: "#6b7280", running: "#1d4ed8", done: "#16a34a", failed: "#b3261e", zombie: "#dc2626", cancelled: "#9333ea", paused: "#a16207", }; const SCOPE_LABEL: Record = { pilot: "Pilot (50 кварталов ЕКБ)", ekb_full: "Full ЕКБ (2 408 кварталов)", manual_list: "Manual list", }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- 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)} ч`; } function calcProgress(job: CadastreJob): number { if (job.progress_pct !== undefined) return job.progress_pct; if (job.targets_total === 0) return 0; return (job.targets_done / job.targets_total) * 100; } function reqPerSec(job: CadastreJob): string { if (!job.started_at || job.requests_count === 0) return "—"; const endTs = job.heartbeat_at ?? job.finished_at; if (!endTs) return "—"; const elapsed = (new Date(endTs).getTime() - new Date(job.started_at).getTime()) / 1000; if (elapsed <= 0) return "—"; return (job.requests_count / elapsed).toFixed(2); } // --------------------------------------------------------------------------- // Query keys // --------------------------------------------------------------------------- const jobsKey = (token: string) => ["admin", "cadastre", "jobs", token]; const jobKey = (token: string, id: number) => [ "admin", "cadastre", "job", id, token, ]; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function CadastreScrapeAdminPage() { const [token, setToken] = useState(() => typeof window === "undefined" ? "" : (localStorage.getItem("admin_token") ?? ""), ); const [scope, setScope] = useState("pilot"); const [name, setName] = useState(""); const [quartersText, setQuartersText] = useState(""); const [limit, setLimit] = useState(""); const [lastResp, setLastResp] = useState( null, ); const [expandedId, setExpandedId] = useState(null); const queryClient = useQueryClient(); const saveToken = (v: string) => { setToken(v); if (typeof window !== "undefined") { localStorage.setItem("admin_token", v); } }; // ------------------------------------------------------------------------- // Queries // ------------------------------------------------------------------------- const jobs = useQuery({ queryKey: jobsKey(token), queryFn: () => apiFetch("/api/v1/admin/cadastre/jobs?limit=30", { headers: { "X-Admin-Token": token }, }), enabled: !!token, refetchInterval: 5000, }); // Per-job detail (only fetched when row is expanded) const expandedJob = useQuery({ queryKey: jobKey(token, expandedId ?? 0), queryFn: () => apiFetch(`/api/v1/admin/cadastre/jobs/${expandedId}`, { headers: { "X-Admin-Token": token }, }), enabled: !!token && expandedId !== null, refetchInterval: expandedId !== null ? 5000 : false, }); // ------------------------------------------------------------------------- // Mutations // ------------------------------------------------------------------------- const createJob = useMutation({ mutationFn: () => { const body: CreateCadastreJobRequest = { scope }; if (name.trim()) body.name = name.trim(); if (scope === "manual_list") { body.quarters = quartersText .split(/[\n,]+/) .map((s) => s.trim()) .filter(Boolean); } const limitNum = parseInt(limit, 10); if (!isNaN(limitNum) && limitNum > 0) body.limit = limitNum; return apiFetch( "/api/v1/admin/cadastre/jobs", { method: "POST", headers: { "X-Admin-Token": token, "Content-Type": "application/json", }, body: JSON.stringify(body), }, ); }, onSuccess: (data) => { setLastResp(data); void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); }, }); const cancelJob = useMutation({ mutationFn: (id: number) => apiFetch<{ cancelled: boolean }>( `/api/v1/admin/cadastre/jobs/${id}/cancel`, { method: "POST", headers: { "X-Admin-Token": token } }, ), onSuccess: (_data, id) => { void queryClient.invalidateQueries({ queryKey: jobKey(token, id) }); void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); }, }); const resumeJob = useMutation({ mutationFn: (id: number) => apiFetch<{ resumed: boolean }>( `/api/v1/admin/cadastre/jobs/${id}/resume`, { method: "POST", headers: { "X-Admin-Token": token } }, ), onSuccess: (_data, id) => { void queryClient.invalidateQueries({ queryKey: jobKey(token, id) }); void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); }, }); // ------------------------------------------------------------------------- // Handlers // ------------------------------------------------------------------------- const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!token) return; if (scope === "manual_list" && !quartersText.trim()) { alert("Для Manual list укажите хотя бы один квартал"); return; } createJob.mutate(); }; const toggleExpand = (id: number) => { setExpandedId((prev) => (prev === id ? null : id)); }; // ------------------------------------------------------------------------- // Render helpers // ------------------------------------------------------------------------- const renderProgressBar = (job: CadastreJob) => { const pct = calcProgress(job); const color = STATUS_COLOR[job.status] ?? "#6b7280"; return (
{pct.toFixed(1)}%
); }; const renderHeartbeat = (job: CadastreJob) => { if (!job.heartbeat_at) return ; const lagMs = Date.now() - new Date(job.heartbeat_at).getTime(); const lagMin = lagMs / 60000; const stale = job.status === "running" && lagMin > 10; const lagStr = lagMs < 60000 ? `${Math.round(lagMs / 1000)} с` : `${lagMin.toFixed(1)} мин`; return ( {stale ? `STALE (${lagStr})` : lagStr} ); }; const renderStatusBadge = (job: CadastreJob) => { const color = STATUS_COLOR[job.status] ?? "#6b7280"; const heartbeatLag = job.heartbeat_at ? (Date.now() - new Date(job.heartbeat_at).getTime()) / 60000 : null; const isZombie = job.status === "running" && heartbeatLag !== null && heartbeatLag > 10; return ( {isZombie ? "zombie" : job.status} ); }; const renderActions = (job: CadastreJob) => (
{(job.status === "running" || job.status === "queued") && ( )} {(job.status === "failed" || job.status === "cancelled" || job.status === "paused") && job.phase_state?.phase !== "done" && ( )}
); const renderExpandedPanel = (job: CadastreJob | undefined) => { if (!job) { return ( Загрузка деталей... ); } return (
{/* Phase state */}
PHASE STATE
{job.phase_state ? (
                  {JSON.stringify(job.phase_state, null, 2)}
                
) : ( )}
{/* Stats */}
СТАТИСТИКА
Запросов {job.requests_count.toLocaleString("ru-RU")}
Req/s {reqPerSec(job)}
WAF blocked 5 ? "#dc2626" : "inherit", fontWeight: job.waf_blocked_count > 5 ? 700 : 400, }} > {job.waf_blocked_count} {job.waf_blocked_count > 5 && " ⚠ HIGH"}
Skipped {job.targets_skipped}
Rate ms {job.rate_ms}
Started {formatIso(job.started_at)}
Finished {formatIso(job.finished_at)}
Triggered by {job.triggered_by ?? "—"}
{/* Error */} {job.error && (
ERROR
                  {job.error}
                
)}
); }; // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- return ( <>

Cadastre bulk harvest — ручной запуск

Bulk-загрузка кадастровых данных (участки + здания) из NSPD. State хранится в БД (cadastre_jobs +{" "} cadastre_targets) — при redeploy job продолжается с незавершённых targets.

{/* Admin Token */}
{/* Create job form */}

Запуск bulk harvest

{/* Scope radio buttons */}
Scope
{(["pilot", "ekb_full", "manual_list"] as ScopeOption[]).map( (s) => ( ), )}
{/* Manual quarters textarea */} {scope === "manual_list" && (