diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index ce5aad20..27b7e5b5 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -22,10 +22,11 @@ import { SCHEDULER_JOB_LABELS, type CollectorSettingsDto, type SchedulerJobStatusDto, + type SchedulerStatusDto, type SchedulerRunRowDto, type UptimeSettingsDto, } from "@/lib/scheduler-settings" -import { listSchedulerRuns } from "@/shared/api/scheduler" +import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler" import { requestJson, ApiClientError } from "@/shared/api/http-client" import { parseSchedulerRunSnapshot, @@ -586,6 +587,9 @@ function DataCollectionContent() { const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600") const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30") const [schedulerRuns, setSchedulerRuns] = useState([]) + const [schedulerStatus, setSchedulerStatus] = useState(null) + const [schedulerStatusError, setSchedulerStatusError] = useState(null) + const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState(null) const [runsKnown, setRunsKnown] = useState(false) const [runsError, setRunsError] = useState(null) const [runsReceivedAt, setRunsReceivedAt] = useState(null) @@ -613,9 +617,9 @@ function DataCollectionContent() { }, []) const schedulerJobsByKey = useMemo(() => { - const jobs = uptimeCollector?.scheduler?.jobs ?? [] + const jobs = schedulerStatus?.jobs ?? [] return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record - }, [uptimeCollector?.scheduler?.jobs]) + }, [schedulerStatus?.jobs]) const loadCollectors = useCallback(async () => { if (!isLive || !reader.current.active) return @@ -628,19 +632,27 @@ function DataCollectionContent() { setPageBusy(true) try { const runsFilter = runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number] : undefined - const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([ + const runsRequest = listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }).then(data => { + if (current()) { setSchedulerRuns(data.runs); setRunsKnown(true); setRunsError(null); setRunsReceivedAt(new Date().toISOString()) } + return data + }, error => { if (current()) setRunsError(extractApiError(error)); throw error }) + const statusRequest = getSchedulerStatus(backendUrl, { signal }).then(data => { + if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) } + return data + }, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error }) + const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes] = await Promise.allSettled([ 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 }), - listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }), + runsRequest, statusRequest, ]) if (!current()) return const loadErrors = collectSettledErrors( - [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes], - ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"], + [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes, statusRes], + ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика", "статус планировщика"], ) if (loadErrors.length > 0) { setCollectorError(loadErrors.join("; ")) @@ -698,13 +710,6 @@ function DataCollectionContent() { setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30)) } - const runsPayload = readSettled(runsRes) - if (runsPayload) { - setSchedulerRuns(runsPayload.runs) - setRunsKnown(true); setRunsError(null); setRunsReceivedAt(new Date().toISOString()) - } else if (runsRes.status === "rejected") { - setRunsError(extractApiError(runsRes.reason)) - } } catch (e) { if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") } finally { @@ -984,51 +989,23 @@ function DataCollectionContent() { uptimeSpeedIntervalDraft, ]) - const enabledJobsCount = useMemo(() => { - const jobs = uptimeCollector?.scheduler?.jobs - if (jobs?.length) return jobs.filter((job) => job.enabled).length - - let n = draftTrafficEnabled ? 1 : 0 - if (draftServersApiEnabled) n += 1 - if (draftResourcesEnabled) n += 1 - if (draftPingEnabled) n += 1 - if (draftSpeedEnabled) n += 1 - if (draftInternetPathEnabled) n += 1 - if (draftCertRenewEnabled) n += 1 - return n - }, [ - draftCertRenewEnabled, - draftInternetPathEnabled, - draftPingEnabled, - draftResourcesEnabled, - draftServersApiEnabled, - draftSpeedEnabled, - draftTrafficEnabled, - uptimeCollector?.scheduler?.jobs, - ]) - - const schedulerJobCount = SCHEDULER_JOB_KEYS.length - - const runningJobsCount = useMemo( - () => (uptimeCollector?.scheduler?.jobs ?? []).filter((j) => j.running).length, - [uptimeCollector?.scheduler?.jobs], - ) + const enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0 + const schedulerJobCount = schedulerStatus?.jobs.length ?? 0 + const runningJobsCount = schedulerStatus?.jobs.filter(job => job.running).length ?? 0 const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns]) const stats = [ { label: "Включено задач", - value: `${enabledJobsCount} / ${schedulerJobCount}`, - sub: uptimeCollector?.scheduler?.jobs?.length - ? "По сохранённым задачам планировщика" - : "По переключателям на этой странице", + value: schedulerStatus ? `${enabledJobsCount} / ${schedulerJobCount}` : "—", + sub: schedulerStatusError ? (schedulerStatus ? "Последний подтверждённый статус" : "Статус не получен") : schedulerStatus ? "По сохранённым задачам планировщика" : "Ожидание статуса", icon: , }, { label: "Сейчас выполняется", - value: String(runningJobsCount), - sub: "Фоновые прогоны планировщика", + value: schedulerStatus ? String(runningJobsCount) : "—", + sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика", icon: , }, { @@ -1099,6 +1076,14 @@ function DataCollectionContent() { )} + {isLive && schedulerStatusError ? ( + + + ) : null} + {isLive && schedulerStatusReceivedAt ?

Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}

: null} {isLive && collectorError && ( diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 86b32f5a..89b6e0e1 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.3 — независимые scheduler status/runs и достоверные KPI. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/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; внешние ограничения — журнал. +Актуальная точка продолжения: 11Б.3.3.4 — strict collector settings API и различия DTO. Независимые scheduler status/runs и достоверные KPI приняты в 11Б.3.3.3. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/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 256735a5..2c441d2f 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.3.2; strict history/unknown/свежесть проверены; далее независимые status/readers и формы | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.3; независимые status/history и достоверные KPI проверены; далее settings/dirty формы | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11Б.3.3.3 — независимые scheduler status/runs и достоверные KPI (2026-10-01) + +- Strict getSchedulerStatus подключён как отдельный источник. Status/runs публикуются сразу по своим ответам, до завершения пяти settings зависимостей; медленный/ошибочный uptime не задерживает подтверждённую сводку и историю. +- KPI enabled/running считает подтверждённые jobs, не переключатели/default drafts. Unknown показывает —; backend zero/false сохраняется. Фоновая ошибка оставляет последний статус и прежнее browser receivedAt с Alert обновления. Таблица получает metadata из отдельного status; это не атомарный снимок всех настроек. +- 13 общих data-collection E2E и 4 status E2E (повтор после уточнения fixture), ESLint страницы/теста и production frontend/contracts build со встроенным TypeScript прошли. Проверен быстрый status/history при задержанном uptime, initial API/malformed, background failure. +- Первый status fixture по ошибке включал все интеграции generic enabled=true, выявив старый malformed Evolution snapshot crash в sidebar. Fixture ограничена выключенной интеграцией; этот отдельный дефект Evolution reader включён в оставшийся scope 11Б, исправление не заявлено. +- Остальные settings всё ещё обрабатываются общим Promise.allSettled и могут сбрасывать drafts; strict settings readers/dirty state и операции впереди. GeoIP/NetFlow/общий UI ещё не принят, реальные DB/RouterOS/Portal остаются внешней приёмкой. +- **Следующий блок 11Б.3.3.4:** strict collector settings API с реальными различиями DTO; затем подключение независимых settings readers и dirty drafts, mutation/RBAC/source проверки. + ## Блок 11Б.3.3.2 — подтверждённая история и свежесть журнала (2026-10-01) - Strict listSchedulerRuns подключён к странице. Missing/duplicate/foreign/invalid DTO не становится пустой историей; первоначальная ошибка даёт unknown/KPI —, подтверждённое empty даёт 0. Ответные ошибки API теперь понятные, без сырого Zod JSON. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index f9e04127..9a999d51 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -81,3 +81,13 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S - Добавлены 5 history E2E: initial API/malformed, background error, known empty, смена среза. Общие 13 data-collection E2E, 5 scheduler API unit, ESLint страницы/API/теста и production frontend/contracts build прошли. Первые проверки KPI уточнены по реальной вложенности установленного KpiStatGrid; повтор прошёл. - Remaining: быстрый журнал пока ждёт остальных пяти зависимостей; status KPI ещё использует uptime/default drafts. Их независимость/known состояния — следующий блок. Forms/mutations/права/весь 11Б не приняты. - **Следующий блок 11Б.3.3.3:** независимое получение scheduler status/runs и достоверные status KPI; затем strict settings readers, dirty drafts и операции. + + +## Блок 11Б.3.3.3 — независимые scheduler status/runs и достоверные KPI (2026-10-01) + +- Strict getSchedulerStatus подключён как отдельный источник. Status/runs публикуются сразу по своим ответам, до завершения пяти settings зависимостей; медленный/ошибочный uptime не задерживает подтверждённую сводку и историю. +- KPI enabled/running считает подтверждённые jobs, не переключатели/default drafts. Unknown показывает —; backend zero/false сохраняется. Фоновая ошибка оставляет последний статус и прежнее browser receivedAt с Alert обновления. Таблица получает metadata из отдельного status; это не атомарный снимок всех настроек. +- 13 общих data-collection E2E и 4 status E2E (повтор после уточнения fixture), ESLint страницы/теста и production frontend/contracts build со встроенным TypeScript прошли. Проверен быстрый status/history при задержанном uptime, initial API/malformed, background failure. +- Первый status fixture по ошибке включал все интеграции generic enabled=true, выявив старый malformed Evolution snapshot crash в sidebar. Fixture ограничена выключенной интеграцией; этот отдельный дефект Evolution reader включён в оставшийся scope 11Б, исправление не заявлено. +- Остальные settings всё ещё обрабатываются общим Promise.allSettled и могут сбрасывать drafts; strict settings readers/dirty state и операции впереди. GeoIP/NetFlow/общий UI ещё не принят, реальные DB/RouterOS/Portal остаются внешней приёмкой. +- **Следующий блок 11Б.3.3.4:** strict collector settings API с реальными различиями DTO; затем подключение независимых settings readers и dirty drafts, mutation/RBAC/source проверки. diff --git a/tests/e2e/data-collection-status.spec.ts b/tests/e2e/data-collection-status.spec.ts new file mode 100644 index 00000000..d2f99b93 --- /dev/null +++ b/tests/e2e/data-collection-status.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "@playwright/test" +import { geoip } from "./fixtures/geoip" +const job = { jobKey: "backups", enabled: false, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null } +async function setup(page: import("@playwright/test").Page) { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (path === "/api/auth/config") return r.fulfill({ json: { required: false } }) + if (path === "/api/geoip") return r.fulfill({ json: geoip }) + if (path === "/api/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow outside fixture" } }) + if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } }) + return r.fulfill({ json: { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } }) + }) +} +for (const malformed of [true, false]) test("Статус: initial failure не подменяется drafts/" + malformed, async ({ page }) => { + await setup(page); await page.route("**/api/scheduler/status", r => r.fulfill(malformed ? { json: {} } : { status: 503, json: { error: "status unavailable" } })) + await page.goto("/data-collection"); await expect(page.getByText("Статус планировщика не получен", { exact: true })).toBeVisible() + for (const label of ["Включено задач", "Сейчас выполняется"]) await expect(page.getByText(label, { exact: true }).locator("../..")).toContainText("—") +}) +test("Статус и история появляются до медленной зависимости uptime", async ({ page }) => { + await setup(page); let started = false, release!: () => void; const gate = new Promise(resolve => release = resolve) + await page.route("**/api/uptime/settings", async r => { started = true; await gate; try { await r.fulfill({ status: 503, json: { error: "uptime unavailable" } }) } catch {} }) + await page.route("**/api/scheduler/status", r => r.fulfill({ json: { jobs: [job] } })) + try { await page.goto("/data-collection"); await expect.poll(() => started).toBe(true); await expect(page.getByText("Включено задач", { exact: true }).locator("../..")).toContainText("0 / 1"); await expect(page.getByText("Пока нет прогонов", { exact: true })).toBeVisible(); await expect(page.getByText(/^Статус получен:/)).toBeVisible() } finally { release() } +}) +test("Статус: background failure сохраняет последний статус и время", async ({ page }) => { + await setup(page); let fails = false + await page.route("**/api/scheduler/status", r => r.fulfill(fails ? { status: 503, json: { error: "status refresh unavailable" } } : { json: { jobs: [job] } })) + await page.goto("/data-collection"); await expect(page.getByText(/^Статус получен:/)).toBeVisible(); const received = await page.getByText(/^Статус получен:/).textContent(); fails = true + await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled(); await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByText("Статус планировщика не обновлён", { exact: true })).toBeVisible(); await expect(page.getByText("Включено задач", { exact: true }).locator("../..")).toContainText("0 / 1"); await expect(page.getByText(/^Статус получен:/)).toHaveText(received!) +})