diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 2d22b081..7d749397 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 — строгие 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; внешние ограничения — журнал. +Актуальная точка продолжения: 11Б.3.2 — строгий GeoIP API; затем подключение scheduler клиента/состояния сбора. 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 9dc81590..754e3ac6 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Б.2; runner/JSONB исправлены, далее строгие API и состояния сбора | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.1; runner/JSONB и strict scheduler API проверены; далее GeoIP/API и состояния сбора | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.1 — строгий API-клиент планировщика (2026-10-01) + +- Добавлен shared/api/scheduler.ts: status/runs/run-now/refresh проверяют DTO, известные ключи, дубли, обязательные false/zero/null, время PostgreSQL и согласованность receipt. Фильтр журнала отклоняет записи чужой задачи. Snapshot остаётся unknown в каталоге до отдельного декодирования. +- Manual POST принимает только receipt выбранной задачи; успешный status с fatal/partial snapshot отклоняется. Partial files, eventError и statusError остаются отдельными подтверждёнными полями. Потерянный/неверный ответ не вызывает повторный POST. Caller cancellation и 30s срок сохранены; timeout POST не доказывает отмену backend. +- 5 новых API unit + 4 snapshot unit, полный TypeScript и ESLint новых файлов прошли. Frontend build прошёл в 11Б.2; этот блок добавляет пока не подключённый API-модуль, UI/backend не менялись. +- Клиент ещё не подключён к странице: её старые settings/receipts/locks/context/lint ограничения сохраняются. Это основа миграции, не приёмка операций сборщика. Вложенные коллекции остальных snapshot ещё требуют строгого decoder. +- **Следующий блок 11Б.3.2:** GeoIP strict API/receipt против выявленного malformed crash; затем 11Б.3.3 подключение scheduler и независимые reader/context/dirty states. + ## Блок 11Б.2 — JSONB и legacy snapshot общего журнала (2026-10-01) - resultJson читается как unknown: PostgreSQL JSONB объект и старый строковый JSON проходят общий decoder. Больше нет raw.trim на объекте. Проверены версия/известная задача/время/обязательные поля верхнего уровня; backup counts и новые diagnostics валидируются без подстановки нулей. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index dece60aa..2461518d 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -45,3 +45,12 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S - Первый 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Б.3.1 — строгий API-клиент планировщика (2026-10-01) + +- Добавлен shared/api/scheduler.ts: status/runs/run-now/refresh проверяют DTO, известные ключи, дубли, обязательные false/zero/null, время PostgreSQL и согласованность receipt. Фильтр журнала отклоняет записи чужой задачи. Snapshot остаётся unknown в каталоге до отдельного декодирования. +- Manual POST принимает только receipt выбранной задачи; успешный status с fatal/partial snapshot отклоняется. Partial files, eventError и statusError остаются отдельными подтверждёнными полями. Потерянный/неверный ответ не вызывает повторный POST. Caller cancellation и 30s срок сохранены; timeout POST не доказывает отмену backend. +- 5 новых API unit + 4 snapshot unit, полный TypeScript и ESLint новых файлов прошли. Frontend build прошёл в 11Б.2; этот блок добавляет пока не подключённый API-модуль, UI/backend не менялись. +- Клиент ещё не подключён к странице: её старые settings/receipts/locks/context/lint ограничения сохраняются. Это основа миграции, не приёмка операций сборщика. Вложенные коллекции остальных snapshot ещё требуют строгого decoder. +- **Следующий блок 11Б.3.2:** GeoIP strict API/receipt против выявленного malformed crash; затем 11Б.3.3 подключение scheduler и независимые reader/context/dirty states. diff --git a/shared/api/scheduler.test.ts b/shared/api/scheduler.test.ts new file mode 100644 index 00000000..0d100662 --- /dev/null +++ b/shared/api/scheduler.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { getSchedulerStatus, listSchedulerRuns, refreshScheduler, runSchedulerJobNow } from "./scheduler" +const base = "https://api.example.test" +const job = { jobKey: "backups", enabled: false, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null } +const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-01T00:00:00Z", due: true, created: 1, failures: 0, pruned: 0 } +const row = { id: "run-1", jobKey: "backups", startedAt: snapshot.sampledAt, finishedAt: snapshot.sampledAt, durationMs: 0, status: "ok", error: null, resultJson: snapshot } +const ack = { ok: true, result: { status: "ok", error: null, result: snapshot }, status: { jobs: [job] } } +const json = (body: unknown) => new Response(JSON.stringify(body)) +test("known empty, false, zero and null stay explicit; missing/duplicate status is rejected", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json({ jobs: [] }); assert.deepEqual(await getSchedulerStatus(base), { jobs: [] }) + globalThis.fetch = async () => json({ jobs: [job] }); const status = await getSchedulerStatus(base); assert.equal(status.jobs[0].enabled, false); assert.equal(status.jobs[0].lastDurationMs, 0); assert.equal(status.jobs[0].lastStatus, null) + for (const body of [{}, { jobs: [job, job] }, { jobs: [{ ...job, running: undefined }] }, { jobs: [{ ...job, lastStatus: "unknown" }] }, { jobs: [{ ...job, intervalSec: 0 }] }]) { globalThis.fetch = async () => json(body); await assert.rejects(() => getSchedulerStatus(base)) } + } finally { globalThis.fetch = original } +}) +test("run catalogue retains JSONB and normalizes real PostgreSQL timestamps without invented snapshots", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json({ runs: [{ ...row, startedAt: "2026-10-01 00:00:00+00", resultJson: snapshot }] }); const result = await listSchedulerRuns(base, { jobKey: "backups" }); assert.equal(result.runs[0].startedAt, "2026-10-01T00:00:00.000Z"); assert.deepEqual(result.runs[0].resultJson, snapshot) + for (const body of [{}, { runs: [row, row] }, { runs: [{ ...row, durationMs: -1 }] }, { runs: [{ ...row, jobKey: "traffic" }] }]) { globalThis.fetch = async () => json(body); await assert.rejects(() => listSchedulerRuns(base, { jobKey: "backups" })) } + } finally { globalThis.fetch = original } +}) +test("successful and partial receipts preserve files, event error and independent status read failure", async () => { + const original = globalThis.fetch + try { + globalThis.fetch = async () => json(ack); assert.equal((await runSchedulerJobNow(base, "backups")).ok, true) + globalThis.fetch = async () => json({ ok: false, result: { status: "error", error: "S3 unavailable", eventError: "event unavailable", result: snapshot }, status: null, statusError: "status unavailable" }); const result = await runSchedulerJobNow(base, "backups"); assert.equal(result.ok, false); assert.equal(result.result.result.job, "backups"); assert.equal(result.result.eventError, "event unavailable"); assert.equal(result.statusError, "status unavailable") + } finally { globalThis.fetch = original } +}) +test("empty, mismatched and contradictory POST acknowledgments never confirm a run or retry", async () => { + const original = globalThis.fetch + try { + for (const body of [{}, { ...ack, result: { ...ack.result, result: { ...snapshot, errors: ["failed"] } } }, { ...ack, result: { ...ack.result, result: { ...snapshot, uploadFailures: 1 } } }, { ...ack, ok: false }, { ...ack, result: { ...ack.result, result: null } }, { ...ack, result: { ...ack.result, result: { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } } }, { ...ack, status: null }, { ...ack, result: { ...ack.result, status: "error", error: null } }]) { + let posts = 0; globalThis.fetch = async (_url, init) => { assert.equal(init?.method, "POST"); posts++; return json(body) }; await assert.rejects(() => runSchedulerJobNow(base, "backups")); assert.equal(posts, 1) + } + let posts = 0; globalThis.fetch = async () => { posts++; throw Error("lost acknowledgment") }; await assert.rejects(() => runSchedulerJobNow(base, "backups"), /lost acknowledgment/); assert.equal(posts, 1) + } finally { globalThis.fetch = original } +}) +test("invalid input fails before HTTP, caller cancellation is preserved and refresh needs confirmation", async () => { + const original = globalThis.fetch + try { + let calls = 0; globalThis.fetch = async () => { calls++; return json({}) } + await assert.rejects(() => runSchedulerJobNow(base, "invalid" as never)); await assert.rejects(() => listSchedulerRuns(base, { limit: 0 })); assert.equal(calls, 0) + const controller = new AbortController(); controller.abort(); globalThis.fetch = async (_url, init) => { assert.equal(init?.signal?.aborted, true); throw controller.signal.reason }; await assert.rejects(() => getSchedulerStatus(base, { signal: controller.signal })) + globalThis.fetch = async () => json({ ok: false, status: { jobs: [] } }); await assert.rejects(() => refreshScheduler(base)) + globalThis.fetch = async () => json({ ok: true, status: { jobs: [] } }); assert.equal((await refreshScheduler(base)).ok, true) + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/scheduler.ts b/shared/api/scheduler.ts new file mode 100644 index 00000000..91ec04cf --- /dev/null +++ b/shared/api/scheduler.ts @@ -0,0 +1,43 @@ +import { z } from "zod" +import { SCHEDULER_JOB_KEYS, type SchedulerJobKey } from "@/lib/scheduler-settings" +import { parseSchedulerRunSnapshot } from "@/lib/scheduler-run-snapshot" +import { requestJson } from "./http-client" +const key = z.enum(SCHEDULER_JOB_KEYS) +const count = z.number().int().nonnegative().safe() +const timestamp = z.string().min(1).refine(value => Number.isFinite(Date.parse(value)), "Некорректное время планировщика").transform(value => new Date(value).toISOString()) +const jobStatus = z.object({ jobKey: key, enabled: z.boolean(), intervalSec: count.positive(), running: z.boolean(), lastFinishedAt: timestamp.nullable(), lastStatus: z.enum(["ok", "error"]).nullable(), lastDurationMs: count.nullable(), lastError: z.string().nullable() }) +const statusSchema = z.object({ jobs: z.array(jobStatus).refine(jobs => new Set(jobs.map(job => job.jobKey)).size === jobs.length, "Повторяющиеся задачи планировщика") }) +const runSchema = z.object({ id: z.string().min(1), jobKey: key, startedAt: timestamp, finishedAt: timestamp, status: z.enum(["ok", "error"]), error: z.string().nullable(), durationMs: count, resultJson: z.unknown().optional() }) +const runsSchema = z.object({ runs: z.array(runSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся записи планировщика") }) +const receiptSchema = z.object({ status: z.enum(["ok", "error"]), error: z.string().nullable(), result: z.unknown(), eventError: z.string().optional() }).refine(receipt => receipt.status === "ok" ? receipt.error === null : !!receipt.error, "Статус запуска не согласован с ошибкой") +const responseSchema = z.object({ ok: z.boolean(), result: receiptSchema, status: statusSchema.nullable(), statusError: z.string().optional() }).refine(response => response.ok === (response.result.status === "ok") && (response.status !== null || !!response.statusError), "Подтверждение запуска не согласовано с результатом") +function options(init?: RequestInit): RequestInit { + return { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) } +} +async function json(baseUrl: string, path: string, init?: RequestInit) { + return requestJson(baseUrl, path, options(init)) +} +export async function getSchedulerStatus(baseUrl: string, init?: RequestInit) { + return statusSchema.parse(await json(baseUrl, "/api/scheduler/status", { ...init, method: "GET" })) +} +export async function listSchedulerRuns(baseUrl: string, filter: { jobKey?: SchedulerJobKey; limit?: number; offset?: number } = {}, init?: RequestInit) { + const query = z.object({ jobKey: key.optional(), limit: count.min(1).max(200).optional(), offset: count.optional() }).parse(filter) + const params = new URLSearchParams({ limit: String(query.limit ?? 80) }) + if (query.jobKey) params.set("jobKey", query.jobKey) + if (query.offset !== undefined) params.set("offset", String(query.offset)) + const result = runsSchema.parse(await json(baseUrl, "/api/scheduler/runs?" + params.toString(), { ...init, method: "GET" })) + if (query.jobKey && result.runs.some(run => run.jobKey !== query.jobKey)) throw new Error("Журнал относится к другой задаче планировщика") + return result +} +export async function runSchedulerJobNow(baseUrl: string, jobKey: SchedulerJobKey, init?: RequestInit) { + const target = key.parse(jobKey) + const response = responseSchema.parse(await json(baseUrl, "/api/scheduler/jobs/" + encodeURIComponent(target) + "/run-now", { ...init, method: "POST" })) + const snapshot = parseSchedulerRunSnapshot(response.result.result) + if (!snapshot || snapshot.job !== target) throw new Error("Результат запуска не подтверждён для выбранной задачи планировщика") + const snapshotHasErrors = ("fatalError" in snapshot && !!snapshot.fatalError) || ("errors" in snapshot && !!snapshot.errors?.length) || (snapshot.job === "backups" && (!!snapshot.failures || !!snapshot.uploadFailures || !!snapshot.pruneFailures || !!snapshot.metadataError || !!snapshot.eventErrors?.length)) + if (response.ok && snapshotHasErrors) throw new Error("Успешный статус не согласован с ошибками снимка планировщика") + return { ...response, result: { ...response.result, result: snapshot } } +} +export async function refreshScheduler(baseUrl: string, init?: RequestInit) { + return z.object({ ok: z.literal(true), status: statusSchema }).parse(await json(baseUrl, "/api/scheduler/refresh", { ...init, method: "POST" })) +}