feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,367 @@
|
||||
import {readProbeOutput} from "@/lib/probe-response"
|
||||
import type { GreTunnel, Server, WanUplink } from "@/lib/data"
|
||||
import {
|
||||
buildLiveOptimizerData,
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
type FiltersRulesetRow,
|
||||
type OptimizerApiServer,
|
||||
type RouteOptimizerSpeedProbe,
|
||||
} from "@/lib/route-optimizer-data"
|
||||
|
||||
export interface InternetPathRoute {
|
||||
wanId: string
|
||||
jhId?: string
|
||||
exitId?: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface InternetPathHop {
|
||||
home: Server
|
||||
wan: WanUplink
|
||||
jumpHost: Server
|
||||
exitNode: Server
|
||||
wanJhMetrics: {
|
||||
pingMs: number | null
|
||||
dlMbps: number | null
|
||||
ulMbps: number | null
|
||||
fromMonitoring: boolean
|
||||
}
|
||||
jhExitMetrics: {
|
||||
pingMs: number | null
|
||||
dlMbps: number | null
|
||||
ulMbps: number | null
|
||||
fromMonitoring: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface InternetPathViewModel {
|
||||
homeRouter: Server
|
||||
activeWanUplink: WanUplink | null
|
||||
fallbackJumpHost: Server | null
|
||||
fallbackExitNode: Server | null
|
||||
primaryHop: InternetPathHop | null
|
||||
currentHop: InternetPathHop | null
|
||||
primaryPath: InternetPathRoute | null
|
||||
currentPath: InternetPathRoute | null
|
||||
pathState: "healthy" | "degraded" | "failover" | "unknown"
|
||||
directWan: {
|
||||
enabled: boolean
|
||||
gateway: string | null
|
||||
leasedIp: string | null
|
||||
iface: string | null
|
||||
provider: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface RouteLookupResult {
|
||||
gateway: string | null
|
||||
routingMark: string | null
|
||||
}
|
||||
|
||||
export interface HomeWanRuntime {
|
||||
defaultGateway: string | null
|
||||
defaultInterface: string | null
|
||||
uplinks: Array<{
|
||||
id: string
|
||||
iface: string
|
||||
name: string
|
||||
isp: string
|
||||
configuredIp: string
|
||||
leasedIp: string | null
|
||||
dhcpStatus: string | null
|
||||
isDefault: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
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 {
|
||||
return String(v ?? "").trim().toLowerCase()
|
||||
}
|
||||
|
||||
function parseRouteLookupOutput(output: string): RouteLookupResult {
|
||||
const lines = output.split(/\r?\n/)
|
||||
let gateway: string | null = null
|
||||
let routingMark: string | null = null
|
||||
for (const line of lines) {
|
||||
const g = line.match(/^\s*gateway:\s*(.+?)\s*$/i)
|
||||
if (g) gateway = g[1].trim()
|
||||
const rm = line.match(/^\s*routing-mark:\s*(.+?)\s*$/i)
|
||||
if (rm) routingMark = rm[1].trim()
|
||||
}
|
||||
return { gateway, routingMark }
|
||||
}
|
||||
|
||||
function chooseActiveWan(
|
||||
home: Server,
|
||||
lookup: RouteLookupResult | null,
|
||||
runtime: HomeWanRuntime | null,
|
||||
): WanUplink | null {
|
||||
const wans = home.wanUplinks ?? []
|
||||
if (!wans.length) return null
|
||||
|
||||
// 1) Истина из MikroTik /ip/route + /ip/dhcp-client (wan-runtime)
|
||||
if (runtime) {
|
||||
const byDefaultFlag = runtime.uplinks.find((u) => u.isDefault)
|
||||
if (byDefaultFlag) {
|
||||
const byId = wans.find((w) => norm(w.id) === norm(byDefaultFlag.id))
|
||||
if (byId) return byId
|
||||
const byIface = wans.find((w) => norm(w.iface) === norm(byDefaultFlag.iface))
|
||||
if (byIface) return byIface
|
||||
}
|
||||
if (runtime.defaultInterface) {
|
||||
const byIface = wans.find((w) => norm(w.iface) === norm(runtime.defaultInterface))
|
||||
if (byIface) return byIface
|
||||
}
|
||||
const runtimeByIface = runtime.uplinks.find((u) => norm(u.iface) === norm(runtime.defaultInterface))
|
||||
if (runtimeByIface) {
|
||||
const byId = wans.find((w) => norm(w.id) === norm(runtimeByIface.id))
|
||||
if (byId) return byId
|
||||
}
|
||||
if (runtime.defaultGateway) {
|
||||
const byGatewayIp = wans.find((w) => norm(w.ip) === norm(runtime.defaultGateway))
|
||||
if (byGatewayIp) return byGatewayIp
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 2) Fallback: route-lookup
|
||||
const gw = norm(lookup?.gateway)
|
||||
if (gw) {
|
||||
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) => Boolean(norm(w.ip)) && (norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`)))
|
||||
if (byIp) return byIp
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function bestJhForWan(
|
||||
home: Server,
|
||||
wan: WanUplink,
|
||||
jhs: Server[],
|
||||
probes: DashboardPathProbe[],
|
||||
): Server | null {
|
||||
const pool = probes.filter((p) => p.srcServerId === home.id && p.enabled !== false)
|
||||
const ranked = jhs
|
||||
.map((jh) => {
|
||||
const m = pool
|
||||
.filter((p) => p.dstServerId === jh.id)
|
||||
.filter((p) => {
|
||||
const iface = norm(p.srcInterface)
|
||||
return !iface || iface === norm(wan.iface)
|
||||
})
|
||||
const best = m.sort((a, b) => (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999))[0]
|
||||
const rtt = best?.lastPingRttMs ?? jh.latency ?? 9999
|
||||
return { jh, rtt }
|
||||
})
|
||||
.sort((a, b) => a.rtt - b.rtt)
|
||||
return ranked[0]?.jh ?? null
|
||||
}
|
||||
|
||||
function bestExitForJh(jh: Server, exits: Server[], probes: DashboardPathProbe[]): Server | null {
|
||||
const pool = probes.filter((p) => p.enabled !== false)
|
||||
const ranked = exits
|
||||
.map((ex) => {
|
||||
const best = pool
|
||||
.filter((p) => (
|
||||
(p.srcServerId === jh.id && p.dstServerId === ex.id) ||
|
||||
(p.srcServerId === ex.id && p.dstServerId === jh.id)
|
||||
))
|
||||
.sort((a, b) => (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999))[0]
|
||||
const rtt = best?.lastPingRttMs ?? ex.latency ?? 9999
|
||||
return { ex, rtt }
|
||||
})
|
||||
.sort((a, b) => a.rtt - b.rtt)
|
||||
return ranked[0]?.ex ?? null
|
||||
}
|
||||
|
||||
function pickHomeJhProbe(
|
||||
homeId: string,
|
||||
wanIface: string,
|
||||
jhId: string,
|
||||
probes: DashboardPathProbe[],
|
||||
): RouteOptimizerSpeedProbe | null {
|
||||
const ifaceNorm = norm(wanIface)
|
||||
const list = probes
|
||||
.filter((p) => p.enabled !== false)
|
||||
.filter((p) => p.srcServerId === homeId && p.dstServerId === jhId)
|
||||
.filter((p) => {
|
||||
const srcIf = norm(p.srcInterface)
|
||||
return !ifaceNorm || !srcIf || srcIf === ifaceNorm
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aHasPing = a.lastPingRttMs != null ? 1 : 0
|
||||
const bHasPing = b.lastPingRttMs != null ? 1 : 0
|
||||
if (bHasPing !== aHasPing) return bHasPing - aHasPing
|
||||
return (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999)
|
||||
})
|
||||
return list[0] ?? null
|
||||
}
|
||||
|
||||
function pickJhExitProbe(jhId: string, exitId: string, probes: DashboardPathProbe[]): RouteOptimizerSpeedProbe | null {
|
||||
const list = probes
|
||||
.filter((p) => p.enabled !== false)
|
||||
.filter((p) => (
|
||||
(p.srcServerId === jhId && p.dstServerId === exitId) ||
|
||||
(p.srcServerId === exitId && p.dstServerId === jhId)
|
||||
))
|
||||
.sort((a, b) => {
|
||||
const aHasPing = a.lastPingRttMs != null ? 1 : 0
|
||||
const bHasPing = b.lastPingRttMs != null ? 1 : 0
|
||||
if (bHasPing !== aHasPing) return bHasPing - aHasPing
|
||||
return (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999)
|
||||
})
|
||||
return list[0] ?? null
|
||||
}
|
||||
|
||||
export function buildDashboardInternetPath(args: {
|
||||
servers: Server[]
|
||||
greTunnels: GreTunnel[]
|
||||
probes: DashboardPathProbe[]
|
||||
filtersRulesets: FiltersRulesetRow[]
|
||||
routeLookupByServerId: Record<string, RouteLookupResult | null>
|
||||
wanRuntimeByHomeId?: Record<string, HomeWanRuntime | null>
|
||||
}): InternetPathViewModel | null {
|
||||
const homes = args.servers.filter((s) => s.type === "home-router" && s.enabled)
|
||||
const jhs = args.servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
|
||||
const exits = args.servers.filter((s) => s.type === "exit-node" && s.enabled && s.status !== "offline")
|
||||
const home = homes[0]
|
||||
if (!home || !jhs.length || !exits.length) return null
|
||||
const runtime = args.wanRuntimeByHomeId?.[home.id] ?? null
|
||||
|
||||
const optimizerRows: OptimizerApiServer[] = args.servers.map((s) => ({
|
||||
id: Number.parseInt(s.id, 10) || 0,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
type: s.type,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
enabled: s.enabled,
|
||||
status: s.status === "unknown" ? null : s.status === "degraded" ? "online" : s.status,
|
||||
latency: s.latency,
|
||||
model: s.model ?? null,
|
||||
wanUplinks: (s.wanUplinks ?? []).map((w) => ({ ...w })),
|
||||
}))
|
||||
const optimizerData = buildLiveOptimizerData(
|
||||
optimizerRows,
|
||||
args.filtersRulesets,
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
args.probes,
|
||||
)
|
||||
const homeOptimizer = optimizerData.homes.find((h) => h.home.id === home.id)
|
||||
const best = homeOptimizer?.bestRoute ?? null
|
||||
|
||||
const primaryPath: InternetPathRoute | null = best
|
||||
? {
|
||||
wanId: best.wan.id,
|
||||
jhId: best.jh.id,
|
||||
exitId: best.exit.id,
|
||||
reason: "Route optimizer / OSPF quality",
|
||||
}
|
||||
: null
|
||||
|
||||
const observedRoute=Boolean(runtime?.defaultGateway||args.routeLookupByServerId[home.id]?.gateway)
|
||||
const routeKnown=runtime!=null&&args.routeLookupByServerId[home.id]!=null
|
||||
const activeWan = observedRoute ? chooseActiveWan(
|
||||
home,
|
||||
args.routeLookupByServerId[home.id] ?? null,
|
||||
runtime,
|
||||
) : null
|
||||
const currentJh = activeWan ? bestJhForWan(home, activeWan, jhs, args.probes) : null
|
||||
const currentExit = currentJh ? bestExitForJh(currentJh, exits, args.probes) : null
|
||||
const currentPath: InternetPathRoute | null = activeWan
|
||||
? {
|
||||
wanId: activeWan.id,
|
||||
reason: `Default route 0.0.0.0/0 (main) via ${activeWan.iface || activeWan.ip || "WAN"}`,
|
||||
}
|
||||
: null
|
||||
|
||||
const pathState: InternetPathViewModel["pathState"] =
|
||||
!routeKnown||home.status==="unknown"||(currentJh?.status==="unknown")||(currentExit?.status==="unknown")?"unknown":primaryPath && currentPath
|
||||
// Нормальный production-кейс: часть трафика идет через BGP (JH→EN),
|
||||
// часть — напрямую в провайдера WAN uplink.
|
||||
? "healthy"
|
||||
: (!primaryPath && !currentPath ? "unknown" : "degraded")
|
||||
|
||||
const primaryWan = (home.wanUplinks ?? []).find((w) => w.id === primaryPath?.wanId) ?? null
|
||||
const primaryJh = args.servers.find((s) => s.id === primaryPath?.jhId) ?? null
|
||||
const primaryEx = args.servers.find((s) => s.id === primaryPath?.exitId) ?? null
|
||||
const currentWan = (home.wanUplinks ?? []).find((w) => w.id === currentPath?.wanId) ?? activeWan
|
||||
|
||||
const primaryHop: InternetPathHop | null =
|
||||
primaryWan && primaryJh && primaryEx
|
||||
? {
|
||||
home,
|
||||
wan: primaryWan,
|
||||
jumpHost: primaryJh,
|
||||
exitNode: primaryEx,
|
||||
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
|
||||
|
||||
const currentHop: InternetPathHop | null =
|
||||
currentWan && currentJh && currentExit
|
||||
? {
|
||||
home,
|
||||
wan: currentWan,
|
||||
jumpHost: currentJh,
|
||||
exitNode: currentExit,
|
||||
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
|
||||
|
||||
return {
|
||||
homeRouter: home,
|
||||
activeWanUplink: activeWan,
|
||||
fallbackJumpHost: currentJh ?? primaryJh ?? jhs[0] ?? null,
|
||||
fallbackExitNode: currentExit ?? primaryEx ?? exits[0] ?? null,
|
||||
primaryHop,
|
||||
currentHop,
|
||||
primaryPath,
|
||||
currentPath,
|
||||
pathState,
|
||||
directWan: {
|
||||
enabled: Boolean(runtime?.defaultGateway || args.routeLookupByServerId[home.id]?.gateway),
|
||||
gateway: runtime?.defaultGateway ?? args.routeLookupByServerId[home.id]?.gateway ?? null,
|
||||
iface: runtime?.defaultInterface ?? activeWan?.iface ?? null,
|
||||
leasedIp:
|
||||
runtime?.uplinks.find((u) => u.isDefault)?.leasedIp
|
||||
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan?.iface))?.leasedIp
|
||||
?? null,
|
||||
provider:
|
||||
runtime?.uplinks.find((u) => u.isDefault)?.isp
|
||||
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan?.iface))?.isp
|
||||
?? activeWan?.isp
|
||||
?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveDefaultRouteLookup(
|
||||
apiFetch: <T>(path: string, init?: RequestInit) => Promise<T>,
|
||||
serverId: string,
|
||||
): Promise<RouteLookupResult | null> {
|
||||
const payload = await apiFetch<unknown>(`/api/servers/${serverId}/probes/run`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tool: "route",
|
||||
target: INTERNET_TARGET,
|
||||
}),
|
||||
})
|
||||
return parseRouteLookupOutput(readProbeOutput(payload))
|
||||
}
|
||||
Reference in new issue
Block a user