fix(geoip): сохранять черновик и показывать частичный результат обновления

This commit is contained in:
Denozordec committed 2026-10-03 12:13:25 +07:00
1 parent f0d9f488e6
commit 57e7dcf990
6 files changed
+185 -127

No files matched your search

@@ -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 <GeoipSettingsContent key={backendUrl} backendUrl={backendUrl} />
}
function GeoipSettingsContent({ backendUrl }: { backendUrl: string }) {
const [status, setStatus] = useState<GeoipStatusDto | null>(null)
const [receivedAt, setReceivedAt] = useState<string | null>(null)
const [readError, setReadError] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
const [needsReview, setNeedsReview] = useState(false)
const [outcome, setOutcome] = useState<string | null>(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 <OpsPanel title="GeoIP-базы (GeoLite2)" description="Страны и ASN потоков определяются по локальным базам MaxMind GeoLite2 с зеркала P3TERX." contentClassName="px-5 py-4 flex flex-col gap-4" headerRight={<div className="flex items-center gap-2"><Badge variant={status?.countryLoaded ? "success" : "secondary"}>Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}</Badge><Badge variant={status?.asnLoaded ? "success" : "secondary"}>ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}</Badge></div>}>
{!can("mm:traffic:read") ? <p role="status">Нет доступа к данным GeoIP</p> : <p role="status" className="text-xs text-muted-foreground">{readBusy ? "Загрузка GeoIP…" : status ? "Статус GeoIP получен: " + date(receivedAt) : "Статус GeoIP не получен"}</p>}
{readError ? <Alert variant="warning"><AlertCircleIcon aria-hidden="true" /><AlertTitle>{status ? "GeoIP не обновлён" : "GeoIP не получен"}</AlertTitle><AlertDescription>{readError}{status ? ". Показаны последние подтверждённые данные: " + date(receivedAt) : ""}</AlertDescription></Alert> : null}
{opError ? <Alert variant="destructive"><AlertCircleIcon aria-hidden="true" /><AlertTitle>Операция GeoIP требует внимания</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert> : null}
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.</p><Button type="button" size="sm" variant="outline" disabled={!writable || busy || readBusy || status?.running} onClick={() => { setNeedsReview(false); setOpError(null) }}>Разрешить новое обновление GeoIP</Button></div> : null}
{outcome ? <p role="status" className="text-sm">{outcome}</p> : null}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3"><FormToggle ariaLabel="Автообновление GeoIP" checked={autoOn} disabled={!writable || busy} onChange={value => { markDirty(); setAutoOn(value) }} /><span className="text-sm">Автообновление{!status ? ": —" : ""}</span></div>
<FormField label="Интервал проверки (часов)" hint="От 6 до 720 часов"><Input aria-label="Интервал GeoIP в часах" className="font-mono" value={intervalHours} onChange={event => { markDirty(); setIntervalHours(event.target.value) }} inputMode="decimal" disabled={!writable || busy} /></FormField>
</div>
<p className="text-xs text-muted-foreground">Сборка Country: {date(status?.settings.countryBuildAt)} · ASN: {date(status?.settings.asnBuildAt)} · проверка: {date(status?.settings.lastCheckAt)}{status?.settings.lastError ? " · ошибка: " + status.settings.lastError : ""}</p>
<p className="text-xs text-muted-foreground">Каталог: <span className="font-mono break-all">{status?.dir ?? "—"}</span>. До загрузки базы и при отсутствии записи используется RIPEstat.</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={!writable || busy || !dirty || status?.running} onClick={() => { void save() }}>Сохранить GeoIP</Button>
<Button type="button" size="sm" variant="outline" disabled={!writable || busy || needsReview || status?.running} onClick={() => { void update() }}><DownloadIcon aria-hidden="true" className="size-4" />Обновить сейчас</Button>
<Button type="button" size="sm" variant="outline" disabled={busy || readBusy || !can("mm:traffic:read")} onClick={() => { void load() }}>Обновить статус GeoIP</Button>
</div>
<p className="text-xs text-muted-foreground">Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.</p>
</OpsPanel>
}
+3 -125
View File
@@ -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<GeoipStatusDto | null>(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 (
<OpsPanel
title="GeoIP-базы (GeoLite2)"
description="Локальные mmdb MaxMind GeoLite2 с зеркала P3TERX: страна и ASN каждого потока при ingest — мгновенно, включая IPv6, без лимитов RIPEstat."
headerRight={
<div className="flex items-center gap-2">
<Badge variant={status?.countryLoaded ? "success" : "secondary"}>
country {status?.countryLoaded ? "ok" : "нет"}
</Badge>
<Badge variant={status?.asnLoaded ? "success" : "secondary"}>
asn {status?.asnLoaded ? "ok" : "нет"}
</Badge>
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3">
<FormToggle checked={autoOn} onChange={setAutoOn} />
<span className="text-sm">Автообновление</span>
</div>
<FormField label="Интервал проверки (часов)" hint="Upstream обновляется еженедельно; минимум 6 ч">
<Input
className="font-mono"
value={intervalHours}
onChange={(e) => setIntervalHours(e.target.value)}
inputMode="numeric"
disabled={!autoOn}
/>
</FormField>
</div>
<p className="text-xs text-muted-foreground">
Сборка Country: {fmtDate(status?.settings.countryBuildAt)} · ASN: {fmtDate(status?.settings.asnBuildAt)}
{" · "}последняя проверка: {fmtDate(status?.settings.lastCheckAt)}
{status?.settings.lastError ? ` · ошибка: ${status.settings.lastError}` : ""}
</p>
<p className="text-xs text-muted-foreground">
Каталог: <span className="font-mono">{status?.dir || "storage/geoip"}</span>. До загрузки баз
и при промахе lookup страна/ASN берутся из RIPEstat, как раньше.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy || updating} onClick={() => { void handleSave() }}>
Сохранить GeoIP
</Button>
<Button size="sm" variant="outline" disabled={busy || updating} onClick={() => { void handleUpdateNow() }}>
<DownloadIcon className={updating ? "size-4 animate-spin" : "size-4"} />
{updating ? "Обновление…" : "Обновить сейчас"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.
</p>
</OpsPanel>
)
}
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() {
+1 -1
View File
@@ -226,7 +226,7 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.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; внешние ограничения — журнал.
| Экран | ПК | Телефон |
| --- | --- | --- |
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.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 и права сетевых операций не менялись.
+8
View File
@@ -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 не проверена.
+58
View File
@@ -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<void>(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")
})