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>