fix(scheduler): исключить поздние ответы после смены фильтра и источника
This commit is contained in:
1 parent
1b51d2af01
commit
933a4eaf57
5 files changed
+79
-41
No files matched your search
@@ -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 <p role="status" className="p-6 text-sm text-muted-foreground">Загрузка настроек источника…</p>
|
||||
return <DataCollectionContent key={JSON.stringify([mode, backendUrl])} />
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => 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<CollectorSettingsDto>("/api/traffic/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
|
||||
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
||||
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings", { signal }),
|
||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings", { signal }),
|
||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings", { signal }),
|
||||
apiFetch<CollectorSettingsDto>("/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: <DatabaseIcon className="size-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
enabledJobsCount,
|
||||
schedulerJobCount,
|
||||
runningJobsCount,
|
||||
trafficCollector?.lastCollectedAt,
|
||||
trafficCollector?.lastDurationMs,
|
||||
trafficCollector?.lastError,
|
||||
schedulerRuns.length,
|
||||
errorRunsInView,
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -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()) }}
|
||||
>
|
||||
<option value="">Все</option>
|
||||
{SCHEDULER_JOB_KEYS.map((k) => (
|
||||
|
||||
+1
-1
@@ -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; внешние ограничения — журнал.
|
||||
|
||||
| Экран | ПК | Телефон |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -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 остаётся фактом.
|
||||
|
||||
@@ -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Б ещё не приняты.
|
||||
@@ -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<void>(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([])
|
||||
})
|
||||
Reference in new issue
Block a user