From 57e7dcf990fa9d53389de0f0edb7e777b5901eed Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 12:13:25 +0700 Subject: [PATCH] =?UTF-8?q?fix(geoip):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=B8=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B0=D1=82=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/traffic/geoip-settings-section.tsx | 106 +++++++++++++++ components/traffic/netflow-settings-panel.tsx | 128 +----------------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-11b.md | 8 ++ tests/e2e/geoip-settings.spec.ts | 58 ++++++++ 6 files changed, 185 insertions(+), 127 deletions(-) create mode 100644 components/traffic/geoip-settings-section.tsx create mode 100644 tests/e2e/geoip-settings.spec.ts diff --git a/components/traffic/geoip-settings-section.tsx b/components/traffic/geoip-settings-section.tsx new file mode 100644 index 00000000..d0c27f4f --- /dev/null +++ b/components/traffic/geoip-settings-section.tsx @@ -0,0 +1,106 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import type { GeoipStatusDto } from "@mmapp/contracts/geoip" +import { can } from "@/lib/auth" +import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip" +import { OpsPanel } from "@/components/ops-panel" +import { FormField, FormToggle } from "@/components/form-kit" +import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" +import { Badge } from "@/components/reui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { AlertCircleIcon, DownloadIcon } from "lucide-react" + +const message = (error: unknown) => error instanceof Error ? error.message : "Не удалось выполнить операцию GeoIP" +const date = (value: string | null | undefined) => value ? new Date(value).toLocaleString("ru-RU") : "—" +export function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) { + return +} +function GeoipSettingsContent({ backendUrl }: { backendUrl: string }) { + const [status, setStatus] = useState(null) + const [receivedAt, setReceivedAt] = useState(null) + const [readError, setReadError] = useState(null) + const [opError, setOpError] = useState(null) + const [needsReview, setNeedsReview] = useState(false) + const [outcome, setOutcome] = useState(null) + const [readBusy, setReadBusy] = useState(false) + const [busy, setBusy] = useState(false) + const [autoOn, setAutoOn] = useState(false) + const [intervalHours, setIntervalHours] = useState("") + const [dirty, setDirty] = useState(false) + const dirtyRef = useRef(false) + const lock = useRef(false) + const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 }) + useEffect(() => { + const state = reader.current; state.active = true + return () => { state.active = false; state.revision++; state.controller?.abort() } + }, []) + const load = useCallback(async () => { + if (!reader.current.active || lock.current || !can("mm:traffic:read")) return + const revision = ++reader.current.revision; reader.current.controller?.abort() + const controller = new AbortController(); reader.current.controller = controller + const current = () => reader.current.active && reader.current.revision === revision && !controller.signal.aborted + setReadBusy(true) + try { + const data = await getGeoipStatus(backendUrl, { signal: controller.signal }) + if (!current()) return + setStatus(data); setReadError(null); setReceivedAt(new Date().toISOString()) + if (!dirtyRef.current) { setAutoOn(data.settings.enabled); setIntervalHours(String(data.settings.updateIntervalSec / 3600)) } + } catch (error) { if (current()) setReadError(message(error)) } + finally { if (current()) setReadBusy(false) } + }, [backendUrl]) + useEffect(() => { queueMicrotask(() => { void load() }) }, [load]) + const writable = can("mm:traffic:write") && Boolean(status) && !readError + function begin() { + if (!reader.current.active || lock.current || !writable || status?.running) return false + lock.current = true; reader.current.revision++; reader.current.controller?.abort(); setReadBusy(false); setBusy(true); setOpError(null); setOutcome(null) + return true + } + function finish() { lock.current = false; if (reader.current.active) setBusy(false) } + async function save() { + const hours = Number(intervalHours) + if (!/^(?:\d+)(?:\.\d+)?$/.test(intervalHours) || !Number.isSafeInteger(hours * 3600) || hours < 6 || hours > 720) { setOpError("Укажите интервал от 6 до 720 часов с точностью до секунды"); return } + if (!dirty || !begin()) return + try { + const data = await putGeoipSettings(backendUrl, { enabled: autoOn, updateIntervalSec: hours * 3600 }) + if (!reader.current.active) return + setStatus(data.status); setReceivedAt(new Date().toISOString()); dirtyRef.current = false; setDirty(false) + setOutcome("Сохранение настроек GeoIP подтверждено") + } catch (error) { if (reader.current.active) setOpError(message(error)) } + finally { finish() } + } + async function update() { + if (needsReview || !begin()) return + try { + const data = await runGeoipUpdateNow(backendUrl) + if (!reader.current.active) return + const snap = data.snapshot + setOutcome(snap.skipped ? "Прогон GeoIP пропущен" : data.ok ? "Обновление GeoIP подтверждено" : "Обновление GeoIP завершилось частично или с ошибками") + if (snap.errors.length || snap.fatalError) setOpError([snap.fatalError, ...snap.errors].filter(Boolean).join("; ")) + setOutcome(prev => prev + `. Проверено: ${snap.checked}; скачано: ${snap.downloaded}; без изменений: ${snap.skippedUnchanged}; байт: ${snap.bytes}.`) + } catch (error) { if (reader.current.active) { setNeedsReview(true); setOpError(message(error) + ". Результат операции неизвестен; обновите статус перед повтором.") } } + finally { finish() } + if (reader.current.active) await load() + } + const markDirty = () => { dirtyRef.current = true; setDirty(true) } + return Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}}> + {!can("mm:traffic:read") ?

Нет доступа к данным GeoIP

:

{readBusy ? "Загрузка GeoIP…" : status ? "Статус GeoIP получен: " + date(receivedAt) : "Статус GeoIP не получен"}

} + {readError ? : null} + {opError ? : null} + {needsReview ?

Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.

: null} + {outcome ?

{outcome}

: null} +
+
{ markDirty(); setAutoOn(value) }} />Автообновление{!status ? ": —" : ""}
+ { markDirty(); setIntervalHours(event.target.value) }} inputMode="decimal" disabled={!writable || busy} /> +
+

Сборка Country: {date(status?.settings.countryBuildAt)} · ASN: {date(status?.settings.asnBuildAt)} · проверка: {date(status?.settings.lastCheckAt)}{status?.settings.lastError ? " · ошибка: " + status.settings.lastError : ""}

+

Каталог: {status?.dir ?? "—"}. До загрузки базы и при отсутствии записи используется RIPEstat.

+
+ + + +
+

Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.

+
+} diff --git a/components/traffic/netflow-settings-panel.tsx b/components/traffic/netflow-settings-panel.tsx index e994967e..9dca85df 100644 --- a/components/traffic/netflow-settings-panel.tsx +++ b/components/traffic/netflow-settings-panel.tsx @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet" import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow" -import type { GeoipStatusDto } from "@mmapp/contracts/geoip" +import { GeoipSettingsSection } from "./geoip-settings-section" import { generateTrafficFlowKeys, getTrafficFlowHostFiles, @@ -18,7 +18,6 @@ import { purgeTrafficFlowData, putTrafficFlowSettings, } from "@/shared/api/traffic-flow" -import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip" import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog" import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react" @@ -31,127 +30,6 @@ const HOST_STEPS = [ "Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.", ] -function fmtDate(iso: string | null | undefined): string { - return iso ? new Date(iso).toLocaleString("ru-RU") : "—" -} - -function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) { - const [status, setStatus] = useState(null) - const [busy, setBusy] = useState(false) - const [updating, setUpdating] = useState(false) - const [autoOn, setAutoOn] = useState(true) - const [intervalHours, setIntervalHours] = useState("168") - - const load = useCallback(async () => { - const s = await getGeoipStatus(backendUrl) - setStatus(s) - setAutoOn(s.settings.enabled) - setIntervalHours(String(Math.round(s.settings.updateIntervalSec / 3600))) - }, [backendUrl]) - - useEffect(() => { - void load().catch((e: unknown) => { - toast.error(e instanceof Error ? e.message : "Не удалось загрузить GeoIP") - }) - }, [load]) - - async function handleSave() { - setBusy(true) - try { - const hours = Math.min(720, Math.max(6, Number.parseInt(intervalHours, 10) || 168)) - const res = await putGeoipSettings(backendUrl, { - enabled: autoOn, - updateIntervalSec: hours * 3600, - }) - setStatus(res.status) - toast.success("Настройки GeoIP сохранены") - } catch (e) { - toast.error(e instanceof Error ? e.message : "Не удалось сохранить") - } finally { - setBusy(false) - } - } - - async function handleUpdateNow() { - setUpdating(true) - try { - const res = await runGeoipUpdateNow(backendUrl) - if (res.ok || res.snapshot.downloaded > 0 || res.snapshot.skippedUnchanged > 0) { - toast.success( - res.snapshot.downloaded > 0 - ? `Скачано баз: ${res.snapshot.downloaded} (${(res.snapshot.bytes / 1024 / 1024).toFixed(1)} МБ)` - : "Базы актуальны, скачивание не требуется", - ) - } else { - toast.error(res.snapshot.errors.join("; ") || "Обновление не выполнено") - } - await load() - } catch (e) { - toast.error(e instanceof Error ? e.message : "Не удалось обновить базы") - } finally { - setUpdating(false) - } - } - - return ( - - - country {status?.countryLoaded ? "ok" : "нет"} - - - asn {status?.asnLoaded ? "ok" : "нет"} - - - } - contentClassName="px-5 py-4 flex flex-col gap-4" - > -
-
- - Автообновление -
- - setIntervalHours(e.target.value)} - inputMode="numeric" - disabled={!autoOn} - /> - -
- -

- Сборка Country: {fmtDate(status?.settings.countryBuildAt)} · ASN: {fmtDate(status?.settings.asnBuildAt)} - {" · "}последняя проверка: {fmtDate(status?.settings.lastCheckAt)} - {status?.settings.lastError ? ` · ошибка: ${status.settings.lastError}` : ""} -

-

- Каталог: {status?.dir || "storage/geoip"}. До загрузки баз - и при промахе lookup страна/ASN берутся из RIPEstat, как раньше. -

- -
- - -
- -

- Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb. -

-
- ) -} - 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") +})