fix(ui): проверять целостность снимка карты сети

This commit is contained in:
Denozordec committed 2026-10-01 01:33:50 +07:00
1 parent 66fde3fc62
commit 1215cbe8ae
6 files changed
+121 -85

No files matched your search

+45 -84
View File
@@ -10,6 +10,7 @@ import {
type ServerStatus,
type ServerType,
} from "@/lib/data"
import {validateNetworkMapSnapshot,type NetworkMapServerRow} from "@/lib/network-map-response"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
@@ -78,45 +79,7 @@ import { Flag, countryName } from "@/components/flag"
// ─── Backend → frontend (как /servers) ───────────────────────────────────────
interface BackendServerRow {
id: number
name: string
host: string
port: number
useSsl: boolean
verifySsl: boolean
username: string
password: string
type: ServerType
site: string
country: string
asn: string
comment: string
enabled: boolean
lanSubnet?: string
wanUplinks?: Array<{
id: string
name: string
isp: string
iface: string
ip: string
maxDl: number
maxUl: number
}>
status: "online" | "offline" | null
latency: number | null
os: string | null
model: string | null
sessions: number
/** Снапшот последнего poll (как в разделе «Серверы») */
uptime?: string | null
cpuLoad?: number | null
freeMemory?: number | null
totalMemory?: number | null
polledAt?: string | null
createdAt: string
updatedAt: string
}
type BackendServerRow = NetworkMapServerRow
function mapBackendRowToServer(s: BackendServerRow): Server {
return {
@@ -124,9 +87,9 @@ function mapBackendRowToServer(s: BackendServerRow): Server {
name: s.name || s.host,
host: s.host,
type: s.type,
site: s.site,
site: s.site ?? "—",
country: s.country,
asn: s.asn,
asn: s.asn ?? "—",
model: s.model ?? "—",
os: s.os ?? "—",
enabled: s.enabled,
@@ -1171,6 +1134,10 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
// ════════════════════════════════════════════════════════════════════════════
export default function NetworkMapPage() {
const {mode,backendUrl}=useDataSource()
return <NetworkMapContent key={JSON.stringify([mode,backendUrl])} />
}
function NetworkMapContent() {
const { mode, backendUrl } = useDataSource()
const useLiveData = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
@@ -1196,6 +1163,10 @@ export default function NetworkMapPage() {
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
const [dataLoading, setDataLoading] = useState(false)
const [hasSnapshot,setHasSnapshot]=useState(false)
const [readAt,setReadAt]=useState<string|null>(null)
const reader=useRef({alive:true,epoch:0})
useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.epoch++}},[])
const greResolvedMap = useMemo(
() => new Map(Object.entries(greResolvedIpv4ByHost)) as ReadonlyMap<string, string>,
@@ -1204,42 +1175,29 @@ export default function NetworkMapPage() {
const loadLive = useCallback(async () => {
if (!useLiveData) return
const epoch=++reader.current.epoch
const current=()=>reader.current.alive&&reader.current.epoch===epoch
if(!current())return
setDataLoading(true)
setDataError(null)
try {
const [rows, fr, spRes] = await Promise.all([
apiFetch<BackendServerRow[]>("/api/servers"),
apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })),
apiFetch<{
probes?: Array<{
id: string
srcServerId: string
dstServerId: string
enabled?: boolean
srcInterface?: string
dstInterface?: string
lastTxAvgMbps?: number | null
lastRxAvgMbps?: number | null
lastPingRttMs?: number | null
}>
}>("/api/uptime/speed-probes").catch(() => ({ probes: [] })),
const parts=await Promise.allSettled([
apiFetch<unknown>("/api/servers"),
apiFetch<unknown>("/api/filters/gre-tunnels"),
apiFetch<unknown>("/api/uptime/speed-probes"),
])
setMapServers(rows.map(mapBackendRowToServer))
const mappedTunnels = (fr.tunnels ?? []).map(apiGreToGreTunnel)
setMapGreTunnels(mappedTunnels)
setSpeedProbes(
(spRes.probes ?? []).map((p) => ({
id: p.id,
srcServerId: String(p.srcServerId),
dstServerId: String(p.dstServerId),
enabled: p.enabled !== false,
srcInterface: p.srcInterface || undefined,
dstInterface: p.dstInterface || undefined,
lastTxAvgMbps: p.lastTxAvgMbps,
lastRxAvgMbps: p.lastRxAvgMbps,
lastPingRttMs: p.lastPingRttMs,
})),
)
if(!current())return
const labels=["Устройства","Каталог GRE","Измерения"]
const failed=parts.flatMap((part,index)=>part.status==="rejected"?[labels[index]+": "+(part.reason instanceof Error?part.reason.message:"ошибка API")]:[])
if(failed.length)throw new Error(failed.join("; "))
let snapshot:ReturnType<typeof validateNetworkMapSnapshot>
try {snapshot=validateNetworkMapSnapshot(...parts.map(part=>part.status==="fulfilled"?part.value:null) as [unknown,unknown,unknown])}
catch(error){throw new Error("Недостоверный снимок карты: "+(error instanceof Error?error.message:"ошибка данных"))}
const rows=snapshot.servers
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
if(!current())return
setMapServers(rows.map(mapBackendRowToServer));setMapGreTunnels(mappedTunnels)
setSpeedProbes(snapshot.probes.map(probe=>({...probe,srcInterface:probe.srcInterface||undefined,dstInterface:probe.dstInterface||undefined})))
setHasSnapshot(true);setReadAt(new Date().toISOString());setDataError(null)
const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels))
for (const row of rows) {
if (!Array.isArray(row.wanUplinks)) continue
@@ -1262,17 +1220,19 @@ export default function NetworkMapPage() {
body: JSON.stringify({ hosts: hostnames }),
},
)
if(!current())return
setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results)))
} catch {
if(!current())return
setGreResolvedIpv4ByHost({})
}
} else {
setGreResolvedIpv4ByHost({})
}
} catch (e) {
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
if(current())setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
} finally {
setDataLoading(false)
if(current())setDataLoading(false)
}
}, [apiFetch, useLiveData])
@@ -2058,13 +2018,13 @@ export default function NetworkMapPage() {
// ═════════════════════════════════════════════════════════════════════════
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full min-h-0 min-w-0">
<PageHeader
crumbs={[{ label: "Обзор" }, { label: "Карта сети" }]}
actions={
<>
{useLiveData && (
<Button variant="outline" size="sm" onClick={() => void loadLive()} disabled={dataLoading}>
<Button type="button" variant="outline" size="sm" onClick={() => void loadLive()} disabled={dataLoading}>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
@@ -2203,21 +2163,22 @@ export default function NetworkMapPage() {
{/* Stats */}
<div className="ml-auto flex items-center gap-4 text-xs text-muted-foreground flex-wrap justify-end">
<span className="text-[10px] border border-border rounded px-1.5 py-0.5">
{useLiveData ? "Живые данные · API" : "Демо · mock"}
{useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"}
</span>
{dataLoading && <span role="status">Обновление снимка карты…</span>}
{dataError && (
<span className="text-[10px] text-destructive max-w-[220px] truncate" title={dataError}>
{dataError}
<span role="alert" className="text-[10px] text-destructive break-all">
{dataError}. {hasSnapshot ? "Последний полный снимок; актуальность не подтверждена." : "Первоначальная загрузка карты не подтверждена."}
</span>
)}
<span>
<span className="font-semibold text-emerald-400">{mapServers.filter(s => s.status === "online").length}</span> онлайн
<span className="font-semibold text-emerald-400">{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "online").length}</span> онлайн
</span>
<span>
<span className="font-semibold text-amber-400">{mapServers.filter(s => s.status === "degraded").length}</span> с проблемами
<span className="font-semibold text-amber-400">{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "degraded").length}</span> с проблемами
</span>
<span>
<span className="font-semibold text-red-400">{mapServers.filter(s => s.status === "offline").length}</span> оффлайн
<span className="font-semibold text-red-400">{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "offline").length}</span> оффлайн
</span>
<span title="Линии на карте / всего туннелей в данных">
<span className="font-semibold text-cyan-400">{greEdges.length}</span>
+8 -1
View File
@@ -26,11 +26,18 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.44; оптимизатор принят локально, далее карта сети/терминал |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.45; оптимизатор принят локально, карта сети в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.45 — согласованный снимок карты сети (2026-10-01)
- Servers/GRE/probes публикуются вместе после строгой проверки схем, уникальности и владельцев. Частичный каталог GRE и неизвестные ссылки отклоняются; первоначальный отказ не становится пустым успешным ответом. При фоновом сбое сохраняется последний полный снимок с явным предупреждением.
- Время ответа браузера отделено от измерений. Новый источник пересоздаёт контекст; epoch/alive запрещают позднему ответу и DNS изменять новую карту.
- 2 unit, 9 HTTP E2E и TypeScript прошли. ESLint карты содержит 5 исходных ошибок и 1 warning; сравнение с HEAD подтвердило отсутствие новых. Их исправление включено в дальнейшую приёмку карты. Next dev имеет собственный пустой alert, поэтому тест восстановления проверяет именно сообщение карты.
- Далее неизвестный статус, ресурсы и GRE без синтетических live значений; Flow/DNS, URL/доступность и приёмка карты ещё не завершены.
## Блок 10.44 — локальная приёмка оптимизатора (2026-10-01)
- Схема расчёта использует общий WAN-аплинк, без утверждения о существующих WAN1/WAN2. Длинные имена/метаданные и badges переносятся; модельная категория/индекс имеют доступные подписи. Текст бейджей не сжимается/не переносится внутри них.
+8
View File
@@ -228,3 +228,11 @@ Shared контракт и backend observed исходные значения/д
## 10.44. Локальная приёмка оптимизатора
47 разных основных + 8 source E2E, backend optimizer/live-maps, production frontend/backend прошли; девять финальных снимков, ReUI audit/TS/lint. Исходные метрики отдельно от модели, full receipts/URL/source, без mock WAN/foreign Community. Внешние RouterOS/DB/JWT ограничения в журнале. Далее карта сети и терминал, этап 10 ещё открыт.
## Блок 10.45 — согласованный снимок карты сети (2026-10-01)
- Servers/GRE/probes публикуются вместе после строгой проверки схем, уникальности и владельцев. Частичный каталог GRE и неизвестные ссылки отклоняются; первоначальный отказ не становится пустым успешным ответом. При фоновом сбое сохраняется последний полный снимок с явным предупреждением.
- Время ответа браузера отделено от измерений. Новый источник пересоздаёт контекст; epoch/alive запрещают позднему ответу и DNS изменять новую карту.
- 2 unit, 9 HTTP E2E и TypeScript прошли. ESLint карты содержит 5 исходных ошибок и 1 warning; сравнение с HEAD подтвердило отсутствие новых. Их исправление включено в дальнейшую приёмку карты. Next dev имеет собственный пустой alert, поэтому тест восстановления проверяет именно сообщение карты.
- Далее неизвестный статус, ресурсы и GRE без синтетических live значений; Flow/DNS, URL/доступность и приёмка карты ещё не завершены.
+15
View File
@@ -0,0 +1,15 @@
import {test} from "node:test"
import assert from "node:assert/strict"
import {validateNetworkMapSnapshot} from "./network-map-response"
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0}
test("network map requires complete envelopes and preserves zero/null",()=>{
const result=validateNetworkMapSnapshot([server],{tunnels:[],failures:[]},{probes:[]})
assert.equal(result.servers[0].latency,0);assert.equal(result.servers[0].status,null)
for(const args of [[null,{tunnels:[]},{probes:[]}],[[server],{}, {probes:[]}],[[server],{tunnels:[]},{}],[[server,server],{tunnels:[]},{probes:[]}],[[{...server,freeMemory:20,totalMemory:10}],{tunnels:[]},{probes:[]}],[[{...server,status:undefined}],{tunnels:[]},{probes:[]}]])assert.throws(()=>validateNetworkMapSnapshot(...args as [unknown,unknown,unknown]))
})
test("network map refuses partial GRE and foreign failure/probe contexts",()=>{
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[],failures:[{serverId:"1",error:"offline"}]},{probes:[]}),/Неполный/)
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[],failures:[{serverId:"2",error:"offline"}]},{probes:[]}),/неизвестному/)
const probe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",enabled:true,lastTxAvgMbps:null,lastRxAvgMbps:null,lastPingRttMs:null,lastPingLossPct:null,lastPingAt:null}
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[]},{probes:[probe]}),/неизвестному/)
})
+20
View File
@@ -0,0 +1,20 @@
import {z} from "zod"
import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response"
import {filtersGreResponseSchema} from "./filters-response"
import {greResponseSchema} from "./gre-response"
export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({
status:z.enum(["online","offline","degraded"]).nullable(),
asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(),
sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(),
cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(),
polledAt:z.string().datetime({offset:true}).nullable().optional(),
})).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory))
export type NetworkMapServerRow=z.infer<typeof networkMapServersSchema>[number]
const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId)))
export function validateNetworkMapSnapshot(servers:unknown,gre:unknown,probes:unknown){
const inventory=networkMapServersSchema.parse(servers),tunnels=mapGreSchema.parse(gre),measurements=optimizerProbesSchema.parse(probes)
const ids=new Set(inventory.map(row=>String(row.id)))
if(tunnels.tunnels.some(tunnel=>!ids.has(tunnel.serverId))||tunnels.failures.some(failure=>!ids.has(failure.serverId))||measurements.probes.some(probe=>!ids.has(probe.srcServerId)||!ids.has(probe.dstServerId)))throw new Error("Данные карты относятся к неизвестному устройству")
if(tunnels.failures.length)throw new Error("Неполный каталог GRE: "+tunnels.failures.map(failure=>failure.serverName||failure.serverId).join(", "))
return {servers:inventory,tunnels:tunnels.tunnels,probes:measurements.probes}
}
+25
View File
@@ -0,0 +1,25 @@
import {test,expect} from "@playwright/test"
const paths={servers:"/api/servers",gre:"/api/filters/gre-tunnels",probes:"/api/uptime/speed-probes"} as const
const body=(path:string,name="Роутер")=>path===paths.servers?[{id:1,name,host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:0}]:path===paths.gre?{tunnels:[],failures:[]}:path===paths.probes?{probes:[]}:path==="/api/auth/config"?{required:false}:{}
for(const part of ["servers","gre","probes"] as const)for(const malformed of [false,true])test("Карта: первоначальный отказ и повтор "+part+"/"+malformed,async({page})=>{
let failing=true
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(failing&&p===paths[part]?(malformed?{json:{}}:{status:403,json:{error:"Нет доступа к данным"}}):{json:body(p)})})
await page.goto("/network-map")
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка"})).toBeVisible()
await expect(page.getByText("Данные карты неизвестны",{exact:true})).toBeVisible()
failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible()
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка"})).toHaveCount(0)
})
for(const part of ["servers","gre","probes"] as const)test("Карта: частичный отказ сохраняет снимок "+part,async({page})=>{
let failing=false
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(failing&&p===paths[part]?{status:503,json:{error:"Источник недоступен"}}:{json:body(p,failing?"Новый роутер":"Последний роутер")})})
await page.goto("/network-map")
await expect(page.getByText("Последний роутер",{exact:true}).first()).toBeVisible()
failing=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByRole("alert").filter({hasText:"Последний полный снимок"})).toBeVisible()
await expect(page.getByText("Последний роутер",{exact:true}).first()).toBeVisible()
await expect(page.getByText("Новый роутер",{exact:true})).toHaveCount(0)
})