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

This commit is contained in:
Denozordec committed 2026-10-01 21:47:10 +07:00
1 parent 933a4eaf57
commit 1f75bc5cea
6 files changed
+95 -18

No files matched your search

+28 -12
View File
@@ -25,6 +25,7 @@ import {
type SchedulerRunRowDto,
type UptimeSettingsDto,
} from "@/lib/scheduler-settings"
import { listSchedulerRuns } from "@/shared/api/scheduler"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
@@ -585,6 +586,9 @@ function DataCollectionContent() {
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
const [runsKnown, setRunsKnown] = useState(false)
const [runsError, setRunsError] = useState<string | null>(null)
const [runsReceivedAt, setRunsReceivedAt] = useState<string | null>(null)
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
@@ -623,17 +627,14 @@ function DataCollectionContent() {
setCollectorError(null)
setPageBusy(true)
try {
const runsQuery =
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
: "?limit=80"
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([
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 }),
listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }),
])
if (!current()) return
@@ -699,14 +700,17 @@ function DataCollectionContent() {
const runsPayload = readSettled(runsRes)
if (runsPayload) {
setSchedulerRuns(runsPayload.runs ?? [])
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 {
if (current()) setPageBusy(false)
}
}, [apiFetch, isLive, runFilterJobKey])
}, [apiFetch, backendUrl, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
@@ -1041,8 +1045,8 @@ function DataCollectionContent() {
},
{
label: "Журнал (в списке)",
value: String(schedulerRuns.length),
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
value: runsKnown ? String(schedulerRuns.length) : "—",
sub: runsError ? (runsKnown ? "Показана последняя подтверждённая история" : "История не получена") : runsKnown ? (errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет") : "Ожидание истории",
icon: <DatabaseIcon className="size-4" />,
},
]
@@ -1260,7 +1264,7 @@ function DataCollectionContent() {
<OpsPanel
title="Журнал прогонов"
description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
description="Последние 80 запусков. Раскройте запись для результатов и объяснения ошибок."
headerRight={
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
@@ -1270,7 +1274,7 @@ function DataCollectionContent() {
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); setOpenRunIds(new Set()) }}
onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
>
<option value="">Все</option>
{SCHEDULER_JOB_KEYS.map((k) => (
@@ -1283,7 +1287,19 @@ function DataCollectionContent() {
}
contentClassName="px-0 pb-0"
>
{schedulerRuns.length === 0 ? (
{runsError ? (
<div className="mx-4 mb-3">
<Alert variant="warning">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{runsKnown ? "История не обновлена" : "История не получена"}</AlertTitle>
<AlertDescription>{runsError}</AlertDescription>
</Alert>
</div>
) : null}
{runsReceivedAt ? <p className="px-4 pb-3 text-xs text-muted-foreground">История получена: {new Date(runsReceivedAt).toLocaleString("ru-RU")}</p> : null}
{!runsKnown ? (
!runsError ? <p role="status" className="px-4 py-10 text-center text-sm text-muted-foreground">Загрузка журнала…</p> : null
) : schedulerRuns.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
) : (
<div className="divide-y divide-border">
+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Б.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; внешние ограничения — журнал.
Актуальная точка продолжения: 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; внешние ограничения — журнал.
| Экран | ПК | Телефон |
| --- | --- | --- |
+9 -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–11Б.3.3.1; runner/API/JSONB/GeoIP/гонки журнала проверены; далее состояния и формы сбора |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.2; strict history/unknown/свежесть проверены; далее независимые status/readers и формы |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.3.3.2 — подтверждённая история и свежесть журнала (2026-10-01)
- Strict listSchedulerRuns подключён к странице. Missing/duplicate/foreign/invalid DTO не становится пустой историей; первоначальная ошибка даёт unknown/KPI —, подтверждённое empty даёт 0. Ответные ошибки API теперь понятные, без сырого Zod JSON.
- Фоновый сбой сохраняет строки и прежнее время получения; Alert объясняет необходимость обновления. Смена job filter сбрасывает только историю/раскрытие/свежесть предыдущего среза: старые строки не выдаются за новый фильтр. Browser receivedAt явно подписан временем получения истории, не временем выполнения job.
- Добавлены 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.1 — контекст и гонки чтения общего журнала (2026-10-01)
- Страница ждёт hydration источника; mode/backend задают ключ нового содержимого. При размонтировании читатель становится inactive, старые запросы отменяются и их callbacks не обновляют UI/не запускают последующее чтение.
+9
View File
@@ -72,3 +72,12 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
- Сброс раскрытых строк перенесён в обработчик фильтра; эффектные 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.3.2 — подтверждённая история и свежесть журнала (2026-10-01)
- Strict listSchedulerRuns подключён к странице. Missing/duplicate/foreign/invalid DTO не становится пустой историей; первоначальная ошибка даёт unknown/KPI —, подтверждённое empty даёт 0. Ответные ошибки API теперь понятные, без сырого Zod JSON.
- Фоновый сбой сохраняет строки и прежнее время получения; Alert объясняет необходимость обновления. Смена job filter сбрасывает только историю/раскрытие/свежесть предыдущего среза: старые строки не выдаются за новый фильтр. Browser receivedAt явно подписан временем получения истории, не временем выполнения job.
- Добавлены 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 и операции.
+9 -4
View File
@@ -11,6 +11,11 @@ const runSchema = z.object({ id: z.string().min(1), jobKey: key, startedAt: time
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 confirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Ответ планировщика не подтверждён: получены некорректные данные")
return parsed.data
}
function options(init?: RequestInit): RequestInit {
return { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) }
}
@@ -18,20 +23,20 @@ async function json(baseUrl: string, path: string, init?: RequestInit) {
return requestJson<unknown>(baseUrl, path, options(init))
}
export async function getSchedulerStatus(baseUrl: string, init?: RequestInit) {
return statusSchema.parse(await json(baseUrl, "/api/scheduler/status", { ...init, method: "GET" }))
return confirmed(statusSchema, 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" }))
const result = confirmed(runsSchema, 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 response = confirmed(responseSchema, 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))
@@ -39,5 +44,5 @@ export async function runSchedulerJobNow(baseUrl: string, jobKey: SchedulerJobKe
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" }))
return confirmed(z.object({ ok: z.literal(true), status: statusSchema }), await json(baseUrl, "/api/scheduler/refresh", { ...init, method: "POST" }))
}
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const row = { id: "confirmed-run", jobKey: "backups", startedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:00:00Z", status: "ok", error: null, durationMs: 100, resultJson: 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" } })
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("Журнал: первоначальная ошибка не становится empty/" + malformed, async ({ page }) => {
await setup(page); await page.route("**/api/scheduler/runs?**", r => r.fulfill(malformed ? { json: {} } : { status: 503, json: { error: "history unavailable" } }))
await page.goto("/data-collection"); await expect(page.getByText("История не получена", { exact: true }).first()).toBeVisible()
await expect(page.getByText("Пока нет прогонов", { exact: true })).toHaveCount(0)
await expect(page.getByText("Журнал (в списке)", { exact: true }).locator("../..")).toContainText("—")
})
test("Журнал: background error сохраняет строки и время последнего чтения", async ({ page }) => {
await setup(page); let fails = false
await page.route("**/api/scheduler/runs?**", r => r.fulfill(fails ? { status: 503, json: { error: "history refresh unavailable" } } : { json: { runs: [row] } }))
await page.goto("/data-collection"); await page.getByRole("button", { name: /Бэкапы.*100 мс/ }).click(); await expect(page.getByText("confirmed-run", { exact: true })).toBeVisible()
const received = await page.getByText(/^История получена:/).textContent(); fails = true
await page.getByRole("button", { name: "Обновить", exact: true }).click(); await expect(page.getByText("История не обновлена", { exact: true })).toBeVisible()
await expect(page.getByText("confirmed-run", { exact: true })).toBeVisible(); await expect(page.getByText(/^История получена:/)).toHaveText(received!)
})
test("Журнал: подтверждённый пустой список имеет ноль и время получения", async ({ page }) => {
await setup(page); await page.route("**/api/scheduler/runs?**", r => r.fulfill({ json: { runs: [] } }))
await page.goto("/data-collection"); await expect(page.getByText("Пока нет прогонов", { exact: true })).toBeVisible(); await expect(page.getByText(/^История получена:/)).toBeVisible()
await expect(page.getByText("Журнал (в списке)", { exact: true }).locator("../..")).toContainText("0")
})
test("Журнал: смена среза не выдаёт прежние строки за новый фильтр", async ({ page }) => {
await setup(page); await page.route("**/api/scheduler/runs?**", r => r.fulfill(new URL(r.request().url()).searchParams.get("jobKey") === "traffic" ? { status: 503, json: { error: "traffic history unavailable" } } : { json: { runs: [row] } }))
await page.goto("/data-collection"); await expect(page.getByRole("button", { name: /Бэкапы.*100 мс/ })).toBeVisible()
await page.getByLabel("Задача", { exact: true }).selectOption("traffic"); await expect(page.getByText("История не получена", { exact: true }).first()).toBeVisible()
await expect(page.getByRole("button", { name: /Бэкапы.*100 мс/ })).toHaveCount(0); await expect(page.getByText(/^История получена:/)).toHaveCount(0)
})