diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index 53a05e55..4f0307bd 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -432,7 +432,11 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) { function RunRowDetail({ r }: { r: SchedulerRunRowDto }) { const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—" - const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson]) + const snapshot = useMemo(() => { + const parsed = parseSchedulerRunSnapshot(r.resultJson) + return parsed?.job === r.jobKey ? parsed : null + }, [r.resultJson, r.jobKey]) + const hasSnapshot = r.resultJson != null && (typeof r.resultJson !== "string" || r.resultJson.trim() !== "") return (
@@ -501,6 +505,12 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {

Результаты измерений

{snapshot ? ( + ) : hasSnapshot ? ( + + + Снимок недоступен + Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче. + ) : (

Снимок отсутствует (запись до обновления бекенда или задача без детализации). diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index aebdfe72..2d22b081 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Б.2 — совместимость JSONB/legacy snapshot общего журнала. Общий 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 — строгие API и состояния сбора данных. Совместимость 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 5b0a69b5..9dc81590 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; общий runner исправлен, далее JSONB журнал | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.2; runner/JSONB исправлены, далее строгие API и состояния сбора | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11Б.2 — JSONB и legacy snapshot общего журнала (2026-10-01) + +- resultJson читается как unknown: PostgreSQL JSONB объект и старый строковый JSON проходят общий decoder. Больше нет raw.trim на объекте. Проверены версия/известная задача/время/обязательные поля верхнего уровня; backup counts и новые diagnostics валидируются без подстановки нулей. +- Снимок другого job не отображается в выбранной записи. Некорректный/неподдерживаемый снимок показывает Alert «Снимок недоступен», отсутствие снимка остаётся отдельным состоянием. Полная валидация вложенных строк остальных коллекций ещё относится к строгому API следующего блока. +- 4 unit, 4 E2E object/legacy/invalid/foreign, TypeScript и production frontend/contracts build прошли. ESLint parser/DTO/tests прошёл. На общей странице остаются 2 исходные set-state-in-effect ошибки и 2 warnings: отдельный lint HEAD через stdin подтвердил ту же исходную базу; их исправление входит в reader/context блока 11Б.3. +- Первый E2E прогон использовал неполную GeoIP фикстуру и падал в старом GeoipSettingsSection. Фикстура уточнена; NetFlow зависимость вынесена в явный HTTP 503 вместо неверного success DTO. Повторные 4 E2E прошли без pageerror. Строгий GeoIP reader/guard также необходим в 11Б, реальная интеграция не принята. +- ReUI audit checklist получен; существующий Alert/вариант warning, текстовый рендер и структура сохранены. Весь раздел сбора данных ещё не прошёл визуальную/доступностную приёмку. +- **Следующий блок 11Б.3:** строгие scheduler/collector API, независимые reader states, dirty drafts и контекст/права; новый run-now receipt пока не обрабатывается страницей строго. + ## Блок 11Б.1 — общий scheduler runner и карта оставшихся разделов (2026-10-01) - Collector вызывается один раз, результат записывается одной попыткой. Fatal/errors snapshot даёт status=error с сохранёнными счётчиками/снимком; partial отмечается текстом «Завершено с ограничениями». Наблюдаемое down/offline само по себе не является ошибкой выполнения коллектора. Skipped/not-due не порождают событие о выполненной работе. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 73eb6e27..dece60aa 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -35,3 +35,13 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S - 9 runner + 4 HTTP теста, backend/contracts build и ESLint изменённых файлов прошли. Общий npm --prefix backend test завершился с кодом 0; DB-зависимые тесты пропущены из-за недоступного PostgreSQL, это не проверка транзакций с реальной DB. - Карта/референсы/схема оставшегося этапа сохранены в ux-ui-stage-11b.md. UI сбора данных пока не читает новый receipt строго; JSONB resultJson ошибочно ожидается строкой (raw.trim может выбросить ошибку). Следующий небольшой блок исправляет совместимость JSONB/legacy JSON; полные reader/state/операции — после него. - **Следующий блок 11Б.2:** отображение object/legacy snapshot в общем журнале без падения, затем 11Б.3 строгие API и независимые состояния/черновики сбора данных. Этап 11Б/12 не принят, реальные DB/RouterOS/Telegram/Portal остаются внешней приёмкой. + + +## Блок 11Б.2 — JSONB и legacy snapshot общего журнала (2026-10-01) + +- resultJson читается как unknown: PostgreSQL JSONB объект и старый строковый JSON проходят общий decoder. Больше нет raw.trim на объекте. Проверены версия/известная задача/время/обязательные поля верхнего уровня; backup counts и новые diagnostics валидируются без подстановки нулей. +- Снимок другого job не отображается в выбранной записи. Некорректный/неподдерживаемый снимок показывает Alert «Снимок недоступен», отсутствие снимка остаётся отдельным состоянием. Полная валидация вложенных строк остальных коллекций ещё относится к строгому API следующего блока. +- 4 unit, 4 E2E object/legacy/invalid/foreign, TypeScript и production frontend/contracts build прошли. ESLint parser/DTO/tests прошёл. На общей странице остаются 2 исходные set-state-in-effect ошибки и 2 warnings: отдельный lint HEAD через stdin подтвердил ту же исходную базу; их исправление входит в reader/context блока 11Б.3. +- Первый E2E прогон использовал неполную GeoIP фикстуру и падал в старом GeoipSettingsSection. Фикстура уточнена; NetFlow зависимость вынесена в явный HTTP 503 вместо неверного success DTO. Повторные 4 E2E прошли без pageerror. Строгий GeoIP reader/guard также необходим в 11Б, реальная интеграция не принята. +- ReUI audit checklist получен; существующий Alert/вариант warning, текстовый рендер и структура сохранены. Весь раздел сбора данных ещё не прошёл визуальную/доступностную приёмку. +- **Следующий блок 11Б.3:** строгие scheduler/collector API, независимые reader states, dirty drafts и контекст/права; новый run-now receipt пока не обрабатывается страницей строго. diff --git a/lib/scheduler-run-snapshot.test.ts b/lib/scheduler-run-snapshot.test.ts new file mode 100644 index 00000000..c05535b0 --- /dev/null +++ b/lib/scheduler-run-snapshot.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { parseSchedulerRunSnapshot } from "./scheduler-run-snapshot" +const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-01T00:00:00Z", due: true, created: 2, failures: 0, pruned: 1, uploadFailures: 1, pruneFailures: 1, metadataError: "metadata unavailable", eventErrors: ["event unavailable"], errors: ["S3 unavailable"] } +test("JSONB object and legacy JSON preserve the same snapshot and partial diagnostics", () => { + assert.deepEqual(parseSchedulerRunSnapshot(snapshot), snapshot) + assert.deepEqual(parseSchedulerRunSnapshot(JSON.stringify(snapshot)), snapshot) +}) +test("missing, primitive, broken and future snapshots never throw or become valid empty data", () => { + for (const raw of [undefined, null, "", " ", "{", 1, false, [], "null", { ...snapshot, v: 2 }, { ...snapshot, job: "unknown" }, { ...snapshot, sampledAt: "bad" }]) assert.equal(parseSchedulerRunSnapshot(raw), null) +}) +test("invalid backup counts, flags and diagnostic fields are rejected without zero substitution", () => { + for (const patch of [{ created: -1 }, { failures: "0" }, { pruned: 1.5 }, { created: undefined }, { due: undefined }, { skipped: "true" }, { errors: [{}] }, { eventErrors: [null] }, { metadataError: {} }, { uploadFailures: -1 }]) assert.equal(parseSchedulerRunSnapshot({ ...snapshot, ...patch }), null) +}) +test("other collector envelopes support both representations and keep missing arrays unknown", () => { + for (const job of ["traffic", "uptime_resources", "servers_rest_ping", "uptime_ping", "uptime_speed"]) { + const key = job === "uptime_ping" ? "probes" : job === "uptime_speed" ? "runs" : "servers" + const value = { v: 1, job, sampledAt: snapshot.sampledAt, [key]: [] } + assert.deepEqual(parseSchedulerRunSnapshot(value), value); assert.deepEqual(parseSchedulerRunSnapshot(JSON.stringify(value)), value) + assert.equal(parseSchedulerRunSnapshot({ ...value, [key]: undefined }), null) + } +}) diff --git a/lib/scheduler-run-snapshot.ts b/lib/scheduler-run-snapshot.ts index 5197d058..ee7fc963 100644 --- a/lib/scheduler-run-snapshot.ts +++ b/lib/scheduler-run-snapshot.ts @@ -93,6 +93,10 @@ export interface BackupsRunSnapshot { created: number failures: number pruned: number + uploadFailures?: number + pruneFailures?: number + metadataError?: string + eventErrors?: string[] errors?: string[] fatalError?: string } @@ -220,12 +224,32 @@ export interface SpeedScheduledRunSnapshot { runs: SpeedRunSnapshot[] } -export function parseSchedulerRunSnapshot(raw: string | null | undefined): SchedulerRunSnapshot | null { - if (raw == null || raw.trim() === "") return null +/** PostgreSQL JSONB returns an object; older records may still contain JSON text. */ +export function parseSchedulerRunSnapshot(raw: unknown): SchedulerRunSnapshot | null { try { - const o = JSON.parse(raw) as { v?: unknown; job?: string } - if (o?.v !== 1 || typeof o.job !== "string") return null - return o as SchedulerRunSnapshot + const value: unknown = typeof raw === "string" ? (raw.trim() ? JSON.parse(raw) : null) : raw + if (!value || typeof value !== "object" || Array.isArray(value)) return null + const o = value as Record + if (o.v !== 1 || typeof o.job !== "string" || typeof o.sampledAt !== "string" || !Number.isFinite(Date.parse(o.sampledAt))) return null + if (o.skipped !== undefined && typeof o.skipped !== "boolean") return null + if (o.fatalError !== undefined && typeof o.fatalError !== "string") return null + for (const field of ["errors", "eventErrors"]) if (o[field] !== undefined && (!Array.isArray(o[field]) || !(o[field] as unknown[]).every(error => typeof error === "string"))) return null + const arrays: Record = { traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" } + if (Object.hasOwn(arrays, o.job)) { + if (!Array.isArray(o[arrays[o.job]])) return null + } else { + const fields: Record = { gre_bgp: ["greWritten", "bgpWritten"], internet_path: ["homes"], certificates_renew: ["checked", "renewed", "skippedTargets"], backups: ["created", "failures", "pruned"], geoip_update: ["checked", "downloaded", "skippedUnchanged", "bytes"], alert_engine: ["rulesChecked", "standaloneFires", "groupFires"] } + if (!Object.hasOwn(fields, o.job) || !fields[o.job].every(field => typeof o[field] === "number" && Number.isSafeInteger(o[field]) && (o[field] as number) >= 0)) return null + if (["certificates_renew", "geoip_update"].includes(o.job) && !Array.isArray(o.errors)) return null + if (o.job === "backups") { + if (typeof o.due !== "boolean") return null + for (const field of ["uploadFailures", "pruneFailures"]) if (o[field] !== undefined && (typeof o[field] !== "number" || !Number.isSafeInteger(o[field]) || (o[field] as number) < 0)) return null + if (o.metadataError !== undefined && typeof o.metadataError !== "string") return null + } + if (o.job === "internet_path" && typeof o.snapshotSaved !== "boolean") return null + if (o.job === "alert_engine" && typeof o.skippedNoTelegram !== "boolean") return null + } + return o as unknown as SchedulerRunSnapshot } catch { return null } diff --git a/lib/scheduler-settings.ts b/lib/scheduler-settings.ts index ed88a853..ab22fec3 100644 --- a/lib/scheduler-settings.ts +++ b/lib/scheduler-settings.ts @@ -94,5 +94,5 @@ export interface SchedulerRunRowDto { error: string | null durationMs: number /** JSON снимка результатов прогона (серверы, пробы, speed и т.д.) */ - resultJson?: string | null + resultJson?: unknown } diff --git a/tests/e2e/data-collection-snapshot.spec.ts b/tests/e2e/data-collection-snapshot.spec.ts new file mode 100644 index 00000000..72d1fcdf --- /dev/null +++ b/tests/e2e/data-collection-snapshot.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "@playwright/test" +const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-01T00:00:00Z", due: true, created: 2, failures: 0, pruned: 1, errors: ["S3 upload unavailable"] } +for (const representation of ["jsonb", "legacy", "invalid", "foreign"] as const) test("Сбор данных: snapshot/" + representation, async ({ page }) => { + const pageErrors: string[] = []; page.on("pageerror", error => pageErrors.push(error.message)) + 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/traffic/flow/settings") return r.fulfill({ status: 503, json: { error: "NetFlow is outside this snapshot fixture" } }) + const settings = { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: null, lastError: null, scheduler: { jobs: [] } } + return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/geoip" ? { settings: { ...settings, updateIntervalSec: 604800 }, countryLoaded: false, asnLoaded: false, dir: "test" } : path === "/api/scheduler/runs" ? { runs: [{ id: "run-jsonb", jobKey: "backups", startedAt: snapshot.sampledAt, finishedAt: snapshot.sampledAt, status: "error", error: "Завершено с ограничениями", durationMs: 100, resultJson: representation === "legacy" ? JSON.stringify(snapshot) : representation === "invalid" ? { ...snapshot, created: undefined } : representation === "foreign" ? { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } : snapshot }] } : settings }) + }) + await page.goto("/data-collection") + await page.getByRole("button", { name: /Бэкапы.*100 мс/ }).click() + await expect(page.getByText("run-jsonb", { exact: true })).toBeVisible() + if (representation === "invalid" || representation === "foreign") { await expect(page.getByText("Слот расписания", { exact: true })).toHaveCount(0); await expect(page.getByText("Снимок недоступен", { exact: true })).toBeVisible() } + else { await expect(page.getByText("Слот расписания", { exact: true })).toBeVisible(); await expect(page.getByText("S3 upload unavailable", { exact: true })).toBeVisible(); await expect(page.getByText("Создано", { exact: true }).locator("..")).toContainText("2") } + expect(pageErrors).toEqual([]) +})