diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx
index 84317e05..21a069a5 100644
--- a/app/(main)/data-collection/page.tsx
+++ b/app/(main)/data-collection/page.tsx
@@ -876,9 +876,9 @@ function DataCollectionContent() {
sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? (
- ) : (
+ ) : trafficCollector?.lastCollectedAt ? (
- ),
+ ) : (),
},
{
label: "Журнал (в списке)",
@@ -946,7 +946,7 @@ function DataCollectionContent() {
{isLive && schedulerStatusReceivedAt ?
diff --git a/components/traffic/netflow-settings-panel.tsx b/components/traffic/netflow-settings-panel.tsx
index ec6ebd83..e994967e 100644
--- a/components/traffic/netflow-settings-panel.tsx
+++ b/components/traffic/netflow-settings-panel.tsx
@@ -274,7 +274,7 @@ function NetflowSettingsPanel({
<>
{settings?.listenerBound ? (
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index 6ae9d8fe..6d3963e0 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.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; внешние ограничения — журнал.
+Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.4 — GeoIP/NetFlow формы и операции. Scheduler принят локально в 11Б.3.3.7 (46 основных + 6 backend source E2E, ПК/390/320); внешние интеграции не доказаны. Параллельных агентов не использовать. Ручные 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 e797899b..da721f9d 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.3; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
+| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.3.7; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
+## Блок 11Б.3.3.7 — локальная приёмка scheduler (2026-10-03)
+
+- Проверены обычные HTTP-пути со сменой backend: 6 source E2E для позднего GET/PUT/run-now, успех и отказ старого источника не меняют новый. Source тесты вынесены в отдельную конфигурацию :3101/local; общий профиль остаётся :3100/same-origin.
+- 46 основных профильных E2E проверены: 39 прошли сразу, 7 KPI assertions требовали обновления селектора после переноса подписей; 9 history/status повторно прошли с устойчивой привязкой к установленному FramePanel. Отдельно 6 source E2E прошли. Новая структура не меняет unknown/zero значения.
+- ПК/390/320 проходят без горизонтального переполнения body; сохранены 6 снимков docs/ux-ui/screens/data-collection*.png, визуально просмотрены 320 и ПК. KPI тексты переносятся, unknown сбор не помечается зелёным успехом. Кнопка «Сохранить изменения» помещается на 320, доступное имя сохранено. Удалена техническая ссылка Preview из текста NetFlow.
+- ESLint новых/изменённых файлов и production frontend/contracts build прошли; две последние правки текста дополнительно проверены 3 responsive E2E с обновлением снимков. ReUI композиция/тема сохранены, labels и async объявления есть. Это приёмка scheduler/settings/history, а не всей страницы интеграций.
+- Следующий блок 11Б.3.4: GeoIP/NetFlow формы — known/error/dirty/source/partial и операции. Evolution provider, alerts/releases/probe scheduling и итоговая приёмка 11Б остаются впереди. PostgreSQL, RouterOS, Portal, MaxMind download и JWT не доказаны HTTP-фикстурами.
+
## Блок 11Б.3.3.6.3 — подтверждённые ручные запуски (2026-10-03)
- Строгие scheduler run-now/refresh подключены вместо raw POST. Показаны подтверждённый запуск, skipped/not-due и partial; event/status failure отделены от результата задачи. Неверный/чужой receipt не становится успехом и не повторяет POST, сообщает неизвестность результата и необходимость обновить журнал.
diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md
index d0ce12c4..108d8a62 100644
--- a/docs/ux-ui-stage-11b.md
+++ b/docs/ux-ui-stage-11b.md
@@ -146,3 +146,11 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
- 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.7 — локальная приёмка scheduler (2026-10-03)
+
+- Проверены обычные HTTP-пути со сменой backend: 6 source E2E для позднего GET/PUT/run-now, успех и отказ старого источника не меняют новый. Source тесты вынесены в отдельную конфигурацию :3101/local; общий профиль остаётся :3100/same-origin.
+- 46 основных профильных E2E проверены: 39 прошли сразу, 7 KPI assertions требовали обновления селектора после переноса подписей; 9 history/status повторно прошли с устойчивой привязкой к установленному FramePanel. Отдельно 6 source E2E прошли. Новая структура не меняет unknown/zero значения.
+- ПК/390/320 проходят без горизонтального переполнения body; сохранены 6 снимков docs/ux-ui/screens/data-collection*.png, визуально просмотрены 320 и ПК. KPI тексты переносятся, unknown сбор не помечается зелёным успехом. Кнопка «Сохранить изменения» помещается на 320, доступное имя сохранено. Удалена техническая ссылка Preview из текста NetFlow.
+- ESLint новых/изменённых файлов и production frontend/contracts build прошли; две последние правки текста дополнительно проверены 3 responsive E2E с обновлением снимков. ReUI композиция/тема сохранены, labels и async объявления есть. Это приёмка scheduler/settings/history, а не всей страницы интеграций.
+- Следующий блок 11Б.3.4: GeoIP/NetFlow формы — known/error/dirty/source/partial и операции. Evolution provider, alerts/releases/probe scheduling и итоговая приёмка 11Б остаются впереди. PostgreSQL, RouterOS, Portal, MaxMind download и JWT не доказаны HTTP-фикстурами.
diff --git a/docs/ux-ui/screens/data-collection-1440.png b/docs/ux-ui/screens/data-collection-1440.png
new file mode 100644
index 00000000..9680ac21
Binary files /dev/null and b/docs/ux-ui/screens/data-collection-1440.png differ
diff --git a/docs/ux-ui/screens/data-collection-320.png b/docs/ux-ui/screens/data-collection-320.png
new file mode 100644
index 00000000..e91a5fd5
Binary files /dev/null and b/docs/ux-ui/screens/data-collection-320.png differ
diff --git a/docs/ux-ui/screens/data-collection-390.png b/docs/ux-ui/screens/data-collection-390.png
new file mode 100644
index 00000000..042a7a5c
Binary files /dev/null and b/docs/ux-ui/screens/data-collection-390.png differ
diff --git a/docs/ux-ui/screens/data-collection-settings-1440.png b/docs/ux-ui/screens/data-collection-settings-1440.png
new file mode 100644
index 00000000..61cf5d4c
Binary files /dev/null and b/docs/ux-ui/screens/data-collection-settings-1440.png differ
diff --git a/docs/ux-ui/screens/data-collection-settings-320.png b/docs/ux-ui/screens/data-collection-settings-320.png
new file mode 100644
index 00000000..51f5cd3d
Binary files /dev/null and b/docs/ux-ui/screens/data-collection-settings-320.png differ
diff --git a/docs/ux-ui/screens/data-collection-settings-390.png b/docs/ux-ui/screens/data-collection-settings-390.png
new file mode 100644
index 00000000..e2a1fe5a
Binary files /dev/null and b/docs/ux-ui/screens/data-collection-settings-390.png differ
diff --git a/playwright.config.ts b/playwright.config.ts
index 8c77fd60..956418e4 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
- testIgnore: ["**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
+ testIgnore: ["**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false,
workers: 1,
retries: 0,
diff --git a/playwright.data-collection-context.config.ts b/playwright.data-collection-context.config.ts
new file mode 100644
index 00000000..1498b673
--- /dev/null
+++ b/playwright.data-collection-context.config.ts
@@ -0,0 +1,5 @@
+import { defineConfig } from "@playwright/test"
+import base from "./playwright.config"
+const server = base.webServer
+if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
+export default defineConfig({ ...base, use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, testIgnore: [], testMatch: "data-collection-context.spec.ts", webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } } })
diff --git a/tests/e2e/data-collection-context.spec.ts b/tests/e2e/data-collection-context.spec.ts
new file mode 100644
index 00000000..71240f7e
--- /dev/null
+++ b/tests/e2e/data-collection-context.spec.ts
@@ -0,0 +1,35 @@
+import { test, expect, type Page } from "@playwright/test"
+import { geoip } from "./fixtures/geoip"
+const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
+const common = { enabled: true, intervalSec: 45, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
+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 }
+async function setup(page: Page) {
+ await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
+ await page.route("**/api/**", r => {
+ const path = new URL(r.request().url()).pathname
+ if (path === "/api/auth/config") return r.fulfill({ headers, json: { required: false } })
+ if (path === "/api/geoip") return r.fulfill({ headers, json: geoip })
+ if (path === "/api/scheduler/status") return r.fulfill({ headers, json: { jobs: [job] } })
+ if (path === "/api/scheduler/runs") return r.fulfill({ headers, json: { runs: [] } })
+ if (path === "/api/traffic/settings") return r.fulfill({ headers, json: { ...common, intervalSec: new URL(r.request().url()).port === "8101" ? 77 : 45 } })
+ return r.fulfill({ headers, status: 503, json: { error: "outside context fixture" } })
+ })
+}
+for (const action of ["read", "put", "run"] as const) for (const fails of [false, true]) test("Сбор данных: поздний ответ после смены backend/" + action + "/" + fails, async ({ page }) => {
+ await setup(page); let started = false, completed = false, release!: () => void; const gate = new Promise(resolve => release = resolve)
+ const path = action === "run" ? "/api/scheduler/jobs/backups/run-now" : "/api/traffic/settings"
+ await page.route("**" + path, async r => {
+ if (new URL(r.request().url()).port !== "8000" || (action === "read" ? r.request().method() !== "GET" : action === "put" ? r.request().method() !== "PUT" : false)) return r.fallback()
+ started = true; await gate
+ try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРАЯ ОШИБКА" } } : { headers, json: action === "run" ? { ok: true, result: { status: "ok", error: null, result: snapshot }, status: { jobs: [job] } } : action === "put" ? { ok: true, settings: { ...common, intervalSec: 99 } } : { ...common, intervalSec: 99 } }) } catch {} finally { completed = true }
+ })
+ try {
+ await page.goto("/data-collection")
+ if (action === "put") { const field = page.getByRole("textbox", { name: "Интервал: Трафик", exact: true }); await expect(field).toHaveValue("45"); await field.fill("99"); await page.getByRole("button", { name: "Сохранить настройки планировщика" }).click() }
+ if (action === "run") { 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"); const url = page.getByPlaceholder("http://localhost:8000"); await url.fill("http://localhost:8101"); await url.press("Enter"); await page.goto("/data-collection")
+ await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("77"); release(); await expect.poll(() => completed).toBe(true)
+ await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("77"); await expect(page.getByText(/СТАРАЯ ОШИБКА/)).toHaveCount(0); await expect(page.getByText("Сохранение подтверждено: Трафик", { exact: true })).toHaveCount(0); await expect(page.getByText("Запуск подтверждён: Бэкапы", { exact: true })).toHaveCount(0)
+ } finally { release() }
+})
diff --git a/tests/e2e/data-collection-history.spec.ts b/tests/e2e/data-collection-history.spec.ts
index 9b96998f..895c51a6 100644
--- a/tests/e2e/data-collection-history.spec.ts
+++ b/tests/e2e/data-collection-history.spec.ts
@@ -15,7 +15,7 @@ for (const malformed of [true, false]) test("Журнал: первоначал
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("—")
+ await expect(page.getByText("Журнал (в списке)", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("—")
})
test("Журнал: background error сохраняет строки и время последнего чтения", async ({ page }) => {
await setup(page); let fails = false
@@ -28,7 +28,7 @@ test("Журнал: background error сохраняет строки и врем
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")
+ await expect(page.getByText("Журнал (в списке)", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("0")
})
test("Журнал: смена среза не выдаёт прежние строки за новый фильтр", async ({ page }) => {
diff --git a/tests/e2e/data-collection-responsive.spec.ts b/tests/e2e/data-collection-responsive.spec.ts
new file mode 100644
index 00000000..572d4354
--- /dev/null
+++ b/tests/e2e/data-collection-responsive.spec.ts
@@ -0,0 +1,35 @@
+import { test, expect, type Page } from "@playwright/test"
+import { geoip } from "./fixtures/geoip"
+const common = { enabled: true, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
+const values: Record = {
+ "/api/traffic/settings": { ...common, intervalSec: 45 },
+ "/api/servers-api-ping/settings": { ...common, enabled: false, intervalSec: 120 },
+ "/api/uptime/settings": { ...common, resourcesEnabled: true, pingEnabled: false, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 },
+ "/api/internet-path/settings": common,
+ "/api/certificates/renew-settings": { enabled: false, intervalSec: 21600, renewBeforeDays: 30 },
+}
+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: [] } })
+ if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
+ if (path === "/api/traffic/flow/settings") return r.fulfill({ json: { enabled: false, collectorIp: "10.255.254.1", flowListenPort: 4739, wgListenPort: 51821, prefix: "10.255.254.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false, hubServerId: null, retentionHours: 24, topN: 200, mapServiceMinSharePct: 5, lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0, listenerBound: false, listenerAddress: null, peers: [] } })
+ if (values[path]) return r.fulfill({ json: values[path] })
+ return r.fulfill({ status: 503, json: { error: "outside settings fixture" } })
+ })
+}
+
+for (const width of [1440, 390, 320]) test("Сбор данных: адаптивность/" + width, async ({ page }) => {
+ await page.setViewportSize({ width, height: 1000 }); await setup(page)
+ await page.goto("/data-collection"); await expect(page.getByTestId("settings-traffic")).toContainText("настройки получены")
+ await expect(page.getByRole("textbox", { name: "Интервал: Трафик", exact: true })).toHaveValue("45")
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
+ await page.getByRole("button", { name: "Обновить", exact: true }).scrollIntoViewIfNeeded()
+ await page.screenshot({ path: "docs/ux-ui/screens/data-collection-" + width + ".png", fullPage: true })
+ await page.getByRole("button", { name: "Сохранить настройки планировщика" }).scrollIntoViewIfNeeded()
+ await expect(page.getByRole("textbox", { name: "Хранение трафика", exact: true })).toBeVisible()
+ await page.screenshot({ path: "docs/ux-ui/screens/data-collection-settings-" + width + ".png", fullPage: true })
+})
diff --git a/tests/e2e/data-collection-status.spec.ts b/tests/e2e/data-collection-status.spec.ts
index d2f99b93..132ce5db 100644
--- a/tests/e2e/data-collection-status.spec.ts
+++ b/tests/e2e/data-collection-status.spec.ts
@@ -15,18 +15,18 @@ async function setup(page: import("@playwright/test").Page) {
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("—")
+ for (const label of ["Включено задач", "Сейчас выполняется"]) await expect(page.getByText(label, { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("—")
})
test("Статус и история появляются до медленной зависимости uptime", async ({ page }) => {
await setup(page); let started = false, release!: () => void; const gate = new Promise(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() }
+ try { await page.goto("/data-collection"); await expect.poll(() => started).toBe(true); await expect(page.getByText("Включено задач", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).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!)
+ await expect(page.getByText("Статус планировщика не обновлён", { exact: true })).toBeVisible(); await expect(page.getByText("Включено задач", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame-panel'][1]")).toContainText("0 / 1"); await expect(page.getByText(/^Статус получен:/)).toHaveText(received!)
})