fix(scheduler): различать неизвестную историю и подтверждённые запуски
This commit is contained in:
1 parent
933a4eaf57
commit
1f75bc5cea
6 files changed
+95
-18
No files matched your search
@@ -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
@@ -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; внешние ограничения — журнал.
|
||||
|
||||
| Экран | ПК | Телефон |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -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/не запускают последующее чтение.
|
||||
|
||||
@@ -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 и операции.
|
||||
@@ -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" }))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user