-
- )
-}
-
function NetflowSettingsPanel({
backendUrl,
enabled,
@@ -194,9 +72,9 @@ function NetflowSettingsPanel({
}, [backendUrl, enabled])
useEffect(() => {
- void load().catch((e: unknown) => {
+ queueMicrotask(() => { void load().catch((e: unknown) => {
toast.error(e instanceof Error ? e.message : "Не удалось загрузить NetFlow")
- })
+ }) })
}, [load])
async function handleSave() {
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index f27992f2..1340e8ab 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.4.2 — GeoIP форма и partial outcome; затем NetFlow. GeoIP RBAC исправлен в 11Б.3.4.1 (traffic read/write вместо catch-all dashboard read). 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; внешние ограничения — журнал.
+Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.4.3 — GeoIP source/визуальная приёмка; затем strict NetFlow API/форма. GeoIP known/dirty/partial/unknown guard приняты в 11Б.3.4.2. GeoIP RBAC исправлен в 11Б.3.4.1 (traffic read/write вместо catch-all dashboard read). 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 8963e49e..4eab9521 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.4.1; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
+| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
+## Блок 11Б.3.4.2 — достоверная форма GeoIP (2026-10-03)
+
+- GeoIP вынесен из NetFlow в отдельный раздел с backend key/remount, abort/revision и active guards. Независимые read/operation ошибки, browser receivedAt и последние данные сохраняются. Unknown не выдаётся за отсутствующие базы; формы/действия до подтверждения или без traffic write отключены.
+- Dirty сохраняется при GET и после ручного обновления; PUT принимает фактический интервал с точностью до секунды без rounding/default/clamp, receipt очищает dirty только после подтверждения. Синхронный lock блокирует PUT/POST/refresh во время операции и инвалидирует pending GET.
+- Частичный update показывает скачанное/без изменений/bytes вместе с ошибками, не сообщает полного успеха. Skipped отдельно. Неизвестный POST не повторяется; новое обновление требует явного действия после проверки журнала/баз. Guard живёт в текущем разделе, не является durable backend идемпотентностью.
+- Прошли 10 новых GeoIP E2E, 2 ранее существовавших malformed tests, ESLint и production frontend/contracts build. После добавления explicit unknown guard профильный POST тест повторно прошёл. Проверены fractional interval, invalid/no HTTP, false/unknown, partial, dirty, lock и reader claims; signed JWT gate принят в 11Б.3.4.1.
+- NetFlow сохраняет собственные известные ограничения; его synchronous-effect lint исправлен очередью microtask, не считать это защитой от поздних ответов. Далее 11Б.3.4.3 — GeoIP source/визуальная приёмка, затем strict NetFlow API/форма. Реальная загрузка mmdb/DB/Portal не проверена.
+
## Блок 11Б.3.4.1 — права GeoIP (2026-10-03)
- Выявлен и устранён fallback GeoIP в mm:dashboard:read: GET /api/geoip требует mm:traffic:read, PUT /api/geoip и POST /api/geoip/update — mm:traffic:write, как соседние NetFlow настройки. Catch-all и права сетевых операций не менялись.
diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md
index 8f7d57b3..5d4a6a46 100644
--- a/docs/ux-ui-stage-11b.md
+++ b/docs/ux-ui-stage-11b.md
@@ -161,3 +161,11 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
- Это целевое ужесточение: пользователя с одним dashboard:read больше недостаточно для чтения/изменения GeoIP. Traffic write/admin наследуют read; settings:admin не даёт произвольных traffic прав по существующей модели. AUTH_REQUIRED=false сохраняет локальный режим.
- Подписанные JWT/Fastify inject проверяют GET/PUT/POST с пятью наборами прав: reader не вызывает mutation handler, traffic writer/admin допускаются, без JWT 401. Первоначальное ожидание глобальных прав settings:admin исправлено по реальному hasPermission; повтор прошёл. Проверка добавлена в backend test:geoip.
- Backend permissions unit, JWT gate, ESLint и backend build прошли. Нет БД/скачивания MaxMind/производственного Portal в этих тестах. UI ещё использует старую форму; далее 11Б.3.4.2 — known/error/dirty/права и partial outcome GeoIP.
+
+## Блок 11Б.3.4.2 — достоверная форма GeoIP (2026-10-03)
+
+- GeoIP вынесен из NetFlow в отдельный раздел с backend key/remount, abort/revision и active guards. Независимые read/operation ошибки, browser receivedAt и последние данные сохраняются. Unknown не выдаётся за отсутствующие базы; формы/действия до подтверждения или без traffic write отключены.
+- Dirty сохраняется при GET и после ручного обновления; PUT принимает фактический интервал с точностью до секунды без rounding/default/clamp, receipt очищает dirty только после подтверждения. Синхронный lock блокирует PUT/POST/refresh во время операции и инвалидирует pending GET.
+- Частичный update показывает скачанное/без изменений/bytes вместе с ошибками, не сообщает полного успеха. Skipped отдельно. Неизвестный POST не повторяется; новое обновление требует явного действия после проверки журнала/баз. Guard живёт в текущем разделе, не является durable backend идемпотентностью.
+- Прошли 10 новых GeoIP E2E, 2 ранее существовавших malformed tests, ESLint и production frontend/contracts build. После добавления explicit unknown guard профильный POST тест повторно прошёл. Проверены fractional interval, invalid/no HTTP, false/unknown, partial, dirty, lock и reader claims; signed JWT gate принят в 11Б.3.4.1.
+- NetFlow сохраняет собственные известные ограничения; его synchronous-effect lint исправлен очередью microtask, не считать это защитой от поздних ответов. Далее 11Б.3.4.3 — GeoIP source/визуальная приёмка, затем strict NetFlow API/форма. Реальная загрузка mmdb/DB/Portal не проверена.
diff --git a/tests/e2e/geoip-settings.spec.ts b/tests/e2e/geoip-settings.spec.ts
new file mode 100644
index 00000000..35748093
--- /dev/null
+++ b/tests/e2e/geoip-settings.spec.ts
@@ -0,0 +1,58 @@
+import { test, expect, type Page } from "@playwright/test"
+import { geoip } from "./fixtures/geoip"
+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: [] } })
+ return r.fulfill({ status: 503, json: { error: "outside GeoIP fixture" } })
+ })
+}
+test("GeoIP: unknown блокирует действия и не выдаётся за пустую базу", async ({ page }) => {
+ await setup(page); await page.route("**/api/geoip", r => r.fulfill({ json: {} })); await page.goto("/data-collection")
+ await expect(page.getByText("GeoIP не получен", { exact: true })).toBeVisible(); await expect(page.getByText("Country: —", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Сохранить GeoIP", exact: true })).toBeDisabled(); await expect(page.getByRole("button", { name: "Обновить сейчас", exact: true })).toBeDisabled()
+})
+test("GeoIP: сохранение проверяет receipt и отправляет фактический интервал", async ({ page }) => {
+ await setup(page); const writes: object[] = []
+ await page.route("**/api/geoip", r => { if (r.request().method() !== "PUT") return r.fallback(); const patch = r.request().postDataJSON(); writes.push(patch); return r.fulfill({ json: { ok: true, status: { ...geoip, settings: { ...geoip.settings, ...patch } } } }) })
+ await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true }); await expect(field).toHaveValue("168"); await field.fill("168.5"); await page.getByRole("button", { name: "Сохранить GeoIP", exact: true }).click()
+ await expect(page.getByText("Сохранение настроек GeoIP подтверждено", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Сохранить GeoIP", exact: true })).toBeDisabled(); expect(writes).toEqual([{ enabled: false, updateIntervalSec: 606600 }])
+})
+test("GeoIP: некорректный ввод не заменяется defaults", async ({ page }) => {
+ await setup(page); let writes = 0; page.on("request", r => { if (r.method() === "PUT") writes++ }); await page.goto("/data-collection")
+ const field = page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true }); await expect(field).toHaveValue("168"); await field.fill("invalid"); await page.getByRole("button", { name: "Сохранить GeoIP", exact: true }).click(); await expect(page.getByText("Укажите интервал от 6 до 720 часов с точностью до секунды", { exact: true })).toBeVisible(); expect(writes).toBe(0)
+})
+test("GeoIP: фоновая ошибка сохраняет известные данные и время", async ({ page }) => {
+ await setup(page); let fails = false; await page.route("**/api/geoip", r => r.fulfill(fails ? { status: 503, json: { error: "GeoIP unavailable" } } : { json: geoip })); await page.goto("/data-collection")
+ const stamp = page.getByRole("status").filter({ hasText: /^Статус GeoIP получен:/ }); await expect(stamp).toBeVisible(); const before = await stamp.textContent(); fails = true; await page.getByRole("button", { name: "Обновить статус GeoIP", exact: true }).click(); await expect(page.getByText("GeoIP не обновлён", { exact: true })).toBeVisible(); await expect(stamp).toHaveText(before!); await expect(page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true })).toHaveValue("168")
+})
+test("GeoIP: partial update сохраняет скачанные данные, ошибку и dirty", async ({ page }) => {
+ await setup(page); let posts = 0; await page.route("**/api/geoip/update", r => { posts++; return r.fulfill({ json: { ok: false, snapshot: { v: 1, job: "geoip_update", sampledAt: "2026-10-03T00:00:00Z", checked: 2, downloaded: 1, skippedUnchanged: 0, bytes: 1024, errors: ["ASN unavailable"] } } }) }); await page.goto("/data-collection")
+ const field = page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true }); await expect(field).toHaveValue("168"); await field.fill("180"); await page.getByRole("button", { name: "Обновить сейчас", exact: true }).click(); await expect(page.getByRole("status").filter({ hasText: "Обновление GeoIP завершилось частично или с ошибками" })).toContainText("скачано: 1"); await expect(page.getByText("ASN unavailable", { exact: true })).toBeVisible(); await expect(field).toHaveValue("180"); expect(posts).toBe(1)
+})
+test("GeoIP: malformed POST не вызывает успех или повтор", async ({ page }) => {
+ await setup(page); let posts = 0; await page.route("**/api/geoip/update", r => { posts++; return r.fulfill({ json: {} }) }); 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(); expect(posts).toBe(1); await expect(run).toBeDisabled(); await expect(page.getByRole("status").filter({ hasText: "Обновление GeoIP подтверждено" })).toHaveCount(0)
+ await page.getByRole("button", { name: "Разрешить новое обновление GeoIP", exact: true }).click(); await expect(run).toBeEnabled(); expect(posts).toBe(1)
+})
+test("GeoIP: read-only claims не разрешают PUT и POST", async ({ page }) => {
+ const token = "header." + Buffer.from(JSON.stringify({ sub: "fixture", apps: ["mm"], permissions: ["mm:dashboard:read", "mm:traffic:read"], exp: Math.floor(Date.now()/1000)+3600 })).toString("base64url") + ".fixture"
+ await page.addInitScript(value => localStorage.setItem("mmapp_token", value), token); await setup(page); await page.route("**/api/auth/config", r => r.fulfill({ json: { required: true } })); await page.goto("/data-collection"); await expect(page.getByRole("status").filter({ hasText: /^Статус GeoIP получен:/ })).toBeVisible(); await expect(page.getByRole("switch", { name: "Автообновление GeoIP", exact: true })).toBeDisabled(); await expect(page.getByRole("button", { name: "Обновить сейчас", exact: true })).toBeDisabled()
+})
+
+test("GeoIP: неизвестный PUT сохраняет черновик без повторов", async ({ page }) => {
+ await setup(page); let writes = 0; await page.route("**/api/geoip", r => { if (r.request().method() !== "PUT") return r.fallback(); writes++; return r.fulfill({ json: {} }) }); await page.goto("/data-collection")
+ const field = page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true }); await expect(field).toHaveValue("168"); await field.fill("180"); await page.getByRole("button", { name: "Сохранить GeoIP", exact: true }).click(); await expect(page.getByText("Данные GeoIP не подтверждены: получен некорректный ответ сервера", { exact: true })).toBeVisible(); await expect(field).toHaveValue("180"); await expect(page.getByRole("button", { name: "Сохранить GeoIP", exact: true })).toBeEnabled(); expect(writes).toBe(1)
+})
+test("GeoIP: lock защищает сохранение и обновление", async ({ page }) => {
+ await setup(page); let writes = 0, release!: () => void; const gate = new Promise(resolve => release = resolve)
+ await page.route("**/api/geoip", async r => { if (r.request().method() !== "PUT") return r.fallback(); writes++; await gate; await r.fulfill({ json: { ok: true, status: { ...geoip, settings: { ...geoip.settings, ...r.request().postDataJSON() } } } }) })
+ try {
+ await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true }); await expect(field).toHaveValue("168"); await field.fill("180"); const save = page.getByRole("button", { name: "Сохранить GeoIP", exact: true }); await save.click(); await expect.poll(() => writes).toBe(1); await expect(save).toBeDisabled(); await expect(page.getByRole("button", { name: "Обновить сейчас", exact: true })).toBeDisabled(); release(); await expect(page.getByText("Сохранение настроек GeoIP подтверждено", { exact: true })).toBeVisible(); expect(writes).toBe(1)
+ } finally { release() }
+})
+test("GeoIP: обновление статуса не затирает draft", async ({ page }) => {
+ await setup(page); await page.goto("/data-collection"); const field = page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true }); await expect(field).toHaveValue("168"); await field.fill("180"); await page.getByRole("button", { name: "Обновить статус GeoIP", exact: true }).click(); await expect(page.getByRole("button", { name: "Обновить статус GeoIP", exact: true })).toBeEnabled(); await expect(field).toHaveValue("180")
+})