fix(ui): проверять целостность снимка карты сети
This commit is contained in:
1 parent
66fde3fc62
commit
1215cbe8ae
6 files changed
+121
-85
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 переносятся; модельная категория/индекс имеют доступные подписи. Текст бейджей не сжимается/не переносится внутри них.
|
||||
|
||||
@@ -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/доступность и приёмка карты ещё не завершены.
|
||||
|
||||
@@ -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]}),/неизвестному/)
|
||||
})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user