fix(ui): показывать ошибки и свежесть NetFlow карты
This commit is contained in:
1 parent
e47e2b4029
commit
1bce4e1ea2
7 files changed
+139
-45
No files matched your search
@@ -10,7 +10,7 @@ import {
|
||||
type ServerStatus,
|
||||
type ServerType,
|
||||
} from "@/lib/data"
|
||||
import {validateNetworkMapSnapshot,type NetworkMapServerRow} from "@/lib/network-map-response"
|
||||
import {validateNetworkMapSnapshot,networkMapFlowSchema,validateNetworkMapDns,type NetworkMapServerRow} from "@/lib/network-map-response"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
matchNetflowForWan,
|
||||
type MatchedNetflowHop,
|
||||
} from "@/lib/map-netflow-hops"
|
||||
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
|
||||
import { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -1165,6 +1165,12 @@ function NetworkMapContent() {
|
||||
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
|
||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||
const [flowError,setFlowError]=useState<string|null>(null)
|
||||
const [flowReadAt,setFlowReadAt]=useState<string|null>(null)
|
||||
const [flowListener,setFlowListener]=useState(false)
|
||||
const [flowLoading,setFlowLoading]=useState(false)
|
||||
const [flowRefresh,setFlowRefresh]=useState(0)
|
||||
const [dnsError,setDnsError]=useState<string|null>(null)
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [hasSnapshot,setHasSnapshot]=useState(false)
|
||||
@@ -1195,7 +1201,7 @@ function NetworkMapContent() {
|
||||
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:"ошибка данных"))}
|
||||
catch(error){throw new Error("Недостоверный снимок карты: "+(error instanceof Error?(error.name==="ZodError"?"неполные или некорректные поля API":error.message):"ошибка данных"))}
|
||||
const rows=snapshot.servers
|
||||
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
|
||||
if(!current())return
|
||||
@@ -1214,9 +1220,10 @@ function NetworkMapContent() {
|
||||
}
|
||||
}
|
||||
const hostnames = [...hostSet]
|
||||
setGreResolvedIpv4ByHost({});setDnsError(null)
|
||||
if (hostnames.length > 0) {
|
||||
try {
|
||||
const res = await apiFetch<{ results: Record<string, string | null> }>(
|
||||
const res = await apiFetch<unknown>(
|
||||
"/api/network/resolve-hosts",
|
||||
{
|
||||
method: "POST",
|
||||
@@ -1225,10 +1232,14 @@ function NetworkMapContent() {
|
||||
},
|
||||
)
|
||||
if(!current())return
|
||||
setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results)))
|
||||
} catch {
|
||||
const results=validateNetworkMapDns(res,hostnames)
|
||||
setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(results)))
|
||||
const missing=Object.keys(results).filter(host=>results[host]===null)
|
||||
setDnsError(missing.length?"DNS не разрешён: "+missing.join(", "):null)
|
||||
} catch(error) {
|
||||
if(!current())return
|
||||
setGreResolvedIpv4ByHost({})
|
||||
setDnsError(error instanceof Error?(error.name==="ZodError"?"Некорректный ответ разрешения имён":error.message):"Ошибка DNS")
|
||||
}
|
||||
} else {
|
||||
setGreResolvedIpv4ByHost({})
|
||||
@@ -1381,56 +1392,53 @@ function NetworkMapContent() {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!showNetflow && !showServices) {
|
||||
queueMicrotask(() => {
|
||||
setMapHops([])
|
||||
setMapServices([])
|
||||
setMapServiceEdges([])
|
||||
setMapServicePaths([])
|
||||
setMapCountries([])
|
||||
setMapCountryEdges([])
|
||||
setMapCountryPaths([])
|
||||
setMapCountryServiceGroups([])
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!showNetflow && !showServices) return
|
||||
let cancelled = false
|
||||
let epoch=0
|
||||
let inFlight=false
|
||||
let ac: AbortController | null = null
|
||||
const tick = () => {
|
||||
if(cancelled||inFlight)return
|
||||
inFlight=true
|
||||
const request=++epoch
|
||||
setFlowLoading(true)
|
||||
ac?.abort()
|
||||
ac = new AbortController()
|
||||
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setMapHops(res.hops ?? [])
|
||||
setMapServices(res.services ?? [])
|
||||
setMapServiceEdges(res.serviceEdges ?? [])
|
||||
setMapServicePaths(res.servicePaths ?? [])
|
||||
setMapCountries(res.countries ?? [])
|
||||
setMapCountryEdges(res.countryEdges ?? [])
|
||||
setMapCountryPaths(res.countryPaths ?? [])
|
||||
setMapCountryServiceGroups(res.countryServiceGroups ?? [])
|
||||
apiFetch<unknown>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
|
||||
.then((raw) => {
|
||||
if (cancelled||request!==epoch) return
|
||||
const res=networkMapFlowSchema.parse(raw)
|
||||
setMapHops(res.hops)
|
||||
setMapServices(res.services)
|
||||
setMapServiceEdges(res.serviceEdges)
|
||||
setMapServicePaths(res.servicePaths)
|
||||
setMapCountries(res.countries)
|
||||
setMapCountryEdges(res.countryEdges)
|
||||
setMapCountryPaths(res.countryPaths)
|
||||
setMapCountryServiceGroups(res.countryServiceGroups)
|
||||
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
|
||||
setMapNamedBytes(res.namedBytes ?? 0)
|
||||
setMapTotalBytes(res.totalBytes ?? 0)
|
||||
setMapNamedBytes(res.namedBytes)
|
||||
setMapTotalBytes(res.totalBytes)
|
||||
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
|
||||
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
|
||||
if (res.windowSec) setMapWindowSec(res.windowSec)
|
||||
setMapWindowSec(res.windowSec)
|
||||
setFlowListener(res.live);setFlowReadAt(new Date().toISOString());setFlowError(null)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
if (cancelled||request!==epoch) return
|
||||
const name = err instanceof Error ? err.name : ""
|
||||
if (name === "AbortError") return
|
||||
})
|
||||
setFlowError(err instanceof Error?(err.name==="ZodError"?"Некорректный ответ: требуется полный снимок за 5 минут":err.message):"Ошибка NetFlow")
|
||||
}).finally(()=>{inFlight=false;if(!cancelled&&request===epoch)setFlowLoading(false)})
|
||||
}
|
||||
tick()
|
||||
queueMicrotask(()=>{if(!cancelled)tick()})
|
||||
const id = window.setInterval(tick, 4000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelled = true;epoch++
|
||||
ac?.abort()
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [useLiveData, showNetflow, showServices, apiFetch])
|
||||
}, [useLiveData, showNetflow, showServices, apiFetch,flowRefresh])
|
||||
|
||||
const effectiveSatPos = useMemo(() => {
|
||||
const out: Record<string, { x: number; y: number }[]> = {}
|
||||
@@ -2029,7 +2037,7 @@ function NetworkMapContent() {
|
||||
actions={
|
||||
<>
|
||||
{useLiveData && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadLive()} disabled={dataLoading}>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => {void loadLive();setFlowRefresh(value=>value+1)}} disabled={dataLoading}>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
@@ -2142,11 +2150,11 @@ function NetworkMapContent() {
|
||||
</button>
|
||||
))}
|
||||
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
|
||||
{mapSharePct > 0
|
||||
{useLiveData&&!flowReadAt?"Порог доли неизвестен":mapSharePct > 0
|
||||
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
|
||||
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
|
||||
</p>
|
||||
{destMode === "countries" && !mapCountryLoaded && (
|
||||
{destMode === "countries" && (!useLiveData||flowReadAt) && !mapCountryLoaded && (
|
||||
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
|
||||
GeoIP Country не загружен, страны из RIPE-кэша
|
||||
</p>
|
||||
@@ -2170,6 +2178,10 @@ function NetworkMapContent() {
|
||||
<span className="text-[10px] border border-border rounded px-1.5 py-0.5">
|
||||
{useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"}
|
||||
</span>
|
||||
{useLiveData&&<span>{showNetflow||showServices?(flowReadAt?"NetFlow · 5 мин · ответ "+new Date(flowReadAt).toLocaleTimeString("ru-RU")+" (время браузера) · "+(flowListener?"приёмник включён":"приёмник выключен"):"Данные NetFlow неизвестны"):"NetFlow: отображение и обновление выключены"}</span>}
|
||||
{useLiveData&&flowLoading&&(showNetflow||showServices)&&<span role="status">Обновление NetFlow…</span>}
|
||||
{useLiveData&&flowError&&(showNetflow||showServices)&&<span role="alert" className="text-destructive break-all">NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}</span>}
|
||||
{useLiveData&&dnsError&&<span role="alert" className="text-destructive break-all">DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.</span>}
|
||||
{dataLoading && <span role="status">Обновление снимка карты…</span>}
|
||||
{dataError && (
|
||||
<span role="alert" className="text-[10px] text-destructive break-all">
|
||||
@@ -3132,7 +3144,7 @@ function NetworkMapContent() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!mapAsnLoaded && destMode === "services" && (
|
||||
{!mapAsnLoaded && (!useLiveData||flowReadAt) && destMode === "services" && (
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
|
||||
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
|
||||
|
||||
Reference in new issue
Block a user