fix(geoip): сохранять черновик и показывать частичный результат обновления
This commit is contained in:
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>
|
||||
}
|
||||
@@ -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
@@ -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; внешние ограничения — журнал.
|
||||
|
||||
| Экран | ПК | Телефон |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -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 и права сетевых операций не менялись.
|
||||
|
||||
@@ -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 не проверена.
|
||||
@@ -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")
|
||||
})
|
||||
Reference in new issue
Block a user