fix(scheduler): показывать подтверждённый статус независимо от настроек uptime

This commit is contained in:
Denozordec committed 2026-10-01 21:55:03 +07:00
1 parent 1f75bc5cea
commit 5d5ea680aa
5 files changed
+87 -51

No files matched your search

+34 -49
View File
@@ -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<SchedulerRunRowDto[]>([])
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatusDto | null>(null)
const [schedulerStatusError, setSchedulerStatusError] = useState<string | null>(null)
const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState<string | null>(null)
const [runsKnown, setRunsKnown] = useState(false)
const [runsError, setRunsError] = useState<string | null>(null)
const [runsReceivedAt, setRunsReceivedAt] = useState<string | null>(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<string, SchedulerJobStatusDto>
}, [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<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 }),
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: <CalendarClockIcon className="size-4" />,
},
{
label: "Сейчас выполняется",
value: String(runningJobsCount),
sub: "Фоновые прогоны планировщика",
value: schedulerStatus ? String(runningJobsCount) : "—",
sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика",
icon: <LoaderCircleIcon className="size-4" />,
},
{
@@ -1099,6 +1076,14 @@ function DataCollectionContent() {
</OpsPanel>
)}
{isLive && schedulerStatusError ? (
<Alert variant="warning">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{schedulerStatus ? "Статус планировщика не обновлён" : "Статус планировщика не получен"}</AlertTitle>
<AlertDescription>{schedulerStatusError}</AlertDescription>
</Alert>
) : null}
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
{isLive && collectorError && (
<Alert variant="destructive">
<AlertCircleIcon />
+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.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; внешние ограничения — журнал.
| Экран | ПК | Телефон |
| --- | --- | --- |
+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–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.
+10
View File
@@ -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 проверки.
+32
View File
@@ -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<void>(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!)
})