fix(ui): показывать подтверждённые метрики участков пути
This commit is contained in:
1 parent
d4ae1c3127
commit
c3a9c00aed
9 files changed
+78
-117
No files matched your search
+24
-109
@@ -1,16 +1,5 @@
|
||||
import {readProbeOutput} from "@/lib/probe-response"
|
||||
import type { GreTunnel, Server, WanUplink } from "@/lib/data"
|
||||
import {
|
||||
assignSpeedProbesToGreTunnels,
|
||||
assignSpeedProbesToWanJhEdges,
|
||||
mergeGreMetricsWithSpeedProbe,
|
||||
wanJhEdgeMapKey,
|
||||
} from "@/lib/map-gre-speed-probe"
|
||||
import {
|
||||
buildWanJhEdges,
|
||||
findServerByGreRemote,
|
||||
greTunnelProbe,
|
||||
} from "@/lib/network-map-layout"
|
||||
import {
|
||||
buildLiveOptimizerData,
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
@@ -84,6 +73,17 @@ export interface HomeWanRuntime {
|
||||
}>
|
||||
}
|
||||
|
||||
type DashboardPathProbe = RouteOptimizerSpeedProbe & {lastRunAt?: string | null; lastStatus?: string | null; lastError?: string | null; lastPingError?: string | null}
|
||||
export function dashboardPathMetrics(probe: DashboardPathProbe | null, reverse = false): InternetPathHop["wanJhMetrics"] {
|
||||
const dated=(time: string | null | undefined)=>Boolean(time)&&Number.isFinite(Date.parse(time!))
|
||||
const known=(value: number | null | undefined)=>typeof value==="number"&&Number.isFinite(value)&&value>=0?value:null
|
||||
const ping=probe?.enabled && dated(probe.lastPingAt) && (probe.lastPingError===null||probe.lastPingError==="") ? known(probe.lastPingRttMs) : null
|
||||
const speed=probe?.enabled && dated(probe.lastRunAt) && probe.lastStatus==="done" && (probe.lastError===null||probe.lastError==="")
|
||||
const dl=speed?known(reverse?probe.lastTxAvgMbps:probe.lastRxAvgMbps):null
|
||||
const ul=speed?known(reverse?probe.lastRxAvgMbps:probe.lastTxAvgMbps):null
|
||||
return {pingMs:ping,dlMbps:dl,ulMbps:ul,fromMonitoring:ping!==null||dl!==null||ul!==null}
|
||||
}
|
||||
|
||||
const INTERNET_TARGET = "1.1.1.1"
|
||||
|
||||
function norm(v: string | null | undefined): string {
|
||||
@@ -133,31 +133,25 @@ function chooseActiveWan(
|
||||
const byGatewayIp = wans.find((w) => norm(w.ip) === norm(runtime.defaultGateway))
|
||||
if (byGatewayIp) return byGatewayIp
|
||||
}
|
||||
const runtimeBound = runtime.uplinks.find((u) => (u.dhcpStatus ?? "").toLowerCase() === "bound")
|
||||
if (runtimeBound) {
|
||||
const byId = wans.find((w) => norm(w.id) === norm(runtimeBound.id))
|
||||
if (byId) return byId
|
||||
const byIface = wans.find((w) => norm(w.iface) === norm(runtimeBound.iface))
|
||||
if (byIface) return byIface
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 2) Fallback: route-lookup
|
||||
const gw = norm(lookup?.gateway)
|
||||
if (gw) {
|
||||
const byIface = wans.find((w) => norm(w.iface) === gw || gw.includes(norm(w.iface)))
|
||||
const byIface = wans.find((w) => Boolean(norm(w.iface)) && (norm(w.iface) === gw || gw.includes("%"+norm(w.iface))))
|
||||
if (byIface) return byIface
|
||||
const byIp = wans.find((w) => norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`))
|
||||
const byIp = wans.find((w) => Boolean(norm(w.ip)) && (norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`)))
|
||||
if (byIp) return byIp
|
||||
}
|
||||
return wans[0] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
function bestJhForWan(
|
||||
home: Server,
|
||||
wan: WanUplink,
|
||||
jhs: Server[],
|
||||
probes: RouteOptimizerSpeedProbe[],
|
||||
probes: DashboardPathProbe[],
|
||||
): Server | null {
|
||||
const pool = probes.filter((p) => p.srcServerId === home.id && p.enabled !== false)
|
||||
const ranked = jhs
|
||||
@@ -176,7 +170,7 @@ function bestJhForWan(
|
||||
return ranked[0]?.jh ?? null
|
||||
}
|
||||
|
||||
function bestExitForJh(jh: Server, exits: Server[], probes: RouteOptimizerSpeedProbe[]): Server | null {
|
||||
function bestExitForJh(jh: Server, exits: Server[], probes: DashboardPathProbe[]): Server | null {
|
||||
const pool = probes.filter((p) => p.enabled !== false)
|
||||
const ranked = exits
|
||||
.map((ex) => {
|
||||
@@ -197,7 +191,7 @@ function pickHomeJhProbe(
|
||||
homeId: string,
|
||||
wanIface: string,
|
||||
jhId: string,
|
||||
probes: RouteOptimizerSpeedProbe[],
|
||||
probes: DashboardPathProbe[],
|
||||
): RouteOptimizerSpeedProbe | null {
|
||||
const ifaceNorm = norm(wanIface)
|
||||
const list = probes
|
||||
@@ -216,7 +210,7 @@ function pickHomeJhProbe(
|
||||
return list[0] ?? null
|
||||
}
|
||||
|
||||
function pickJhExitProbe(jhId: string, exitId: string, probes: RouteOptimizerSpeedProbe[]): RouteOptimizerSpeedProbe | null {
|
||||
function pickJhExitProbe(jhId: string, exitId: string, probes: DashboardPathProbe[]): RouteOptimizerSpeedProbe | null {
|
||||
const list = probes
|
||||
.filter((p) => p.enabled !== false)
|
||||
.filter((p) => (
|
||||
@@ -232,14 +226,10 @@ function pickJhExitProbe(jhId: string, exitId: string, probes: RouteOptimizerSpe
|
||||
return list[0] ?? null
|
||||
}
|
||||
|
||||
function metricValue(primary: number | null | undefined, secondary: number | null | undefined): number | null {
|
||||
return primary ?? secondary ?? null
|
||||
}
|
||||
|
||||
export function buildDashboardInternetPath(args: {
|
||||
servers: Server[]
|
||||
greTunnels: GreTunnel[]
|
||||
probes: RouteOptimizerSpeedProbe[]
|
||||
probes: DashboardPathProbe[]
|
||||
filtersRulesets: FiltersRulesetRow[]
|
||||
routeLookupByServerId: Record<string, RouteLookupResult | null>
|
||||
wanRuntimeByHomeId?: Record<string, HomeWanRuntime | null>
|
||||
@@ -317,18 +307,8 @@ export function buildDashboardInternetPath(args: {
|
||||
wan: primaryWan,
|
||||
jumpHost: primaryJh,
|
||||
exitNode: primaryEx,
|
||||
wanJhMetrics: {
|
||||
pingMs: primaryJh.latency,
|
||||
dlMbps: primaryWan.maxDl,
|
||||
ulMbps: primaryWan.maxUl,
|
||||
fromMonitoring: false,
|
||||
},
|
||||
jhExitMetrics: {
|
||||
pingMs: primaryEx.latency,
|
||||
dlMbps: null,
|
||||
ulMbps: null,
|
||||
fromMonitoring: false,
|
||||
},
|
||||
wanJhMetrics: dashboardPathMetrics(pickHomeJhProbe(home.id, primaryWan.iface, primaryJh.id, args.probes)),
|
||||
jhExitMetrics: (()=>{const p=pickJhExitProbe(primaryJh.id,primaryEx.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==primaryJh.id)})(),
|
||||
}
|
||||
: null
|
||||
|
||||
@@ -339,73 +319,8 @@ export function buildDashboardInternetPath(args: {
|
||||
wan: currentWan,
|
||||
jumpHost: currentJh,
|
||||
exitNode: currentExit,
|
||||
wanJhMetrics: (() => {
|
||||
const wanJh = buildWanJhEdges(args.servers).find((e) =>
|
||||
e.homeId === home.id
|
||||
&& e.jhId === currentJh.id
|
||||
&& (home.wanUplinks?.[e.wanIdx]?.id ?? "") === currentWan.id,
|
||||
)
|
||||
const fallback = {
|
||||
pingMs: wanJh?.pingMs ?? currentJh.latency,
|
||||
dlMbps: wanJh?.dlMbps ?? currentWan.maxDl,
|
||||
ulMbps: currentWan.maxUl,
|
||||
}
|
||||
if (!wanJh) return { ...fallback, fromMonitoring: false }
|
||||
const byEdge = assignSpeedProbesToWanJhEdges([wanJh], args.servers, args.probes)
|
||||
const sp = byEdge.get(wanJhEdgeMapKey(wanJh))
|
||||
const merged = mergeGreMetricsWithSpeedProbe(sp, fallback)
|
||||
return {
|
||||
pingMs: merged.pingMs,
|
||||
dlMbps: metricValue(merged.dlMbps, merged.ulMbps),
|
||||
ulMbps: metricValue(merged.ulMbps, merged.dlMbps),
|
||||
fromMonitoring: merged.hasSpeedMonitor,
|
||||
}
|
||||
})(),
|
||||
jhExitMetrics: (() => {
|
||||
const tunnel = args.greTunnels.find((t) => {
|
||||
const from = args.servers.find((s) => s.id === String(t.serverId))
|
||||
if (!from) return false
|
||||
const to = findServerByGreRemote(args.servers, t.remoteAddress)
|
||||
if (!to) return false
|
||||
return (
|
||||
(from.id === currentJh.id && to.id === currentExit.id)
|
||||
|| (from.id === currentExit.id && to.id === currentJh.id)
|
||||
)
|
||||
})
|
||||
const fallback = tunnel
|
||||
? greTunnelProbe(tunnel)
|
||||
: { pingMs: currentExit.latency, dlMbps: null, ulMbps: null }
|
||||
if (!tunnel) {
|
||||
return {
|
||||
pingMs: fallback.pingMs,
|
||||
dlMbps: fallback.dlMbps,
|
||||
ulMbps: fallback.ulMbps,
|
||||
fromMonitoring: false,
|
||||
}
|
||||
}
|
||||
const fromServer = args.servers.find((s) => s.id === String(tunnel.serverId))
|
||||
const toServer = fromServer ? findServerByGreRemote(args.servers, tunnel.remoteAddress) : undefined
|
||||
if (!fromServer || !toServer) {
|
||||
return {
|
||||
pingMs: fallback.pingMs,
|
||||
dlMbps: fallback.dlMbps,
|
||||
ulMbps: fallback.ulMbps,
|
||||
fromMonitoring: false,
|
||||
}
|
||||
}
|
||||
const byTunnel = assignSpeedProbesToGreTunnels(
|
||||
[{ tunnel, fromServer, toServer }],
|
||||
args.probes,
|
||||
)
|
||||
const sp = byTunnel.get(tunnel.id)
|
||||
const merged = mergeGreMetricsWithSpeedProbe(sp, fallback)
|
||||
return {
|
||||
pingMs: merged.pingMs,
|
||||
dlMbps: metricValue(merged.dlMbps, merged.ulMbps),
|
||||
ulMbps: metricValue(merged.ulMbps, merged.dlMbps),
|
||||
fromMonitoring: merged.hasSpeedMonitor,
|
||||
}
|
||||
})(),
|
||||
wanJhMetrics: dashboardPathMetrics(pickHomeJhProbe(home.id,currentWan.iface,currentJh.id,args.probes)),
|
||||
jhExitMetrics: (()=>{const p=pickJhExitProbe(currentJh.id,currentExit.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==currentJh.id)})(),
|
||||
}
|
||||
: null
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {test} from "node:test"
|
||||
import {parseDashboardPath,parseDashboardSpeedProbes,parseDashboardWanRuntime,parseDashboardFilters} from "./dashboard-path-response"
|
||||
import {resolveDefaultRouteLookup,buildDashboardInternetPath} from "./dashboard-internet-path"
|
||||
import {resolveDefaultRouteLookup,buildDashboardInternetPath,dashboardPathMetrics} from "./dashboard-internet-path"
|
||||
import {servers} from "./data"
|
||||
const snapshot={collectionComplete:true,sampledAt:"2026-10-03T00:00:00Z",servers:[],greTunnels:[],filtersRulesets:[],speedProbes:[],routeLookupByServerId:{},wanRuntimeByHomeId:{}}
|
||||
test("path validates explicit empty snapshot and rejects missing fields, foreign IDs and mismatched time",()=>{
|
||||
@@ -20,3 +20,19 @@ test("configured WAN alone does not prove an observed current route",()=>{
|
||||
const mapped=servers.map((s,i)=>({...s,id:String(i+1)}));const result=buildDashboardInternetPath({servers:mapped,greTunnels:[],filtersRulesets:[],probes:[],routeLookupByServerId:{},wanRuntimeByHomeId:{}})
|
||||
assert.ok(result);assert.equal(result.pathState,"unknown");assert.equal(result.currentPath,null);assert.equal(result.activeWanUplink,null)
|
||||
})
|
||||
|
||||
test("path metrics use dated successful hop observations, preserve RX/TX and reject configured fallback",()=>{
|
||||
const p={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0,lastRxAvgMbps:5,lastPingAt:snapshot.sampledAt,lastRunAt:snapshot.sampledAt,lastStatus:"done",lastError:null,lastPingError:null}
|
||||
assert.deepEqual(dashboardPathMetrics(p),{pingMs:0,dlMbps:5,ulMbps:0,fromMonitoring:true});assert.deepEqual(dashboardPathMetrics(p,true),{pingMs:0,dlMbps:0,ulMbps:5,fromMonitoring:true})
|
||||
assert.deepEqual(dashboardPathMetrics(null),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
|
||||
assert.deepEqual(dashboardPathMetrics({...p,lastRunAt:null,lastPingAt:null}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
|
||||
assert.deepEqual(dashboardPathMetrics({...p,lastStatus:"error",lastPingError:"denied"}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
|
||||
assert.equal(dashboardPathMetrics({...p,lastRxAvgMbps:null}).dlMbps,null)
|
||||
assert.deepEqual(dashboardPathMetrics({...p,enabled:false}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
|
||||
})
|
||||
|
||||
test("observed default route does not select the first WAN or a merely bound DHCP lease",()=>{
|
||||
const home={...servers.find(s=>s.type==="home-router")!,id:"1"};const runtime={defaultGateway:"203.0.113.254",defaultInterface:"unknown-interface",uplinks:home.wanUplinks!.map(w=>({id:w.id,iface:w.iface,name:w.name,isp:w.isp,configuredIp:w.ip,leasedIp:w.ip,dhcpStatus:"bound",isDefault:false}))}
|
||||
const result=buildDashboardInternetPath({servers:[home,...servers.filter(s=>s.type!=="home-router")],greTunnels:[],filtersRulesets:[],probes:[],routeLookupByServerId:{"1":{gateway:"203.0.113.254",routingMark:null}},wanRuntimeByHomeId:{"1":runtime}})
|
||||
assert.equal(result?.activeWanUplink,null);assert.equal(result?.currentPath,null);assert.equal(result?.directWan.gateway,"203.0.113.254")
|
||||
})
|
||||
@@ -5,12 +5,14 @@ import {filtersResponseSchema} from "@/lib/filters-response"
|
||||
import {optimizerProbesSchema,optimizerRulesSchema} from "@/lib/route-optimizer-response"
|
||||
const route=z.object({gateway:z.string().nullable(),routingMark:z.string().nullable()})
|
||||
export const dashboardWanRuntimeSchema=z.object({defaultGateway:z.string().nullable(),defaultInterface:z.string().nullable(),uplinks:z.array(z.object({id:z.string().min(1),iface:z.string(),name:z.string(),isp:z.string(),configuredIp:z.string(),leasedIp:z.string().nullable(),dhcpStatus:z.string().nullable(),isDefault:z.boolean()})).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)})
|
||||
const snapshot=z.object({collectionComplete:z.literal(true),sampledAt:z.iso.datetime({offset:true}),servers:dashboardServersSchema,greTunnels:greResponseSchema.shape.tunnels,filtersRulesets:filtersResponseSchema.shape.rulesets,speedProbes:optimizerProbesSchema.shape.probes,routeLookupByServerId:z.record(z.string(),route.nullable()),wanRuntimeByHomeId:z.record(z.string(),dashboardWanRuntimeSchema.nullable())}).refine(data=>{
|
||||
const pathProbe=optimizerProbesSchema.shape.probes.element.extend({lastRunAt:z.iso.datetime({offset:true}).nullable().optional(),lastStatus:z.enum(["done","error"]).nullable().optional(),lastError:z.string().nullable().optional(),lastPingError:z.string().nullable().optional()})
|
||||
const pathProbes=z.array(pathProbe).refine(rows=>new Set(rows.map(p=>p.id)).size===rows.length)
|
||||
const snapshot=z.object({collectionComplete:z.literal(true),sampledAt:z.iso.datetime({offset:true}),servers:dashboardServersSchema,greTunnels:greResponseSchema.shape.tunnels,filtersRulesets:filtersResponseSchema.shape.rulesets,speedProbes:pathProbes,routeLookupByServerId:z.record(z.string(),route.nullable()),wanRuntimeByHomeId:z.record(z.string(),dashboardWanRuntimeSchema.nullable())}).refine(data=>{
|
||||
const ids=new Set(data.servers.map(s=>String(s.id))),homes=data.servers.filter(s=>s.enabled&&s.type==="home-router")
|
||||
return data.greTunnels.every(t=>ids.has(t.serverId))&&data.filtersRulesets.every(r=>ids.has(r.serverId))&&data.speedProbes.every(p=>ids.has(p.srcServerId)&&ids.has(p.dstServerId))&&Object.keys(data.routeLookupByServerId).every(id=>ids.has(id))&&Object.keys(data.wanRuntimeByHomeId).every(id=>ids.has(id))&&homes.every(h=>data.routeLookupByServerId[String(h.id)]!=null&&data.wanRuntimeByHomeId[String(h.id)]!=null)
|
||||
})
|
||||
const response=z.object({snapshot:snapshot.nullable(),sampledAt:z.iso.datetime({offset:true}).optional()}).refine(data=>data.snapshot===null?data.sampledAt===undefined:!data.sampledAt||Date.parse(data.sampledAt)===Date.parse(data.snapshot.sampledAt))
|
||||
export function parseDashboardPath(raw:unknown){const parsed=response.safeParse(raw);if(!parsed.success)throw Error("Снимок Internet path не подтверждён: данные неполны или повреждены");return parsed.data}
|
||||
export function parseDashboardFilters(raw:unknown){const parsed=optimizerRulesSchema.safeParse(raw);if(!parsed.success||parsed.data.error)throw Error("Данные Filters для Internet path не подтверждены");return parsed.data}
|
||||
export function parseDashboardSpeedProbes(raw:unknown){const parsed=optimizerProbesSchema.safeParse(raw);if(!parsed.success)throw Error("Данные проб скорости для Internet path не подтверждены");return parsed.data}
|
||||
export function parseDashboardSpeedProbes(raw:unknown){const parsed=z.object({probes:pathProbes}).safeParse(raw);if(!parsed.success)throw Error("Данные проб скорости для Internet path не подтверждены");return parsed.data}
|
||||
export function parseDashboardWanRuntime(raw:unknown){const parsed=dashboardWanRuntimeSchema.safeParse(raw);if(!parsed.success)throw Error("Данные WAN для Internet path не подтверждены");return parsed.data}
|
||||
Reference in new issue
Block a user