diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index f84300bd..84317e05 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -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(path: string, init?: RequestInit): Promise { - return requestJson(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> | null>(null) const [serversApiCollector, setServersApiCollector] = useState> | 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() { Обновить - @@ -992,7 +992,7 @@ function DataCollectionContent() {

Планировщик сбора данных

- Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения. + Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.

diff --git a/components/data-grids/data-collection-scheduler-data-grid.tsx b/components/data-grids/data-collection-scheduler-data-grid.tsx index 4f35fa66..68ff2a1e 100644 --- a/components/data-grids/data-collection-scheduler-data-grid.tsx +++ b/components/data-grids/data-collection-scheduler-data-grid.tsx @@ -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}`} > diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 7ecf91af..6ae9d8fe 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -226,7 +226,7 @@ REST запрос → ожидание → подтверждённый output / | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | -Каталог 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; внешние ограничения — журнал. | Экран | ПК | Телефон | | --- | --- | --- | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index bc1b3a6e..e797899b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.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. Неполные данные других источников не мешают точечной операции. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 41fc1356..d0ce12c4 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -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Б; их общий интерфейс пока не принят. diff --git a/tests/e2e/data-collection-operations.spec.ts b/tests/e2e/data-collection-operations.spec.ts new file mode 100644 index 00000000..12e48c67 --- /dev/null +++ b/tests/e2e/data-collection-operations.spec.ts @@ -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(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(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() } +}) diff --git a/tests/e2e/data-collection-settings.spec.ts b/tests/e2e/data-collection-settings.spec.ts index 593690b7..e0aca1e6 100644 --- a/tests/e2e/data-collection-settings.spec.ts +++ b/tests/e2e/data-collection-settings.spec.ts @@ -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(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() } +})