Files
MikrotikManager/backend/src/services/internet-path-observation.ts
T

49 lines
5.3 KiB
TypeScript

import { z } from "zod"
const id = z.union([z.number().int().positive().safe(),z.string().regex(/^[1-9]\d*$/)]).transform(Number).refine(Number.isSafeInteger)
const text = z.string().nullable().optional().transform(value => value ?? null)
const number = z.number().finite().nonnegative().nullable().optional().transform(value => value ?? null)
const flag = z.boolean().nullable().optional().transform(value => value ?? null)
const uplink = z.object({ id:text,iface:text,name:text,isp:text,configuredIp:text,leasedIp:text,dhcpStatus:text,isDefault:flag })
const configurationUplink = z.object({id:text,iface:text,name:text,isp:text,ip:text,maxDl:number,maxUl:number})
const server = z.object({ id,name:text,type:z.enum(["home-router","exit-node","jump-host"]).nullish(),enabled:flag,wanUplinks:z.array(configurationUplink).optional() })
const gre = z.object({id:z.string().min(1),serverId:id,name:text,localAddress:text,remoteAddress:text,localInnerIp:text,remoteInnerIp:text,mtu:number,enabled:flag,status:z.enum(["up","down","degraded"]).nullish()})
const rule = z.object({id:z.string().min(1),community:z.string(),action:z.enum(["route","blackhole"]),gateway:text,gatewayTunnelId:text})
const ruleset = z.object({serverId:id,rules:z.array(rule)})
const speed = z.object({id:z.string().min(1),srcServerId:id,dstServerId:id,srcInterface:text,dstInterface:text,protocol:z.enum(["tcp","udp"]).nullish(),direction:z.enum(["transmit","receive","both"]).nullish(),enabled:flag,lastRunAt:text,lastTxAvgMbps:number,lastRxAvgMbps:number,lastPingRttMs:number,lastPingLossPct:number,lastPingAt:text})
const snapshotSchema = z.object({
bindingShapeValid:z.literal(true),
collectionComplete:z.boolean().nullable(),sampledAt:z.string().nullable(),internetTarget:z.string().nullable(),
servers:z.array(server),greTunnels:z.array(gre),filtersRulesets:z.array(ruleset),speedProbes:z.array(speed),
routeLookupByServerId:z.record(z.string(),z.object({gateway:text,routingMark:text})),
wanRuntimeByHomeId:z.record(z.string(),z.object({defaultGateway:text,defaultInterface:text,uplinks:z.array(uplink)})),
})
function unique<T>(rows:T[],key:(row:T)=>string) {
if(new Set(rows.map(key)).size!==rows.length)throw Error("Неоднозначные объекты сохранённого Internet path")
}
/** This is an application interpretation saved by the legacy collector. Nullable
* fields and provenance prevent it being presented as a current path probe. */
export function projectInternetPathObservation(row:{id:number|string;sampledAt:string;payloadJson:unknown}, serverIds:number[], offset:number,limit:number,maxAgeSec:number, now=Date.now()) {
const sampledAt=new Date(row.sampledAt).toISOString(), timestamp=Date.parse(sampledAt)
const source=snapshotSchema.parse(row.payloadJson), allowed=new Set(serverIds)
if(source.sampledAt!==null && Date.parse(source.sampledAt)!==timestamp)throw Error("Дата содержимого Internet path не совпадает со снимком")
const allIds=[...source.servers.map(item=>item.id),...source.greTunnels.map(item=>item.serverId),...source.filtersRulesets.map(item=>item.serverId),...source.speedProbes.flatMap(item=>[item.srcServerId,item.dstServerId]),...Object.keys(source.routeLookupByServerId).map(Number),...Object.keys(source.wanRuntimeByHomeId).map(Number)]
if(allIds.some(value=>!allowed.has(value)))throw Error("Снимок Internet path выходит за выбранную область")
unique(source.servers,item=>String(item.id));unique(source.greTunnels,item=>item.serverId+":"+item.id)
unique(source.filtersRulesets,item=>String(item.serverId));unique(source.speedProbes,item=>item.id)
for(const group of source.filtersRulesets)unique(group.rules,item=>item.id)
const page=<T>(items:T[])=>({items:items.slice(offset,offset+limit),total:items.length,nextOffset:offset+limit<items.length?offset+limit:null})
const servers=page(source.servers.map(item=>({...item,wanUplinks:item.wanUplinks===undefined?null:page(item.wanUplinks)})))
const greTunnels=page(source.greTunnels),speedProbes=page(source.speedProbes)
const filtersRulesets=page(source.filtersRulesets.map(item=>({...item,rules:page(item.rules)})))
const wanRuntimeByHomeId=Object.fromEntries(Object.entries(source.wanRuntimeByHomeId).map(([key,value])=>[key,{...value,uplinks:page(value.uplinks)}]))
const pages=[servers,greTunnels,speedProbes,filtersRulesets,...filtersRulesets.items.map(item=>item.rules),...servers.items.flatMap(item=>item.wanUplinks?[item.wanUplinks]:[]),...Object.values(wanRuntimeByHomeId).map(item=>item.uplinks)]
const missingServerIds=serverIds.filter(value=>!source.servers.some(item=>item.id===value))
const clockSkew=timestamp>now+30000,ageSec=Math.max(0,Math.floor((now-timestamp)/1000))
return {snapshotId:String(row.id),sampledAt,sourceCollectionComplete:source.collectionComplete,
internetTarget:source.internetTarget,servers,greTunnels,filtersRulesets,speedProbes,routeLookupByServerId:source.routeLookupByServerId,wanRuntimeByHomeId,
missingServerIds,ageSec,clockSkew,stale:clockSkew||ageSec>maxAgeSec,live:false,forwardingVerified:false,currentRoutingVerified:false,
selectedSubgraph:true,source:"stored-application-interpretation",incomplete:source.collectionComplete!==true||missingServerIds.length>0||pages.some(value=>value.items.length<value.total),
}
}