fix(scheduler): подтверждать ручные операции и учитывать частичные ошибки

This commit is contained in:
Denozordec committed 2026-10-03 11:56:08 +07:00
1 parent cb377fb60c
commit 5df337f6d7
7 files changed
+117 -36

No files matched your search

+33 -33
View File
@@ -27,8 +27,8 @@ import {
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings"
import { can } from "@/lib/auth"
import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates"
import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import { getSchedulerStatus, listSchedulerRuns, runSchedulerJobNow, refreshScheduler } from "@/shared/api/scheduler"
import { ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRunSnapshot,
@@ -70,12 +70,6 @@ import {
XCircleIcon,
} from "lucide-react"
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
function extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
@@ -524,7 +518,6 @@ export default function DataCollectionPage() {
function DataCollectionContent() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
@@ -597,7 +590,6 @@ function DataCollectionContent() {
const controller = new AbortController(); reader.current.controller = controller
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
const current = () => reader.current.active && revision === reader.current.revision && !controller.signal.aborted
setCollectorError(null)
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
@@ -733,14 +725,34 @@ function DataCollectionContent() {
}, [isLive, loadCollectors])
const handleRefreshScheduler = async () => {
if (!isLive) return
setCollectorError(null)
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin")) return
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
try {
await apiFetch("/api/scheduler/refresh", { method: "POST" })
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик")
}
const data = await refreshScheduler(backendUrl)
if (!reader.current.active) return
setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString())
setSaveMessage("Перечитывание таймеров подтверждено сервером")
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
}
const handleRunNow = async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number]) => {
const job = schedulerJobsByKey[jobKey]
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin") || !job || job.running) return
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setRunNowJobKey(jobKey); setCollectorError(null); setSaveMessage(null)
try {
const data = await runSchedulerJobNow(backendUrl, jobKey)
if (!reader.current.active) return
if (data.status) { setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
else setSchedulerStatusError(data.statusError ?? "Статус после запуска недоступен")
const skipped = "skipped" in data.result.result && data.result.result.skipped
const notDue = data.result.result.job === "backups" && !data.result.result.due
setSaveMessage((!data.ok ? "Запуск завершился с ошибками: " : skipped || notDue ? "Прогон пропущен: " : "Запуск подтверждён: ") + SCHEDULER_JOB_LABELS[jobKey])
const warnings = [data.result.error, data.result.eventError ? "Журнал событий: " + data.result.eventError : null, data.statusError ? "Статус: " + data.statusError : null].filter(Boolean)
if (warnings.length) setCollectorError(warnings.join("; "))
} catch (error) {
if (reader.current.active) setCollectorError(extractApiError(error) + ". Результат операции неизвестен; перед повторным запуском обновите журнал.")
} finally { mutationLock.current = false; if (reader.current.active) { setSchedulerSaveBusy(false); setRunNowJobKey(null) } }
}
const schedulerGridRows: SchedulerJobGridRow[] = (() => {
@@ -829,20 +841,8 @@ function DataCollectionContent() {
void handleJobEnabledChange(jobKey, nextEnabled)
},
onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) },
onRunNow: async () => {
setRunNowJobKey(jobKey)
setCollectorError(null)
try {
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
method: "POST",
})
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
} finally {
setRunNowJobKey(null)
}
},
onRunNow: async () => { await handleRunNow(jobKey) },
runNowDisabled: schedulerSaveBusy || !can("mm:settings:admin") || !j || j.running,
runNowLoading: runNowJobKey === jobKey,
saveBusy: schedulerSaveBusy || !writable(settingsKey),
}
@@ -906,7 +906,7 @@ function DataCollectionContent() {
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy} onClick={() => { void handleRefreshScheduler() }}>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy || !can("mm:settings:admin")} onClick={() => { void handleRefreshScheduler() }}>
Перечитать таймеры
</Button>
</div>
@@ -992,7 +992,7 @@ function DataCollectionContent() {
<div className="border-b border-border px-5 py-4">
<p className="text-base font-medium">Планировщик сбора данных</p>
<p className="text-xs text-muted-foreground mt-1">
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.
</p>
</div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
@@ -37,6 +37,7 @@ export interface SchedulerJobGridRow {
onEnabledChange?: (enabled: boolean) => void
onIntervalChange: (value: string) => void
onRunNow: () => void
runNowDisabled: boolean
runNowLoading: boolean
saveBusy: boolean
}
@@ -188,7 +189,7 @@ function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGr
size="sm"
variant="outline"
className="h-8"
disabled={r.job?.running || r.runNowLoading}
disabled={r.runNowDisabled || r.runNowLoading}
onClick={r.onRunNow}
aria-label={`Запустить: ${r.label}`}
>
+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) |
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.3.6.3 — строгие run-now/refresh receipts, общий lock/права и сообщения skipped/partial/event/status. Точечные операции настроек, ввод и подтверждённый сброс dirty приняты в 11Б.3.3.6.2. Независимые strict settings readers и защита черновиков подключены в 11Б.3.3.5; реальные интеграции остаются внешней приёмкой. Независимые 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; внешние ограничения — журнал.
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.3.7 — backend source/визуальная приёмка scheduler; параллельных агентов не использовать. Ручные run-now/refresh, общий lock/права и сообщения skipped/partial/event/status приняты в 11Б.3.3.6.3. Точечные операции настроек, ввод и подтверждённый сброс dirty приняты в 11Б.3.3.6.2. Независимые strict settings readers и защита черновиков подключены в 11Б.3.3.5; реальные интеграции остаются внешней приёмкой. Независимые 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; внешние ограничения — журнал.
| Экран | ПК | Телефон |
| --- | --- | --- |
+8 -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.6.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.6.3; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,13 @@
## Блок 11Б.3.3.6.3 — подтверждённые ручные запуски (2026-10-03)
- Строгие scheduler run-now/refresh подключены вместо raw POST. Показаны подтверждённый запуск, skipped/not-due и partial; event/status failure отделены от результата задачи. Неверный/чужой receipt не становится успехом и не повторяет POST, сообщает неизвестность результата и необходимость обновить журнал.
- Run-now/refresh используют общий синхронный lock с PUT, права mm:settings:admin и сохранённые настройки без отправки dirty draft. Неизвестная/running задача не запускается из UI. Поздние callbacks после unmount/source change не публикуются; backend применение уже начатой операции не отменяется.
- Прошли 26 E2E (10 operations + 16 settings), ESLint и production frontend/contracts build. Проверены partial/event/status, false receipt/foreign snapshot, lock и mode change; смена режима также прекращает оставшийся batch PUT. Предыдущие 11 status/history/race прошли в 11Б.3.3.6.2; новый блок не меняет readers.
- Далее 11Б.3.3.7: backend source E2E и визуальная/адаптивная приёмка scheduler. GeoIP/NetFlow формы и Evolution provider, alerts/releases/probe scheduling остаются в объёме этапа 11Б; их общий интерфейс пока не принят.
## Блок 11Б.3.3.6.2 — точечное сохранение и права (2026-10-03)
- Переключатель отправляет только флаг выбранного сборщика; сохранение отправляет только dirty поля. Все числовые поля выбранного плана проверяются до первой записи без parseInt/default/clamp. Неполные данные других источников не мешают точечной операции.
+7
View File
@@ -139,3 +139,10 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
- Синхронный lock предотвращает повтор; pending GET инвалидируется перед записью, поздний ответ не отменяет подтверждённый переключатель. Изменение источника/unmount прекращает публикацию и последующие записи batch, но не гарантирует отмену уже полученной backend операции. Права соответствуют существующим backend permissions каждого endpoint.
- Прошли 26 E2E (15 settings + 11 history/status/race), ESLint и production frontend/contracts build. Проверены malformed/mismatched receipts, selective PUT, partial failure, dirty, fractional/no HTTP, lock, old GET, HTTP 403 и read-only claims. Реальный JWT/DB/RouterOS/ACME не проверен.
- Далее 11Б.3.3.6.3: подключить строгие run-now/refresh receipts, общий lock и права для POST, отдельно показать skipped/partial/event/status failures; затем source E2E/визуальная приёмка страницы. Этап 11Б не завершён.
## Блок 11Б.3.3.6.3 — подтверждённые ручные запуски (2026-10-03)
- Строгие scheduler run-now/refresh подключены вместо raw POST. Показаны подтверждённый запуск, skipped/not-due и partial; event/status failure отделены от результата задачи. Неверный/чужой receipt не становится успехом и не повторяет POST, сообщает неизвестность результата и необходимость обновить журнал.
- Run-now/refresh используют общий синхронный lock с PUT, права mm:settings:admin и сохранённые настройки без отправки dirty draft. Неизвестная/running задача не запускается из UI. Поздние callbacks после unmount/source change не публикуются; backend применение уже начатой операции не отменяется.
- Прошли 26 E2E (10 operations + 16 settings), ESLint и production frontend/contracts build. Проверены partial/event/status, false receipt/foreign snapshot, lock и mode change; смена режима также прекращает оставшийся batch PUT. Предыдущие 11 status/history/race прошли в 11Б.3.3.6.2; новый блок не меняет readers.
- Далее 11Б.3.3.7: backend source E2E и визуальная/адаптивная приёмка scheduler. GeoIP/NetFlow формы и Evolution provider, alerts/releases/probe scheduling остаются в объёме этапа 11Б; их общий интерфейс пока не принят.
@@ -0,0 +1,55 @@
import { test, expect, type Page } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const job = { jobKey: "backups", enabled: true, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null }
const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-03T00:00:00Z", due: true, created: 1, failures: 0, pruned: 0 }
const ack = { ok: true, result: { status: "ok", error: null, result: snapshot }, status: { jobs: [job] } }
async function setup(page: 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/scheduler/status") return r.fulfill({ json: { jobs: [job] } })
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
if (path === "/api/traffic/settings") return r.fulfill({ json: { enabled: true, intervalSec: 45, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null } })
return r.fulfill({ status: 503, json: { error: "outside POST fixture" } })
})
}
for (const [label, result, message] of [
["успех", ack, "Запуск подтверждён: Бэкапы"],
["пропуск", { ...ack, result: { ...ack.result, result: { ...snapshot, due: false, created: 0 } } }, "Прогон пропущен: Бэкапы"],
["частичный сбой", { ...ack, ok: false, result: { status: "error", error: "S3 unavailable", eventError: "event unavailable", result: { ...snapshot, failures: 1 } }, status: null, statusError: "status unavailable" }, "Запуск завершился с ошибками: Бэкапы"],
] as const) test("Ручной запуск: " + label + " отдельно от черновика", async ({ page }) => {
await setup(page); let posts = 0, puts = 0; page.on("request", r => { if (r.method() === "PUT") puts++ })
await page.route("**/api/scheduler/jobs/backups/run-now", r => { posts++; return r.fulfill({ json: result }) })
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99")
const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click(); await expect(page.getByText(message, { exact: true })).toBeVisible(); await expect(field).toHaveValue("99"); expect(posts).toBe(1); expect(puts).toBe(0)
if (label === "частичный сбой") { await expect(page.getByText(/Журнал событий: event unavailable/)).toBeVisible(); await expect(page.getByText("Статус планировщика не обновлён", { exact: true })).toBeVisible() }
})
for (const response of [{}, { ...ack, result: { ...ack.result, result: { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } } }]) test("Ручной запуск: неверное подтверждение не повторяет POST/" + JSON.stringify(response), async ({ page }) => {
await setup(page); let posts = 0; await page.route("**/api/scheduler/jobs/backups/run-now", r => { posts++; return r.fulfill({ json: response }) })
await page.goto("/data-collection"); const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click()
await expect(page.getByText(/Результат операции неизвестен/)).toBeVisible(); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toHaveCount(0); expect(posts).toBe(1)
})
test("Ручной запуск: общий lock блокирует повтор и сохранение", async ({ page }) => {
await setup(page); let posts = 0, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/scheduler/jobs/backups/run-now", async r => { posts++; await gate; await r.fulfill({ json: ack }) })
try {
await page.goto("/data-collection"); const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click(); await expect.poll(() => posts).toBe(1); await expect(run).toBeDisabled(); await expect(page.getByRole("button", { name: "Перечитать таймеры", exact: true })).toBeDisabled(); release(); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toBeVisible(); expect(posts).toBe(1)
} finally { release() }
})
for (const valid of [false, true]) test("Таймеры: строгий receipt/" + valid, async ({ page }) => {
await setup(page); let posts = 0; await page.route("**/api/scheduler/refresh", r => { posts++; return r.fulfill({ json: valid ? { ok: true, status: { jobs: [] } } : { ok: true } }) })
await page.goto("/data-collection"); const refresh = page.getByRole("button", { name: "Перечитать таймеры", exact: true }); await expect(refresh).toBeEnabled(); await refresh.click()
await expect(page.getByText(valid ? "Перечитывание таймеров подтверждено сервером" : "Ответ планировщика не подтверждён: получены некорректные данные", { exact: true })).toBeVisible(); expect(posts).toBe(1)
})
for (const fails of [false, true]) test("Ручной запуск: поздний ответ после смены режима/" + fails, async ({ page }) => {
await setup(page); let started = false, completed = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/scheduler/jobs/backups/run-now", async r => { started = true; await gate; try { await r.fulfill(fails ? { status: 503, json: { error: "СТАРАЯ ОШИБКА ЗАПУСКА" } } : { json: ack }) } catch {} finally { completed = true } })
try {
await page.goto("/data-collection"); const run = page.getByRole("button", { name: "Запустить: Бэкапы", exact: true }); await expect(run).toBeEnabled(); await run.click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings"); await page.getByRole("button", { name: "Моковые", exact: true }).click(); await page.goto("/data-collection"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByText("Нужен live-режим", { exact: true })).toBeVisible(); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toHaveCount(0); await expect(page.getByText(/СТАРАЯ ОШИБКА ЗАПУСКА/)).toHaveCount(0)
} finally { release() }
})
@@ -131,3 +131,14 @@ test("Настройки: read-only claims отключают запись", asy
await page.goto("/data-collection"); await expect(page.getByTestId("settings-traffic")).toContainText("настройки получены")
await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toBeDisabled(); await expect(page.getByRole("switch", { name: "Включить: Трафик", exact: true })).toBeDisabled()
})
test("Настройки: смена режима прекращает оставшуюся часть batch", async ({ page }) => {
await setup(page); let started = false, completed = false, uptimeWrites = 0, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
await page.route("**/api/traffic/settings", async r => { if (r.request().method() !== "PUT") return r.fallback(); started = true; await gate; try { await r.fulfill({ json: { ok: true, settings: { ...values["/api/traffic/settings"], ...r.request().postDataJSON() } } }) } catch {} finally { completed = true } })
await page.route("**/api/uptime/settings", r => { if (r.request().method() !== "PUT") return r.fallback(); uptimeWrites++; return r.fulfill({ json: {} }) })
try {
await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99"); await page.getByRole("textbox", { name: "Хранение uptime", exact: true }).fill("21"); await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings"); await page.getByRole("button", { name: "Моковые", exact: true }).click(); await page.goto("/data-collection"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByText("Нужен live-режим", { exact: true })).toBeVisible(); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toHaveCount(0); expect(uptimeWrites).toBe(0)
} finally { release() }
})