"use client"; import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import ScrapeLogsPanel from "@/components/admin/ScrapeLogsPanel"; import { apiFetch } from "@/lib/api"; import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; interface ObjectiveRunRow { run_id: number; started_at: string | null; finished_at: string | null; heartbeat_at: string | null; group_name: string; reports_ok: number; reports_failed: number; rows_corpus_room: number; rows_lots: number; rows_mappings: number; requests_count: number; status: string; error: string | null; triggered_by: string; } interface ObjectiveCoverage { pg: { lots: number; corp_room_month: number; mappings: number; last_lot_snapshot: string | null; last_run_at: string | null; }; sqlite: { path: string; exists: boolean; size_bytes?: number; modified_at?: number; lots?: number; corp_room_month?: number; mappings?: number; error?: string; }; } interface ObjectiveTriggerResp { task_id: string; sqlite_path: string; } interface ObjectiveSyncOurResp { task_id: string; groups_planned: string[]; estimate_minutes: number; } interface ObjectiveConfig { cron_schedule: string; groups_csv: string; groups: string[]; use_ddu: boolean; use_dkp: boolean; period_months_back: number; inter_group_delay_s: number; rate_ms: number; retries: number; updated_at: string | null; updated_by: string | null; _note?: string; } function formatBytes(b: number | undefined): string { if (!b) return "—"; const mb = b / 1024 / 1024; return mb >= 100 ? `${mb.toFixed(0)} MB` : `${mb.toFixed(1)} MB`; } function formatEpoch(sec: number | undefined): string { if (!sec) return "—"; return new Date(sec * 1000).toLocaleString("ru-RU"); } 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(1)}с`; return `${(dt / 60).toFixed(1)} мин`; } export default function ObjectiveEtlAdminPage() { const [overrideSqlitePath, setOverrideSqlitePath] = useState(""); const [lastTaskId, setLastTaskId] = useState(null); const [syncGroupsCsv, setSyncGroupsCsv] = useState(""); const [lastSyncTaskId, setLastSyncTaskId] = useState(null); const [cfgDraft, setCfgDraft] = useState>({}); const [cfgSaved, setCfgSaved] = useState(false); const [showEtl, setShowEtl] = useState(false); const queryClient = useQueryClient(); const coverage = useQuery({ queryKey: ["objective-coverage"], queryFn: () => apiFetch("/api/v1/admin/scrape/objective/coverage"), refetchInterval: 10000, }); const runs = useQuery({ queryKey: ["objective-runs"], queryFn: () => apiFetch( "/api/v1/admin/scrape/objective/runs?limit=20", ), refetchInterval: 5000, }); const cfg = useQuery({ queryKey: ["objective-config"], queryFn: () => apiFetch("/api/v1/admin/scrape/objective/config"), refetchOnWindowFocus: false, }); // Подтягиваем cfg в draft при первом появлении (не во время render!) useEffect(() => { if (cfg.data && Object.keys(cfgDraft).length === 0) { setCfgDraft(cfg.data); } }, [cfg.data, cfgDraft]); const trigger = useMutation({ mutationFn: () => apiFetch("/api/v1/admin/scrape/objective", { method: "POST", body: JSON.stringify({ sqlite_path: overrideSqlitePath || null }), }), onSuccess: (data) => { setLastTaskId(data.task_id); void queryClient.invalidateQueries({ queryKey: ["objective-runs"] }); void queryClient.invalidateQueries({ queryKey: ["objective-coverage"] }); }, }); const syncOur = useMutation({ mutationFn: () => { const groups = syncGroupsCsv .split(",") .map((g) => g.trim()) .filter(Boolean); return apiFetch( "/api/v1/admin/scrape/objective/sync-our", { method: "POST", body: JSON.stringify({ groups: groups.length ? groups : null }), }, ); }, onSuccess: (data) => { setLastSyncTaskId(data.task_id); void queryClient.invalidateQueries({ queryKey: ["objective-runs"] }); }, }); const saveCfg = useMutation({ mutationFn: (patch: Partial) => apiFetch("/api/v1/admin/scrape/objective/config", { method: "PUT", body: JSON.stringify(patch), }), onSuccess: (data) => { setCfgDraft(data); setCfgSaved(true); window.setTimeout(() => setCfgSaved(false), 3000); void queryClient.invalidateQueries({ queryKey: ["objective-config"] }); }, }); return ( <>

Objective: sync с api.objctv.ru → наша PG

Source of truth = эта PG. Наш Celery каждый вторник 06:00 МСК тянет 4 группы (Свердл.обл + Челябинск + Тюмень + Пермь) с api.objctv.ru напрямую в objective_lots / corpus_room_month / lots_history. SQLite Антона (/sf/) — отдельный микросервис, остался только как bootstrap-fallback (свёрнут ниже).

{/* Coverage */}

📦 Состояние PG (источник истины)

{coverage.isLoading && (

Загрузка…

)} {coverage.data && (
last lot snapshot:{" "} {coverage.data.pg.last_lot_snapshot ?? "—"}
last successful sync:{" "} {formatIso(coverage.data.pg.last_run_at)}
SQLite Антона (legacy):{" "} {coverage.data.sqlite.exists ? ( ✓ {formatBytes(coverage.data.sqlite.size_bytes)} ) : ( ✗ не примонтирован )}
файл обновлён:{" "} {formatEpoch(coverage.data.sqlite.modified_at)}
lots в SQLite:{" "} {coverage.data.sqlite.lots?.toLocaleString("ru-RU") ?? "—"}
)}
{/* ⚙️ Settings — основной блок (всегда раскрыт) */}

⚙️ Настройки sync — расписание + параметры запроса

Хранятся в БД objective_sync_config. Все поля кроме cron подхватываются на следующем sync-вызове автоматически. Cron требует{" "} docker compose restart beat после изменения.

{!cfg.data &&

Загрузка настроек…

} {cfg.data && (
{cfgSaved && ( ✓ Сохранено. Параметры (кроме cron) применятся со следующим sync. )} {saveCfg.error && ( Ошибка: {(saveCfg.error as Error).message} )}
{cfg.data.updated_at && (

Последнее изменение: {formatIso(cfg.data.updated_at)} {cfg.data.updated_by && ` (by ${cfg.data.updated_by})`}

)}
)}
{/* 🌐 Запустить наш sync */}

🌐 Запустить наш sync вручную

Дёргает api.objctv.ru с параметрами из настроек выше. Ручной триггер для немедленного запуска (вне beat-расписания). ~3 минуты на группу × N групп.

{lastSyncTaskId && ( task {lastSyncTaskId} в очереди )} {syncOur.error && ( {(syncOur.error as Error).message} )}
{/* 🛠 Bootstrap ETL — collapsible */}
{showEtl && (

Используется только для одноразового импорта Антоновского SQLite в нашу PG (например при cold-start). После того как наш sync отработал — этот ETL не нужен.

{lastTaskId && ( task {lastTaskId} )} {trigger.error && ( {(trigger.error as Error).message} )}
)}
{/* 📜 Runs */}

📜 Последние прогоны (top 20, обновляется каждые 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 > 300; const statusColor = r.status === "done" ? "#16a34a" : r.status === "running" && !isStale ? "#1d4ed8" : r.status === "failed" || isStale ? "#b3261e" : "#6b7280"; return ( ); })} {runs.data?.length === 0 && ( )}
run_id started group duration lots corp×room mappings status trigger error
{r.run_id} {formatIso(r.started_at)} {r.group_name} {diffSecs(r.started_at, r.finished_at ?? r.heartbeat_at)} {r.rows_lots?.toLocaleString("ru-RU") ?? "—"} {r.rows_corpus_room?.toLocaleString("ru-RU") ?? "—"} {r.rows_mappings?.toLocaleString("ru-RU") ?? "—"} {isStale ? "stale" : r.status} {r.triggered_by} {r.error ?? ""}
Прогонов ещё не было
); } // ── Helper component ──────────────────────────────────────────────────────── function Stat({ label, value }: { label: string; value: number }) { return (
{label}
{value.toLocaleString("ru-RU")}
); } // ── Styles ────────────────────────────────────────────────────────────────── const sectionTitle = { margin: "0 0 12px", fontSize: 16, fontWeight: 600 as const, }; const primaryBtn = { padding: "10px 18px", background: "#1d4ed8", color: "#fff", border: "none", borderRadius: 6, fontSize: 14, fontWeight: 600, cursor: "pointer", }; const secondaryBtn = { padding: "8px 14px", background: "#f3f4f6", color: "#1f2937", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 13, cursor: "pointer", };