From 933a4eaf576bb461f6d3f9fce69f47f2c15323c2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 21:39:04 +0700 Subject: [PATCH] =?UTF-8?q?fix(scheduler):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B7=D0=B4=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D1=81=D0=BC=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/data-collection/page.tsx | 73 ++++++++++++-------------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +++- docs/ux-ui-stage-11b.md | 9 ++++ tests/e2e/data-collection-race.spec.ts | 26 +++++++++ 5 files changed, 79 insertions(+), 41 deletions(-) create mode 100644 tests/e2e/data-collection-race.spec.ts diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index 4f0307bd..f1b5a252 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -1,9 +1,8 @@ "use client" import Link from "next/link" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" -import { FormToggle } from "@/components/form-kit" import { Badge } from "@/components/reui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" @@ -554,6 +553,12 @@ function applySchedulerJobsToDrafts( } export default function DataCollectionPage() { + const { mode, backendUrl, prefsHydrated } = useDataSource() + if (!prefsHydrated) return

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

+ return +} + +function DataCollectionContent() { const { mode, backendUrl, prefsHydrated } = useDataSource() const isLive = prefsHydrated && mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) @@ -587,6 +592,13 @@ export default function DataCollectionPage() { const [collectorError, setCollectorError] = useState(null) const [openRunIds, setOpenRunIds] = useState>(() => new Set()) + const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 }) + useEffect(() => { + reader.current.active = true + const state = reader.current + return () => { state.active = false; state.revision++; state.controller?.abort() } + }, []) + const toggleRunOpen = useCallback((id: string, open: boolean) => { setOpenRunIds((prev) => { const next = new Set(prev) @@ -602,7 +614,12 @@ export default function DataCollectionPage() { }, [uptimeCollector?.scheduler?.jobs]) const loadCollectors = useCallback(async () => { - if (!isLive) return + if (!isLive || !reader.current.active) return + const revision = ++reader.current.revision + reader.current.controller?.abort() + const controller = new AbortController(); reader.current.controller = controller + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) + const current = () => reader.current.active && revision === reader.current.revision && !controller.signal.aborted setCollectorError(null) setPageBusy(true) try { @@ -611,14 +628,15 @@ export default function DataCollectionPage() { ? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}` : "?limit=80" const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([ - apiFetch("/api/traffic/settings"), - apiFetch("/api/servers-api-ping/settings"), - apiFetch("/api/uptime/settings"), - apiFetch("/api/internet-path/settings"), - apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"), - apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`), + apiFetch("/api/traffic/settings", { signal }), + apiFetch("/api/servers-api-ping/settings", { signal }), + apiFetch("/api/uptime/settings", { signal }), + apiFetch("/api/internet-path/settings", { signal }), + apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings", { signal }), + apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`, { signal }), ]) + if (!current()) return const loadErrors = collectSettledErrors( [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes], ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"], @@ -684,9 +702,9 @@ export default function DataCollectionPage() { setSchedulerRuns(runsPayload.runs ?? []) } } catch (e) { - setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") + if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") } finally { - setPageBusy(false) + if (current()) setPageBusy(false) } }, [apiFetch, isLive, runFilterJobKey]) @@ -823,21 +841,10 @@ export default function DataCollectionPage() { }, [loadCollectors, persistSchedulerDrafts]) useEffect(() => { - if (!isLive) { - setTrafficCollector(null) - setServersApiCollector(null) - setUptimeCollector(null) - setInternetPathCollector(null) - setSchedulerRuns([]) - return - } - void loadCollectors() + if (!isLive) return + queueMicrotask(() => { void loadCollectors() }) }, [isLive, loadCollectors]) - useEffect(() => { - setOpenRunIds(new Set()) - }, [runFilterJobKey]) - const handleRefreshScheduler = async () => { if (!isLive) return setCollectorError(null) @@ -1005,8 +1012,7 @@ export default function DataCollectionPage() { const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns]) - const stats = useMemo( - () => [ + const stats = [ { label: "Включено задач", value: `${enabledJobsCount} / ${schedulerJobCount}`, @@ -1039,18 +1045,7 @@ export default function DataCollectionPage() { sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет", icon: , }, - ], - [ - enabledJobsCount, - schedulerJobCount, - runningJobsCount, - trafficCollector?.lastCollectedAt, - trafficCollector?.lastDurationMs, - trafficCollector?.lastError, - schedulerRuns.length, - errorRunsInView, - ], - ) + ] return (
@@ -1275,7 +1270,7 @@ export default function DataCollectionPage() { id="run-filter" className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]" value={runFilterJobKey} - onChange={(e) => setRunFilterJobKey(e.target.value)} + onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()) }} > {SCHEDULER_JOB_KEYS.map((k) => ( diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index c447c583..e8247c0a 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,7 +226,7 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Актуальная точка продолжения: 11Б.3.3.1 — контекст и исходные lint ошибки страницы сбора; затем strict readers/dirty states. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал. +Актуальная точка продолжения: 11Б.3.3.2 — strict scheduler runs и known/error/time журнала. Контекст/abort/revision и исходный lint исправлены в 11Б.3.3.1; далее независимые readers/dirty states и операции. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал. | Экран | ПК | Телефон | | --- | --- | --- | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 58658f0e..a2e2ad0e 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.2; runner/JSONB/scheduler API/GeoIP проверены; далее контекст и состояния сбора | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.1; runner/API/JSONB/GeoIP/гонки журнала проверены; далее состояния и формы сбора | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.3.1 — контекст и гонки чтения общего журнала (2026-10-01) + +- Страница ждёт hydration источника; mode/backend задают ключ нового содержимого. При размонтировании читатель становится inactive, старые запросы отменяются и их callbacks не обновляют UI/не запускают последующее чтение. +- Каждая загрузка отменяет предыдущую и имеет revision/30s deadline; все шесть GET используют общий AbortSignal. Поздний success/error после смены фильтра не заменяет актуальные строки/ошибки/busy. Promise.allSettled пока сохраняется: независимое появление быстрых зависимостей и dirty drafts ещё не реализованы. +- Сброс раскрытых строк перенесён в обработчик фильтра; эффектные synchronous setState и ненужный stats useMemo устранены. ESLint страницы и нового теста прошёл без прежних 2 errors/2 warnings. +- 2 новых filter-race + 6 GeoIP/snapshot E2E, production frontend/contracts build со встроенным TypeScript прошли. Отдельные E2E смены backend/mode и поздних мутаций ещё нужны в следующем блоке. Старые mutations не получили новой идемпотентности/lock; их приёмка не заявлена. +- **Следующий блок 11Б.3.3.2:** подключение strict scheduler runs и known/error/time журнала; затем независимые settings/status readers, dirty drafts, действия/права и source E2E. GeoIP/NetFlow формы и вся 11Б ещё не приняты. + ## Блок 11Б.3.2 — строгий GeoIP API против malformed crash (2026-10-01) - Существующие GeoIP schemas подключены к GET/PUT/update; неполный status не попадает в UI state. Ответ проверяет ready/loaded и время, PUT подтверждает отправленные поля, update сверяет ok с partial/fatal/errors и счётчиками. False/null/zero не заменяются defaults, partial download остаётся фактом. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 10e3a1d5..1087c368 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -63,3 +63,12 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S - 4 GeoIP API unit + 5 scheduler API unit, 6 E2E GeoIP/snapshot, профильный ESLint и production frontend/contracts build прошли. E2E fixture GeoIP приведена к полному существующему DTO. Первую проверку toast уточнили для двух dev StrictMode вызовов; повтор прошёл без pageerror. - UI GeoIP/NetFlow пока сохраняет прежние defaults/dirty/context/mutation ограничения и требует отдельной приёмки; strict reader исправляет конкретное падение, не доказывает реальные загрузки mmdb или готовность интеграции. - **Следующий блок 11Б.3.3.1:** изоляция контекста страницы сбора и исходные lint ошибки; затем подключение strict scheduler/settings readers, независимые состояния и черновики. + + +## Блок 11Б.3.3.1 — контекст и гонки чтения общего журнала (2026-10-01) + +- Страница ждёт hydration источника; mode/backend задают ключ нового содержимого. При размонтировании читатель становится inactive, старые запросы отменяются и их callbacks не обновляют UI/не запускают последующее чтение. +- Каждая загрузка отменяет предыдущую и имеет revision/30s deadline; все шесть GET используют общий AbortSignal. Поздний success/error после смены фильтра не заменяет актуальные строки/ошибки/busy. Promise.allSettled пока сохраняется: независимое появление быстрых зависимостей и dirty drafts ещё не реализованы. +- Сброс раскрытых строк перенесён в обработчик фильтра; эффектные synchronous setState и ненужный stats useMemo устранены. ESLint страницы и нового теста прошёл без прежних 2 errors/2 warnings. +- 2 новых filter-race + 6 GeoIP/snapshot E2E, production frontend/contracts build со встроенным TypeScript прошли. Отдельные E2E смены backend/mode и поздних мутаций ещё нужны в следующем блоке. Старые mutations не получили новой идемпотентности/lock; их приёмка не заявлена. +- **Следующий блок 11Б.3.3.2:** подключение strict scheduler runs и known/error/time журнала; затем независимые settings/status readers, dirty drafts, действия/права и source E2E. GeoIP/NetFlow формы и вся 11Б ещё не приняты. diff --git a/tests/e2e/data-collection-race.spec.ts b/tests/e2e/data-collection-race.spec.ts new file mode 100644 index 00000000..8f8c274c --- /dev/null +++ b/tests/e2e/data-collection-race.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test" +import { geoip } from "./fixtures/geoip" +const row = { id: "current-run", jobKey: "backups", startedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:00:00Z", status: "ok", error: null, durationMs: 100, resultJson: null } +for (const fails of [true, false]) test("Сбор данных: поздний журнал после смены фильтра/" + fails, async ({ page }) => { + let started = false, completed = false, release!: () => void; const gate = new Promise(resolve => release = resolve) + const errors: string[] = []; page.on("pageerror", error => errors.push(error.message)) + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", route => { + const path = new URL(route.request().url()).pathname + if (path === "/api/auth/config") return route.fulfill({ json: { required: false } }) + if (path === "/api/geoip") return route.fulfill({ json: geoip }) + if (path === "/api/traffic/flow/settings") return route.fulfill({ status: 503, json: { error: "NetFlow outside fixture" } }) + return route.fulfill({ json: { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } }) + }) + await page.route("**/api/scheduler/runs?**", async route => { + if (new URL(route.request().url()).searchParams.get("jobKey") === "backups") return route.fulfill({ json: { runs: [row] } }) + started = true; await gate + try { await route.fulfill(fails ? { status: 503, json: { error: "СТАРАЯ ОШИБКА ЖУРНАЛА" } } : { json: { runs: [{ ...row, id: "old-run", error: "СТАРЫЙ РЕЗУЛЬТАТ ЖУРНАЛА" }] } }) } catch {} finally { completed = true } + }) + await page.goto("/data-collection"); await expect.poll(() => started).toBe(true) + await page.getByLabel("Задача", { exact: true }).selectOption("backups") + await page.getByRole("button", { name: /Бэкапы.*100 мс/ }).click(); await expect(page.getByText("current-run", { exact: true })).toBeVisible() + release(); await expect.poll(() => completed).toBe(true) + await expect(page.getByText("current-run", { exact: true })).toBeVisible(); await expect(page.getByText(/СТАРАЯ ОШИБКА ЖУРНАЛА|СТАРЫЙ РЕЗУЛЬТАТ ЖУРНАЛА/)).toHaveCount(0) + expect(errors).toEqual([]) +})