From 20625f78f30644fe1fa5f0263675ffe54854d916 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 10 May 2026 20:16:26 +0300 Subject: [PATCH] fix ui --- .../src/app/admin/scrape/objective/page.tsx | 1099 +++++++++-------- 1 file changed, 608 insertions(+), 491 deletions(-) diff --git a/frontend/src/app/admin/scrape/objective/page.tsx b/frontend/src/app/admin/scrape/objective/page.tsx index e7b90a58..e9530148 100644 --- a/frontend/src/app/admin/scrape/objective/page.tsx +++ b/frontend/src/app/admin/scrape/objective/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; @@ -34,7 +34,7 @@ interface ObjectiveCoverage { path: string; exists: boolean; size_bytes?: number; - modified_at?: number; // epoch seconds + modified_at?: number; lots?: number; corp_room_month?: number; mappings?: number; @@ -76,8 +76,7 @@ function formatBytes(b: number | undefined): string { function formatEpoch(sec: number | undefined): string { if (!sec) return "—"; - const d = new Date(sec * 1000); - return d.toLocaleString("ru-RU"); + return new Date(sec * 1000).toLocaleString("ru-RU"); } function formatIso(s: string | null): string { @@ -100,12 +99,11 @@ export default function ObjectiveEtlAdminPage() { ); const [overrideSqlitePath, setOverrideSqlitePath] = useState(""); const [lastTaskId, setLastTaskId] = useState(null); - // Группы для нашего sync — csv. Пусто = backend возьмёт настройки из БД const [syncGroupsCsv, setSyncGroupsCsv] = useState(""); const [lastSyncTaskId, setLastSyncTaskId] = useState(null); - // Локальный state для формы настроек (заполняется из cfg при первом ответе) const [cfgDraft, setCfgDraft] = useState>({}); const [cfgSaved, setCfgSaved] = useState(false); + const [showEtl, setShowEtl] = useState(false); const queryClient = useQueryClient(); const saveToken = (v: string) => { @@ -134,26 +132,6 @@ export default function ObjectiveEtlAdminPage() { refetchInterval: 5000, }); - const trigger = useMutation({ - mutationFn: () => - apiFetch("/api/v1/admin/scrape/objective", { - method: "POST", - headers: { - "X-Admin-Token": token, - "Content-Type": "application/json", - }, - 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"] }); - }, - }); - - // Динамический config из БД const cfg = useQuery({ queryKey: ["objective-config", token], queryFn: () => @@ -164,10 +142,53 @@ export default function ObjectiveEtlAdminPage() { refetchOnWindowFocus: false, }); - // При первом успешном fetch'е — заполняем draft - if (cfg.data && Object.keys(cfgDraft).length === 0) { - setCfgDraft(cfg.data); - } + // Подтягиваем 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", + headers: { + "X-Admin-Token": token, + "Content-Type": "application/json", + }, + 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", + headers: { + "X-Admin-Token": token, + "Content-Type": "application/json", + }, + 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) => @@ -187,174 +208,355 @@ export default function ObjectiveEtlAdminPage() { }, }); - // Наш sync через api.objctv.ru (платный API → расширение покрытия УрФО) - 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", - headers: { - "X-Admin-Token": token, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - groups: groups.length ? groups : null, - }), - }, - ); - }, - onSuccess: (data) => { - setLastSyncTaskId(data.task_id); - void queryClient.invalidateQueries({ queryKey: ["objective-runs"] }); - }, - }); - - // Подсказка про свежесть данных - const sqliteFresher = (() => { - const cov = coverage.data; - if (!cov?.sqlite.modified_at || !cov?.pg.last_run_at) return null; - const sqliteMs = cov.sqlite.modified_at * 1000; - const pgMs = new Date(cov.pg.last_run_at).getTime(); - return sqliteMs > pgMs; - })(); - return ( -
-
-

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

- - ← Назад к kn-scraper - -
- -

+ <> +

+ 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/) — отдельный микросервис со своим - scoring-слоем, используется только для bootstrap-импорта (не нужен для - регулярной работы). + lots_history. SQLite Антона (/sf/) — отдельный микросервис, остался + только как bootstrap-fallback (свёрнут ниже).

{/* Token */} -
-
+ - {/* Coverage — текущее состояние нашей PG */} -
-

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

+ {/* Coverage */} +
+

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

{coverage.isLoading && ( -

Загрузка…

+

Загрузка…

)} {coverage.data && ( -
-
-
objective_lots
-
- {coverage.data.pg.lots.toLocaleString("ru-RU")} -
-
-
-
corp×room×month
-
- {coverage.data.pg.corp_room_month.toLocaleString("ru-RU")} -
-
-
-
complex_mapping
-
- {coverage.data.pg.mappings.toLocaleString("ru-RU")} -
-
-
+
+ + + +
- last lot snapshot:{" "} - - {coverage.data.pg.last_lot_snapshot ?? "—"} - + last lot snapshot:{" "} + {coverage.data.pg.last_lot_snapshot ?? "—"}
- last successful sync:{" "} - - {formatIso(coverage.data.pg.last_run_at)} - + last successful sync:{" "} + {formatIso(coverage.data.pg.last_run_at)}
-
+
- SQLite Антона (legacy):{" "} + SQLite Антона (legacy):{" "} {coverage.data.sqlite.exists ? ( - + ✓ {formatBytes(coverage.data.sqlite.size_bytes)} ) : ( - ✗ не примонтирован + ✗ не примонтирован )}
- файл обновлён:{" "} - - {formatEpoch(coverage.data.sqlite.modified_at)} - + файл обновлён:{" "} + {formatEpoch(coverage.data.sqlite.modified_at)}
- lots в SQLite:{" "} - + lots в SQLite:{" "} + {coverage.data.sqlite.lots?.toLocaleString("ru-RU") ?? "—"} - +
)} -
+
- {/* PRIMARY: Наш sync с api.objctv.ru → PG (source of truth) */} -
-

- 🌐 Наш sync: api.objctv.ru → PostgreSQL -

-

- Основной production pipeline. Дёргает api.objctv.ru напрямую и пишет в - objective_lots / corpus_room_month / lots_history в нашей PG. - Покрытие: 4 группы УрФО (Свердл.обл целиком + Челябинск + Тюмень + - Пермь). Каждая группа = 2 отчёта (corp_sum + lots_pf), пауза 30с между - группами. ~3 минуты на группу. Тратит лимиты тарифа Объектива. + {/* ⚙️ Settings — основной блок (всегда раскрыт) */} +

+

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

+

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

-

- ⏰ Auto: расписание из настроек (см. блок «⚙️ Настройки» ниже, сейчас:{" "} - {cfg.data?.cron_schedule ?? "..."} - ). Кнопка ниже — ручной триггер для немедленного запуска. + {!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}. - Прогресс — в таблице прогонов ниже (один run per группа). -

- )} - {syncOur.error && ( -

- Ошибка: {(syncOur.error as Error).message} -

- )} -
- - {/* ⚙️ Настройки (динамические, из БД objective_sync_config) */} -
- - ⚙️ Настройки sync (расписание + параметры запроса) - -
- {!cfg.data &&

Загрузка…

} - {cfg.data && ( - <> -
-
- - - setCfgDraft({ - ...cfgDraft, - cron_schedule: e.target.value, - }) - } - placeholder="0 6 * * tue" - className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs" - /> -

- Сейчас активно:{" "} - {cfg.data.cron_schedule} -

-
- -
- - - setCfgDraft({ ...cfgDraft, groups_csv: e.target.value }) - } - placeholder="Свердловская область,Челябинск,Тюмень,Пермь" - className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs" - /> -
- -
- - -
- -
- - -
- -
- - - setCfgDraft({ - ...cfgDraft, - period_months_back: Number(e.target.value), - }) - } - className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono" - /> -

- 1 = только последний полный месяц. 12 = последний год. -

-
- -
- - - setCfgDraft({ - ...cfgDraft, - inter_group_delay_s: Number(e.target.value), - }) - } - className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono" - /> -

- Пауза между группами (сек). Меньше → быстрее, но риск 429. -

-
- -
- - - setCfgDraft({ - ...cfgDraft, - rate_ms: Number(e.target.value), - }) - } - className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono" - /> -
- -
- - - setCfgDraft({ - ...cfgDraft, - retries: Number(e.target.value), - }) - } - className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono" - /> -
-
- -
- - - {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})`} -

- )} - - )} -
-
- - {/* SECONDARY: Bootstrap ETL Антоновского SQLite → PG */} -
- - 🛠 Bootstrap ETL: SQLite Антона → PG{" "} - - (одноразовый импорт; в обычной работе не нужен — наш sync всё делает - напрямую) - - -
-

- Используется только когда Антоновский /sf/ имеет данные, которых ещё - нет в нашей PG (например при первом запуске). После того как наш - sync отработал хотя бы 1 раз — этот ETL не нужен.{" "} - Это legacy-инструмент. -

- - {sqliteFresher === true && ( -
- ⚠️ SQLite Антона обновлён ПОСЛЕ последнего ETL — но это уже - неважно если наш sync настроен на cron. Используй кнопку только - если хочешь срочно подхватить Антоновские данные раньше нашего - следующего sync (вторник 06:00). -
- )} - -
- - setOverrideSqlitePath(e.target.value)} - placeholder="/data/anton-sqlite/analysis.db" - className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs" - /> -
- {lastTaskId && ( -

- Запущена task {lastTaskId}. -

+ {lastSyncTaskId && ( + + task {lastSyncTaskId} в очереди + )} - {trigger.error && ( -

- Ошибка: {(trigger.error as Error).message} -

+ {syncOur.error && ( + + {(syncOur.error as Error).message} + )}
-
+ - {/* Runs */} -
-
+ {/* 🛠 Bootstrap ETL — collapsible */} +
+ + {showEtl && ( +
+

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

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

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

-
- - + +
+
+ - - - - - - - - - + + + + + + + + + + @@ -692,42 +694,81 @@ export default function ObjectiveEtlAdminPage() { r.status === "running" && heartbeatLag !== null && heartbeatLag > 60; + const statusColor = + r.status === "done" + ? "#16a34a" + : r.status === "running" && !isStale + ? "#1d4ed8" + : r.status === "failed" || isStale + ? "#b3261e" + : "#6b7280"; return ( - - - - + + + + - - - - - @@ -748,7 +794,78 @@ export default function ObjectiveEtlAdminPage() {
run_idstarteddurationlotscorp×roommappingsstatustriggererrorrun_idstartedgroupdurationlots + corp×room + mappingsstatustriggererror
{r.run_id}{formatIso(r.started_at)} +
+ {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 ?? ""} @@ -738,8 +779,13 @@ export default function ObjectiveEtlAdminPage() { {runs.data?.length === 0 && (
Прогонов ещё не было
+ + + ); +} + +// ── Helper component ──────────────────────────────────────────────────────── + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
+ {label} +
+
+ {value.toLocaleString("ru-RU")}
); } + +// ── Styles ────────────────────────────────────────────────────────────────── + +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 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", +}; +const th = { + padding: "8px 10px", + textAlign: "left" as const, + fontWeight: 600 as const, + borderBottom: "1px solid #e6e8ec", +}; +const td = { padding: "8px 10px" };