fix(scheduler): читать снимки JSONB и показывать недоступные результаты

This commit is contained in:
Denozordec committed 2026-10-01 21:19:36 +07:00
1 parent 63b2f75012
commit 92b7399ddb
8 files changed
+102 -9

No files matched your search

+11 -1
View File
@@ -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 (
<div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
@@ -501,6 +505,12 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? (
<SnapshotTables snap={snapshot} />
) : hasSnapshot ? (
<Alert variant="warning">
<AlertCircleIcon />
<AlertTitle>Снимок недоступен</AlertTitle>
<AlertDescription>Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче.</AlertDescription>
</Alert>
) : (
<p className="text-xs text-muted-foreground">
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
+1 -1
View File
@@ -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; внешние ограничения — журнал.
| Экран | ПК | Телефон |
| --- | --- | --- |
+10 -1
View File
@@ -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 не порождают событие о выполненной работе.
+10
View File
@@ -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 пока не обрабатывается страницей строго.
+22
View File
@@ -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)
}
})
+29 -5
View File
@@ -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<string, unknown>
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<string, string> = { 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<string, string[]> = { 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
}
+1 -1
View File
@@ -94,5 +94,5 @@ export interface SchedulerRunRowDto {
error: string | null
durationMs: number
/** JSON снимка результатов прогона (серверы, пробы, speed и т.д.) */
resultJson?: string | null
resultJson?: unknown
}
@@ -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([])
})