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>
|
||||
|
||||
Reference in new issue
Block a user