diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 1be4522..0df5b5c 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -10,6 +10,7 @@ import { StatusBadge } from "@/components/status-badge" import { Sparkline } from "@/components/sparkline" import { LatencyChart } from "@/components/dashboard/latency-chart" import { BandwidthChart } from "@/components/dashboard/bandwidth-chart" +import { InternetPathMapCard } from "@/components/dashboard/internet-path-map" import { servers as mockServers, pingProbes, @@ -18,6 +19,7 @@ import { serverFilterRulesets, } from "@/lib/data" import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data" +import type { GreTunnel } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { Flag } from "@/components/flag" import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react" @@ -25,6 +27,13 @@ import { Button, buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils" import { requestJson } from "@/shared/api/http-client" import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency" +import { + buildDashboardInternetPath, + type HomeWanRuntime, + resolveDefaultRouteLookup, + type InternetPathViewModel, +} from "@/lib/dashboard-internet-path" +import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-optimizer-data" import { listEvents } from "@/shared/api/events" import type { EventItem } from "@/packages/contracts/src/events" @@ -135,9 +144,85 @@ interface BackendServerRow { os: string | null model: string | null sessions?: number + wanUplinks?: Array<{ + id: string + name: string + isp: string + iface: string + ip: string + maxDl: number + maxUl: number + }> +} + +interface ApiGreTunnelRow { + id: string + name: string + serverId: string + localAddress: string + remoteAddress: string + localInnerIp: string + remoteInnerIp: string + poolId: string + ipsec: null + mtu: number + keepaliveInterval: number + keepaliveRetries: number + dscp: "inherit" | number + clampTcpMss: boolean + allowFastPath: boolean + comment: string + enabled: boolean + status: "up" | "down" | "degraded" +} + +interface InternetPathSnapshotPayload { + sampledAt: string + servers: BackendServerRow[] + greTunnels: ApiGreTunnelRow[] + filtersRulesets: FiltersRulesetRow[] + speedProbes: RouteOptimizerSpeedProbe[] + routeLookupByServerId: Record + wanRuntimeByHomeId: Record +} + +function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel { + return { + id: t.id, + name: t.name, + serverId: String(t.serverId), + localAddress: t.localAddress, + remoteAddress: t.remoteAddress, + localInnerIp: t.localInnerIp, + remoteInnerIp: t.remoteInnerIp, + poolId: t.poolId || "live", + ipsec: null, + mtu: t.mtu, + keepaliveInterval: t.keepaliveInterval, + keepaliveRetries: t.keepaliveRetries, + dscp: t.dscp, + clampTcpMss: t.clampTcpMss, + allowFastPath: t.allowFastPath, + comment: t.comment, + enabled: t.enabled, + status: t.status, + } } function mapBackendToServer(s: BackendServerRow): Server { + const wanUplinks = Array.isArray(s.wanUplinks) + ? s.wanUplinks + .filter((w) => typeof w === "object" && w != null) + .map((w, idx) => ({ + id: String(w.id || `wan-${s.id}-${idx + 1}`), + name: String(w.name || `WAN${idx + 1}`), + isp: String(w.isp || "—"), + iface: String(w.iface || ""), + ip: String(w.ip || ""), + maxDl: Math.max(1, Math.round(Number(w.maxDl) || 100)), + maxUl: Math.max(1, Math.round(Number(w.maxUl) || 100)), + })) + : [] return { id: String(s.id), name: s.name || s.host, @@ -152,6 +237,7 @@ function mapBackendToServer(s: BackendServerRow): Server { status: (s.status ?? "offline") as ServerStatus, latency: s.latency != null ? Math.round(s.latency) : null, sessions: s.sessions ?? 0, + wanUplinks, } } @@ -215,6 +301,9 @@ export default function DashboardPage() { const [recentEvents, setRecentEvents] = useState([]) const [eventsLoading, setEventsLoading] = useState(false) const [eventsError, setEventsError] = useState(null) + const [internetPath, setInternetPath] = useState(null) + const [internetPathLoading, setInternetPathLoading] = useState(false) + const [internetPathError, setInternetPathError] = useState(null) const probeServerCatalog = useMemo(() => { if (!isLive) return mockServers @@ -224,20 +313,26 @@ export default function DashboardPage() { const fetchProbes = useCallback(async (silent: boolean) => { if (!isLive) return if (!silent) setProbesLoading(true) + if (!silent) setInternetPathLoading(true) try { const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h") setLiveProbes(overview.probes) setProbesError(null) + setInternetPathError(null) + let serversMapped: Server[] = [] try { const backendServers = await apiFetch("/api/servers") - setLiveServersResolved(backendServers.map(mapBackendToServer)) + serversMapped = backendServers.map(mapBackendToServer) + setLiveServersResolved(serversMapped) } catch { + serversMapped = [] setLiveServersResolved([]) } - const [fr, br] = await Promise.allSettled([ + const [fr, br, ipRes] = await Promise.allSettled([ apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), apiFetch>("/api/bgp/sessions"), + apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"), ]) let filtersPart: LiveKpiSnapshot["filters"] = null @@ -263,16 +358,69 @@ export default function DashboardPage() { } setLiveKpi({ filters: filtersPart, bgp: bgpPart }) + if (serversMapped.length > 0) { + const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null + if (snap) { + const snapshotServers = snap.servers.map(mapBackendToServer) + setInternetPath(buildDashboardInternetPath({ + servers: snapshotServers, + greTunnels: (snap.greTunnels ?? []).map(apiGreToGreTunnel), + probes: snap.speedProbes ?? [], + filtersRulesets: snap.filtersRulesets ?? [], + routeLookupByServerId: snap.routeLookupByServerId ?? {}, + wanRuntimeByHomeId: snap.wanRuntimeByHomeId ?? {}, + })) + } else { + const filterRulesets: FiltersRulesetRow[] = + fr.status === "fulfilled" + ? (fr.value.rulesets as FiltersRulesetRow[] ?? []) + : [] + const homes = serversMapped.filter((s) => s.type === "home-router") + const lookups = await Promise.all( + homes.map(async (h) => ({ + id: h.id, + lookup: await resolveDefaultRouteLookup(apiFetch, h.id), + })), + ) + const wanRuntimeRows = await Promise.all( + homes.map(async (h) => { + try { + const rt = await apiFetch(`/api/servers/${h.id}/wan-runtime`) + return { id: h.id, runtime: rt } + } catch { + return { id: h.id, runtime: null } + } + }), + ) + const speedRes = await apiFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(() => ({ probes: [] })) + const greRes = await apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })) + const lookupById = Object.fromEntries(lookups.map((x) => [x.id, x.lookup])) + const wanRuntimeById = Object.fromEntries(wanRuntimeRows.map((x) => [x.id, x.runtime])) + setInternetPath(buildDashboardInternetPath({ + servers: serversMapped, + greTunnels: (greRes.tunnels ?? []).map(apiGreToGreTunnel), + probes: speedRes.probes ?? [], + filtersRulesets: filterRulesets, + routeLookupByServerId: lookupById, + wanRuntimeByHomeId: wanRuntimeById, + })) + } + } else { + setInternetPath(null) + } } catch (e) { const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы" setProbesError(msg) setLiveKpi(null) + setInternetPathError(msg) if (!silent) { setLiveProbes(null) setLiveServersResolved(null) + setInternetPath(null) } } finally { if (!silent) setProbesLoading(false) + if (!silent) setInternetPathLoading(false) } }, [apiFetch, isLive]) @@ -302,6 +450,8 @@ export default function DashboardPage() { setLiveServersResolved(null) setLiveKpi(null) setProbesError(null) + setInternetPath(null) + setInternetPathError(null) }) return } @@ -807,6 +957,18 @@ export default function DashboardPage() { +
+ {internetPathError && isLive && ( +
{internetPathError}
+ )} + {internetPathLoading && isLive && !internetPath && ( +
+ )} + {(!isLive || internetPath || !internetPathLoading) && ( + + )} +
+ {/* Ping probes table */} diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index 89ba60f..8b7d3d7 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -30,6 +30,7 @@ import { type AlertEngineRuleDiagSnapshot, type AlertEngineRunSnapshot, type GreBgpSnapshotRunSnapshot, + type InternetPathRunSnapshot, type PingRunSnapshot, type ResourcesRunSnapshot, type SchedulerRunSnapshot, @@ -436,6 +437,33 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
) } + if (snap.job === "internet_path") { + const p = snap as InternetPathRunSnapshot + return ( +
+

+ Снимок internet-path на{" "} + {new Date(p.sampledAt).toLocaleString("ru-RU")} +

+
+
+
Home routers
+
{p.homes}
+
+
+
Сохранение snapshot
+
{p.snapshotSaved ? "ok" : "no"}
+
+
+ {p.fatalError ? ( + + + Критическая ошибка: {p.fatalError} + + ) : null} +
+ ) + } if (snap.job === "alert_engine") { const a = snap as AlertEngineRunSnapshot const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => { @@ -640,6 +668,7 @@ export default function DataCollectionPage() { const [trafficCollector, setTrafficCollector] = useState(null) const [serversApiCollector, setServersApiCollector] = useState(null) const [uptimeCollector, setUptimeCollector] = useState(null) + const [internetPathCollector, setInternetPathCollector] = useState(null) const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30") const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300") @@ -652,6 +681,8 @@ export default function DataCollectionPage() { const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true) const [draftPingEnabled, setDraftPingEnabled] = useState(true) const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true) + const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true) + const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300") const [schedulerRuns, setSchedulerRuns] = useState([]) const [runFilterJobKey, setRunFilterJobKey] = useState("") const [runNowJobKey, setRunNowJobKey] = useState(null) @@ -683,15 +714,17 @@ export default function DataCollectionPage() { runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}` : "?limit=80" - const [traffic, serversApi, uptime, runsRes] = await Promise.all([ + const [traffic, serversApi, uptime, internetPath, runsRes] = await Promise.all([ apiFetch("/api/traffic/settings"), apiFetch("/api/servers-api-ping/settings"), apiFetch("/api/uptime/settings"), + apiFetch("/api/internet-path/settings"), apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`), ]) setTrafficCollector(traffic) setServersApiCollector(serversApi) setUptimeCollector(uptime) + setInternetPathCollector(internetPath) setSchedulerRuns(runsRes.runs ?? []) setTrafficIntervalDraft(String(traffic.intervalSec)) setTrafficRetentionDraft(String(traffic.retentionDays)) @@ -705,6 +738,8 @@ export default function DataCollectionPage() { setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled)) setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled)) setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled)) + setDraftInternetPathEnabled(!!internetPath.enabled) + setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300)) } catch (e) { setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") } finally { @@ -717,6 +752,7 @@ export default function DataCollectionPage() { setTrafficCollector(null) setServersApiCollector(null) setUptimeCollector(null) + setInternetPathCollector(null) setSchedulerRuns([]) return } @@ -744,8 +780,10 @@ export default function DataCollectionPage() { if (draftResourcesEnabled) n += 1 if (draftPingEnabled) n += 1 if (draftSpeedEnabled) n += 1 + if (draftInternetPathEnabled) n += 1 return n }, [ + draftInternetPathEnabled, draftPingEnabled, draftResourcesEnabled, draftServersApiEnabled, @@ -905,44 +943,52 @@ export default function DataCollectionPage() { ? draftTrafficEnabled : jobKey === "servers_rest_ping" ? draftServersApiEnabled - : jobKey === "uptime_resources" + : jobKey === "uptime_resources" ? draftResourcesEnabled : jobKey === "uptime_ping" ? draftPingEnabled - : draftSpeedEnabled + : jobKey === "uptime_speed" + ? draftSpeedEnabled + : draftInternetPathEnabled const iv = fixedSchedule ? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) : jobKey === "traffic" ? trafficIntervalDraft : jobKey === "servers_rest_ping" ? serversApiIntervalDraft - : jobKey === "uptime_resources" + : jobKey === "uptime_resources" ? uptimeResourceIntervalDraft : jobKey === "uptime_ping" ? uptimeIntervalDraft - : uptimeSpeedIntervalDraft + : jobKey === "uptime_speed" + ? uptimeSpeedIntervalDraft + : internetPathIntervalDraft const setIv = fixedSchedule ? () => {} : jobKey === "traffic" ? setTrafficIntervalDraft : jobKey === "servers_rest_ping" ? setServersApiIntervalDraft - : jobKey === "uptime_resources" + : jobKey === "uptime_resources" ? setUptimeResourceIntervalDraft : jobKey === "uptime_ping" ? setUptimeIntervalDraft - : setUptimeSpeedIntervalDraft + : jobKey === "uptime_speed" + ? setUptimeSpeedIntervalDraft + : setInternetPathIntervalDraft const defSec = fixedSchedule ? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) : jobKey === "traffic" ? 30 : jobKey === "servers_rest_ping" ? 120 - : jobKey === "uptime_resources" + : jobKey === "uptime_resources" ? 300 : jobKey === "uptime_ping" ? 15 - : 60 + : jobKey === "uptime_speed" + ? 60 + : 300 return ( @@ -962,7 +1008,8 @@ export default function DataCollectionPage() { else if (jobKey === "servers_rest_ping") setDraftServersApiEnabled(v) else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v) else if (jobKey === "uptime_ping") setDraftPingEnabled(v) - else setDraftSpeedEnabled(v) + else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v) + else setDraftInternetPathEnabled(v) }} /> @@ -1082,6 +1129,7 @@ export default function DataCollectionPage() { const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60) const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14) const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120) + const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300) await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ @@ -1109,6 +1157,13 @@ export default function DataCollectionPage() { retentionDays: uRet, }), }) + await apiFetch("/api/internet-path/settings", { + method: "PUT", + body: JSON.stringify({ + enabled: draftInternetPathEnabled, + intervalSec: ipInt, + }), + }) await loadCollectors() } catch (e) { setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить") @@ -1163,6 +1218,18 @@ export default function DataCollectionPage() {

{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}

+

+ Internet Path snapshot:{" "} + {internetPathCollector?.lastCollectedAt + ? new Date(internetPathCollector.lastCollectedAt).toLocaleString("ru-RU") + : "—"}{" "} + · {internetPathCollector?.lastDurationMs != null ? `${internetPathCollector.lastDurationMs} мс` : "—"} +

+

+ {internetPathCollector?.lastError + ? `Internet Path: ${internetPathCollector.lastError}` + : "Internet Path: ошибок нет"} +

diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 52eb786..187e785 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -205,6 +205,26 @@ CREATE TABLE IF NOT EXISTS scheduler_runs ( CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, finished_at); +CREATE TABLE IF NOT EXISTS internet_path_settings ( + id INTEGER PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1, + interval_sec INTEGER NOT NULL DEFAULT 300, + retention_days INTEGER NOT NULL DEFAULT 14, + last_collected_at TEXT, + last_duration_ms INTEGER, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS internet_path_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sampled_at TEXT NOT NULL, + payload_json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled + ON internet_path_snapshots(sampled_at DESC); + CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, created_at TEXT NOT NULL, @@ -516,6 +536,12 @@ SELECT 1, 0, 120 WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1); `) +sqlite.exec(` +INSERT INTO internet_path_settings (id, enabled, interval_sec, retention_days) +SELECT 1, 1, 300, 14 +WHERE NOT EXISTS (SELECT 1 FROM internet_path_settings WHERE id = 1); +`) + sqlite.exec(` INSERT INTO alert_telegram_settings (id, bot_token, chat_id) SELECT 1, '', '' diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 4717d54..55e8030 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -464,6 +464,24 @@ export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", { createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), }) +export const internetPathSettings = sqliteTable("internet_path_settings", { + id: integer("id").primaryKey(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + intervalSec: integer("interval_sec").notNull().default(300), + retentionDays: integer("retention_days").notNull().default(14), + lastCollectedAt: text("last_collected_at"), + lastDurationMs: integer("last_duration_ms"), + lastError: text("last_error"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}) + +export const internetPathSnapshots = sqliteTable("internet_path_snapshots", { + id: integer("id").primaryKey({ autoIncrement: true }), + sampledAt: text("sampled_at").notNull(), + payloadJson: text("payload_json").notNull(), +}) + // ── inferred types ───────────────────────────────────────────────────────────── export type Server = typeof servers.$inferSelect @@ -481,6 +499,8 @@ export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect +export type InternetPathSettingsRow = typeof internetPathSettings.$inferSelect +export type InternetPathSnapshotRow = typeof internetPathSnapshots.$inferSelect export type SchedulerRunRow = typeof schedulerRuns.$inferSelect export type EventRow = typeof events.$inferSelect export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect diff --git a/backend/src/index.ts b/backend/src/index.ts index 9f28217..ab21bd1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ import trafficRoutes from "./routes/traffic.js" import serversApiPingRoutes from "./routes/servers-api-ping.js" import uptimeRoutes from "./routes/uptime.js" import networkRoutes from "./routes/network.js" +import internetPathRoutes from "./routes/internet-path.js" import evobgpRoutes from "./routes/evobgp.js" import probesRoutes from "./routes/probes.js" import schedulerRoutes from "./routes/scheduler.js" @@ -57,6 +58,7 @@ await app.register(trafficRoutes, { prefix: "/api" }) await app.register(serversApiPingRoutes, { prefix: "/api" }) await app.register(uptimeRoutes, { prefix: "/api" }) await app.register(networkRoutes, { prefix: "/api" }) +await app.register(internetPathRoutes, { prefix: "/api" }) await app.register(evobgpRoutes, { prefix: "/api" }) await app.register(probesRoutes, { prefix: "/api" }) await app.register(schedulerRoutes, { prefix: "/api" }) diff --git a/backend/src/routes/internet-path.ts b/backend/src/routes/internet-path.ts new file mode 100644 index 0000000..4b36164 --- /dev/null +++ b/backend/src/routes/internet-path.ts @@ -0,0 +1,63 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { refreshScheduler } from "../services/scheduler.js" +import { + getInternetPathSettings, + getLatestInternetPathSnapshot, + updateInternetPathSettings, +} from "../services/internet-path-collector.js" + +const internetPathRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/internet-path/settings", async (_req, reply) => { + const s = getInternetPathSettings() + return reply.send({ + enabled: s.enabled, + intervalSec: s.intervalSec, + retentionDays: s.retentionDays, + lastCollectedAt: s.lastCollectedAt ?? null, + lastDurationMs: s.lastDurationMs ?? null, + lastError: s.lastError || null, + }) + }) + + app.put("/internet-path/settings", async (req, reply) => { + const body = req.body as { + enabled?: boolean + intervalSec?: number | string + retentionDays?: number | string + } + const updated = updateInternetPathSettings({ + enabled: body.enabled, + intervalSec: body.intervalSec == null ? undefined : Math.max(30, Number.parseInt(String(body.intervalSec), 10) || 300), + retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14), + }) + refreshScheduler() + return reply.send({ + ok: true, + settings: { + enabled: updated.enabled, + intervalSec: updated.intervalSec, + retentionDays: updated.retentionDays, + lastCollectedAt: updated.lastCollectedAt ?? null, + lastDurationMs: updated.lastDurationMs ?? null, + lastError: updated.lastError || null, + }, + }) + }) + + app.get("/internet-path/latest", async (_req, reply) => { + const row = getLatestInternetPathSnapshot() + if (!row) return reply.send({ snapshot: null }) + let payload: unknown = null + try { + payload = JSON.parse(row.payloadJson) + } catch { + payload = null + } + return reply.send({ + snapshot: payload, + sampledAt: row.sampledAt, + }) + }) +} + +export default internetPathRoutes diff --git a/backend/src/routes/probes.ts b/backend/src/routes/probes.ts index 96f7136..ad358fc 100644 --- a/backend/src/routes/probes.ts +++ b/backend/src/routes/probes.ts @@ -141,10 +141,19 @@ function fmtBandwidth(rows: Array>): string { } function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string { + const isTrue = (v: unknown) => { + const s = String(v ?? "").trim().toLowerCase() + return s === "true" || s === "yes" + } const matches = routes.filter((r) => { const dst = String(r["dst-address"] ?? "").trim() if (!dst) return false - if (String(r.active ?? "true").toLowerCase() === "false") return false + // Критично: учитывать только реально ACTIVE маршруты из /ip/route. + if (!isTrue(r.active)) return false + const rt = String((r as unknown as Record)["routing-table"] ?? "").trim().toLowerCase() + if (!(rt === "" || rt === "main")) return false + if (String((r as unknown as Record).disabled ?? "false").toLowerCase() === "true") return false + if (String((r as unknown as Record).inactive ?? "false").toLowerCase() === "true") return false return ipMatchesRoute(destIp, dst) }) if (matches.length === 0) { @@ -153,7 +162,14 @@ function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string { matches.sort((a, b) => { const da = parseDstRoute(String(a["dst-address"] ?? "")) const db = parseDstRoute(String(b["dst-address"] ?? "")) - return (db?.maskBits ?? 0) - (da?.maskBits ?? 0) + const maskCmp = (db?.maskBits ?? 0) - (da?.maskBits ?? 0) + if (maskCmp !== 0) return maskCmp + const distA = Number(a.distance ?? 255) + const distB = Number(b.distance ?? 255) + if (distA !== distB) return distA - distB + const aHasGw = String(a.gateway ?? "").trim().length > 0 ? 0 : 1 + const bHasGw = String(b.gateway ?? "").trim().length > 0 ? 0 : 1 + return aHasGw - bHasGw }) const best = matches[0]! const lines = [ diff --git a/backend/src/routes/servers.ts b/backend/src/routes/servers.ts index cd3656e..e879f40 100644 --- a/backend/src/routes/servers.ts +++ b/backend/src/routes/servers.ts @@ -90,6 +90,142 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => { return reply.send({ host: server.host, ipv4 }) }) + // GET /api/servers/:id/wan-runtime — DHCP lease + active default route for HomeRouter WAN uplinks + app.get("/:id/wan-runtime", { schema: { params: ServerIdParamSchema } }, async (req, reply) => { + const params = req.params as ServerIdParams + const server = getServerReadById(params.id) + if (!server) return reply.status(404).send({ error: "Server not found" }) + + const toIp = (raw: string | null | undefined): string | null => { + const v = String(raw ?? "").trim() + if (!v) return null + return v.split("/")[0]?.trim() ?? null + } + const ipv4ToUint = (ip: string): number | null => { + const p = ip.split(".").map((x) => Number.parseInt(x, 10)) + if (p.length !== 4 || p.some((x) => !Number.isFinite(x) || x < 0 || x > 255)) return null + return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0) + } + const maskFromLen = (len: number): number => { + if (len <= 0) return 0 + if (len >= 32) return 0xffffffff + return (~((1 << (32 - len)) - 1)) >>> 0 + } + const parseDstRoute = (dst: string): { net: number; maskBits: number } | null => { + const t = dst.trim() + if (!t) return null + if (!t.includes("/")) { + const ip = ipv4ToUint(t) + return ip == null ? null : { net: ip, maskBits: 32 } + } + const [addr, mb] = t.split("/") + const ip = ipv4ToUint(addr.trim()) + const maskBits = Number.parseInt((mb ?? "").trim(), 10) + if (ip == null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null + const mask = maskFromLen(maskBits) + return { net: ip & mask, maskBits } + } + const routeHasIp = (routeDst: string, ip: string): boolean => { + const ipu = ipv4ToUint(ip) + const cidr = parseDstRoute(routeDst) + if (ipu == null || !cidr) return false + const mask = maskFromLen(cidr.maskBits) + return (ipu & mask) === (cidr.net & mask) + } + const gatewayIface = (gw: string | null | undefined): string | null => { + const v = String(gw ?? "").trim() + if (!v) return null + const idx = v.indexOf("%") + if (idx < 0) return null + return v.slice(idx + 1).trim() || null + } + + try { + const client = MikrotikClient.fromServer(getServerRowById(params.id)!) + const isTrue = (v: unknown) => { + const s = String(v ?? "").trim().toLowerCase() + return s === "true" || s === "yes" + } + const [dhcpRaw, ipAddrs, routes] = await Promise.all([ + client.get>>("/ip/dhcp-client").catch(() => []), + client.getIpAddresses().catch(() => []), + client.getIpRoutes().catch(() => []), + ]) + + const defaultRoute = routes + .filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0") + // Только реально ACTIVE default routes. + .filter((r) => isTrue((r as unknown as Record).active)) + // Эквивалент CLI: routing-table=main + .filter((r) => { + const rt = String((r as unknown as Record)["routing-table"] ?? "").trim().toLowerCase() + return rt === "" || rt === "main" + }) + .filter((r) => String((r as unknown as Record).disabled ?? "false").toLowerCase() !== "true") + .filter((r) => String((r as unknown as Record).inactive ?? "false").toLowerCase() !== "true") + .sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0] + + const defaultGateway = String(defaultRoute?.gateway ?? "").trim() || null + const immediateGw = String((defaultRoute as unknown as Record | undefined)?.["immediate-gw"] ?? "").trim() || null + const gwFromImmediate = immediateGw ? immediateGw.split("%")[0]?.trim() ?? null : null + const gwIp = toIp(defaultGateway) ?? gwFromImmediate + const dhcpIfaceByGateway = gwIp == null + ? null + : ( + dhcpRaw.find((d) => { + const status = String(d.status ?? "").trim().toLowerCase() + if (status && status !== "bound") return false + return toIp(d.gateway) === gwIp + }) + ) + const directIfaceByGwSubnet = + gwIp == null + ? null + : ( + routes + .filter((r) => String((r as unknown as Record).active ?? "").toLowerCase() === "true") + .filter((r) => String((r as unknown as Record)["dst-address"] ?? "").trim() !== "0.0.0.0/0") + .filter((r) => String((r as unknown as Record).disabled ?? "false").toLowerCase() !== "true") + .filter((r) => String((r as unknown as Record).inactive ?? "false").toLowerCase() !== "true") + .find((r) => routeHasIp(String((r as unknown as Record)["dst-address"] ?? ""), gwIp)) + ) + const defaultInterface = + gatewayIface(immediateGw) + || gatewayIface(defaultGateway) + || String((dhcpIfaceByGateway as unknown as Record | undefined)?.interface ?? "").trim() + || String(defaultRoute?.interface ?? "").trim() + || String((directIfaceByGwSubnet as unknown as Record | undefined)?.interface ?? "").trim() + || null + + const uplinks = (server.wanUplinks ?? []).map((w) => { + const iface = String(w.iface ?? "").trim() + const dhcp = dhcpRaw.find((d) => String(d.interface ?? "").trim() === iface) + const fromDhcp = toIp(dhcp?.address) + const fromIpAddr = toIp(ipAddrs.find((a) => String(a.interface ?? "").trim() === iface)?.address) + const leasedIp = fromDhcp ?? fromIpAddr + return { + id: w.id, + iface, + name: w.name, + isp: w.isp, + configuredIp: w.ip, + leasedIp, + dhcpStatus: String(dhcp?.status ?? "").trim() || null, + isDefault: defaultInterface != null && defaultInterface === iface, + } + }) + + return reply.send({ + defaultGateway, + immediateGateway: immediateGw, + defaultInterface, + uplinks, + }) + } catch (err) { + return reply.status(502).send({ error: err instanceof Error ? err.message : "Failed to read WAN runtime" }) + } + }) + // POST /api/servers app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => { return reply.status(201).send(createServer(req.body as ServerCreateRequest)) diff --git a/backend/src/services/internet-path-collector.ts b/backend/src/services/internet-path-collector.ts new file mode 100644 index 0000000..fa27737 --- /dev/null +++ b/backend/src/services/internet-path-collector.ts @@ -0,0 +1,312 @@ +import { and, asc, eq, lt } from "drizzle-orm" +import { db } from "../db/index.js" +import { + filterRules, + internetPathSettings, + internetPathSnapshots, + servers, + uptimeSpeedProbes, +} from "../db/schema.js" +import { listServersRead } from "../modules/servers/service/servers-service.js" +import { MikrotikClient } from "./mikrotik.js" +import type { InternetPathRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" + +const INTERNET_TARGET = "1.1.1.1" +let collecting = false + +function isTrue(v: unknown): boolean { + const s = String(v ?? "").trim().toLowerCase() + return s === "true" || s === "yes" +} + +function toIp(raw: string | null | undefined): string | null { + const v = String(raw ?? "").trim() + if (!v) return null + return v.split("/")[0]?.trim() ?? null +} + +function norm(v: string | null | undefined): string { + return String(v ?? "").trim().toLowerCase() +} + +function getSettingsRow() { + const row = db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1).all()[0] + if (row) return row + const now = new Date().toISOString() + db.insert(internetPathSettings).values({ + id: 1, + enabled: true, + intervalSec: 300, + retentionDays: 14, + createdAt: now, + updatedAt: now, + }).run() + return db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1).all()[0] +} + +function cleanupSnapshots(retentionDays: number) { + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString() + db.delete(internetPathSnapshots).where(lt(internetPathSnapshots.sampledAt, cutoff)).run() +} + +function buildRulesets() { + const enabled = db.select().from(servers).where(eq(servers.enabled, true)).all() + const rules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all() + return enabled.map((s) => ({ + serverId: String(s.id), + rules: rules + .filter((r) => r.serverId === s.id) + .map((r) => ({ + id: String(r.id), + community: r.community, + communityName: r.communityName ?? undefined, + action: r.action, + gateway: r.gateway, + gatewayTunnelId: r.gatewayTunnelId, + description: r.description, + })), + })) +} + +async function readRouteLookup(serverId: number): Promise<{ gateway: string | null; routingMark: string | null }> { + const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] + if (!row) return { gateway: null, routingMark: null } + const client = MikrotikClient.fromServer(row) + const routes = await client.get>>("/ip/route").catch(() => []) + const best = routes + .filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0") + .filter((r) => isTrue(r.active)) + .filter((r) => { + const rt = String(r["routing-table"] ?? "").trim().toLowerCase() + return rt === "" || rt === "main" + }) + .filter((r) => String(r.disabled ?? "false").toLowerCase() !== "true") + .filter((r) => String(r.inactive ?? "false").toLowerCase() !== "true") + .sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0] + return { + gateway: String(best?.gateway ?? "").trim() || null, + routingMark: String(best?.["routing-mark"] ?? "").trim() || null, + } +} + +async function readWanRuntime(serverId: number) { + const server = listServersRead().find((s) => Number(s.id) === serverId) + const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] + if (!server || !row) return null + const client = MikrotikClient.fromServer(row) + const [dhcpRaw, ipAddrs, routes] = await Promise.all([ + client.get>>("/ip/dhcp-client").catch(() => []), + client.get>>("/ip/address").catch(() => []), + client.get>>("/ip/route").catch(() => []), + ]) + const defaultRoute = routes + .filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0") + .filter((r) => isTrue(r.active)) + .filter((r) => { + const rt = String(r["routing-table"] ?? "").trim().toLowerCase() + return rt === "" || rt === "main" + }) + .filter((r) => String(r.disabled ?? "false").toLowerCase() !== "true") + .filter((r) => String(r.inactive ?? "false").toLowerCase() !== "true") + .sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0] + const defaultGateway = String(defaultRoute?.gateway ?? "").trim() || null + const immediateGw = String(defaultRoute?.["immediate-gw"] ?? "").trim() || null + const defaultInterface = + ((immediateGw?.includes("%") ? immediateGw.split("%")[1]?.trim() : "")) + || String(defaultRoute?.interface ?? "").trim() + || String( + dhcpRaw.find((d) => toIp(d.gateway) != null && toIp(d.gateway) === toIp(defaultGateway))?.interface ?? "", + ).trim() + || null + const uplinks = (server.wanUplinks ?? []).map((w) => { + const iface = String(w.iface ?? "").trim() + const dhcp = dhcpRaw.find((d) => norm(d.interface) === norm(iface)) + const leasedIp = + toIp(dhcp?.address) + ?? toIp(ipAddrs.find((a) => norm(a.interface) === norm(iface))?.address) + ?? null + return { + id: w.id, + iface, + name: w.name, + isp: w.isp, + configuredIp: w.ip, + leasedIp, + dhcpStatus: String(dhcp?.status ?? "").trim() || null, + isDefault: defaultInterface != null && norm(defaultInterface) === norm(iface), + } + }) + return { + defaultGateway, + defaultInterface, + uplinks, + } +} + +function mapSpeedProbes() { + const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all() + return rows.map((r) => ({ + id: r.id, + srcServerId: String(r.srcServerId), + dstServerId: String(r.dstServerId), + srcInterface: r.srcInterface || "", + dstInterface: r.dstInterface || "", + protocol: r.protocol === "udp" ? "udp" : "tcp", + direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both", + durationSec: String(Math.max(3, r.durationSec || 10)), + enabled: r.enabled !== false, + lastRunAt: r.lastRunAt ?? null, + lastTxAvgMbps: r.lastTxAvgMbps ?? null, + lastRxAvgMbps: r.lastRxAvgMbps ?? null, + lastStatus: r.lastStatus ?? null, + lastError: r.lastError ?? null, + lastPingRttMs: r.lastPingRttMs ?? null, + lastPingLossPct: r.lastPingLossPct ?? null, + lastPingAt: r.lastPingAt ?? null, + lastPingError: r.lastPingError ?? null, + })) +} + +function parseInnerIps(comment: string): { localInnerIp: string; remoteInnerIp: string } { + const local = comment.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? "" + const remote = comment.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? "" + return { localInnerIp: local, remoteInnerIp: remote } +} + +async function collectGreTunnels() { + const enabled = db.select().from(servers).where(eq(servers.enabled, true)).all() + const all = await Promise.all(enabled.map(async (srv) => { + try { + const client = MikrotikClient.fromServer(srv) + const rows = await client.get>>("/interface/gre") + return rows.map((g, idx) => { + const keepalive = String(g.keepalive ?? "0,0").split(",") + const inner = parseInnerIps(String(g.comment ?? "")) + return { + id: String(g.name ?? g[".id"] ?? `gre-${srv.id}-${idx}`), + name: String(g.name ?? `gre-${idx + 1}`), + serverId: String(srv.id), + localAddress: String(g["local-address"] ?? ""), + remoteAddress: String(g["remote-address"] ?? ""), + localInnerIp: inner.localInnerIp, + remoteInnerIp: inner.remoteInnerIp, + poolId: "live", + ipsec: null, + mtu: Number.parseInt(String(g.mtu ?? "1476"), 10) || 1476, + keepaliveInterval: Number.parseInt(String(keepalive[0] ?? "0"), 10) || 0, + keepaliveRetries: Number.parseInt(String(keepalive[1] ?? "0"), 10) || 0, + dscp: "inherit" as const, + clampTcpMss: String(g["clamp-tcp-mss"] ?? "true") !== "false", + allowFastPath: String(g["allow-fast-path"] ?? "true") !== "false", + comment: String(g.comment ?? ""), + enabled: String(g.disabled ?? "false") !== "true", + status: + String(g.disabled ?? "false") === "true" + ? "down" as const + : (String(g.running ?? "false") === "true" ? "up" as const : "degraded" as const), + } + }) + } catch { + return [] + } + })) + return all.flat() +} + +export function getInternetPathSettings() { + return getSettingsRow() +} + +export function updateInternetPathSettings(patch: { enabled?: boolean; intervalSec?: number; retentionDays?: number }) { + const prev = getSettingsRow() + db.update(internetPathSettings).set({ + enabled: patch.enabled ?? prev.enabled, + intervalSec: patch.intervalSec ?? prev.intervalSec, + retentionDays: patch.retentionDays ?? prev.retentionDays, + updatedAt: new Date().toISOString(), + }).where(eq(internetPathSettings.id, 1)).run() + return getSettingsRow() +} + +export function getLatestInternetPathSnapshot() { + return db.select().from(internetPathSnapshots).orderBy(asc(internetPathSnapshots.id)).all().at(-1) ?? null +} + +export async function collectInternetPathSnapshotOnce(): Promise { + const sampledAt = new Date().toISOString() + if (collecting) { + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "internet_path", + sampledAt, + homes: 0, + snapshotSaved: false, + } + } + collecting = true + const started = Date.now() + const settings = getSettingsRow() + try { + const serversRead = listServersRead() + const homes = serversRead.filter((s) => s.type === "home-router") + const [greTunnels, rulesets] = await Promise.all([collectGreTunnels(), Promise.resolve(buildRulesets())]) + const speedProbes = mapSpeedProbes() + const routeLookupByServerId: Record = {} + const wanRuntimeByHomeId: Record = {} + for (const h of homes) { + routeLookupByServerId[String(h.id)] = await readRouteLookup(Number(h.id)).catch(() => ({ gateway: null, routingMark: null })) + wanRuntimeByHomeId[String(h.id)] = await readWanRuntime(Number(h.id)).catch(() => null) + } + const payload = { + sampledAt, + internetTarget: INTERNET_TARGET, + servers: serversRead, + greTunnels, + filtersRulesets: rulesets, + speedProbes, + routeLookupByServerId, + wanRuntimeByHomeId, + } + db.insert(internetPathSnapshots).values({ + sampledAt, + payloadJson: JSON.stringify(payload), + }).run() + cleanupSnapshots(Math.max(1, settings.retentionDays)) + db.update(internetPathSettings).set({ + lastCollectedAt: sampledAt, + lastDurationMs: Date.now() - started, + lastError: "", + updatedAt: sampledAt, + }).where(eq(internetPathSettings.id, 1)).run() + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "internet_path", + sampledAt, + homes: homes.length, + snapshotSaved: true, + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + db.update(internetPathSettings).set({ + lastCollectedAt: sampledAt, + lastDurationMs: Date.now() - started, + lastError: msg, + updatedAt: sampledAt, + }).where(eq(internetPathSettings.id, 1)).run() + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "internet_path", + sampledAt, + homes: 0, + snapshotSaved: false, + fatalError: msg, + } + } finally { + collecting = false + } +} + +export function isInternetPathCollecting(): boolean { + return collecting +} diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index 480e25d..51ce47e 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -38,6 +38,10 @@ import { scheduleAlertEngineAfterDataCollectors, wireAlertEngineRunner, } from "./alert-collector-hooks.js" +import { + collectInternetPathSnapshotOnce, + getInternetPathSettings, +} from "./internet-path-collector.js" import { endSchedulerJob, isSchedulerJobRunning, @@ -51,6 +55,7 @@ export const JOB_KEYS = [ "uptime_resources", "uptime_ping", "uptime_speed", + "internet_path", "gre_bgp", "alert_engine", ] as const @@ -111,6 +116,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise { case "gre_bgp": snapshot = await collectGreBgpSnapshotOnce() break + case "internet_path": + snapshot = await collectInternetPathSnapshotOnce() + break case "alert_engine": { const r = await runAlertEngineOnce() snapshot = { @@ -158,6 +166,7 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise { jobKey === "uptime_resources" || jobKey === "uptime_ping" || jobKey === "uptime_speed" || + jobKey === "internet_path" || jobKey === "gre_bgp" ) { scheduleAlertEngineAfterDataCollectors() @@ -243,6 +252,7 @@ export function refreshScheduler(): void { } const apiPing = getServersApiPingSettings() + const internetPath = getInternetPathSettings() if (apiPing.enabled) { const apiMs = Math.max(10_000, apiPing.intervalSec * 1000) void executeSchedulerJob("servers_rest_ping").catch(() => {}) @@ -292,6 +302,17 @@ export function refreshScheduler(): void { ) } + if (internetPath.enabled) { + const internetPathMs = Math.max(30_000, internetPath.intervalSec * 1000) + void executeSchedulerJob("internet_path").catch(() => {}) + timers.set( + "internet_path", + setInterval(() => { + void executeSchedulerJob("internet_path").catch(() => {}) + }, internetPathMs), + ) + } + const greBgpMs = 30_000 void executeSchedulerJob("gre_bgp").catch(() => {}) timers.set( @@ -331,6 +352,7 @@ export function getSchedulerStatus() { const traffic = getTrafficSettings() const uptime = getUptimeSettings() const apiPing = getServersApiPingSettings() + const internetPath = getInternetPathSettings() const resOn = uptime.resourcesEnabled ?? uptime.enabled const pingOn = uptime.pingEnabled ?? uptime.enabled @@ -342,6 +364,7 @@ export function getSchedulerStatus() { uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec }, uptime_ping: { enabled: pingOn, intervalSec: uptime.probeIntervalSec }, uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec }, + internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec }, gre_bgp: { enabled: true, intervalSec: 30 }, alert_engine: { enabled: true, intervalSec: 20 }, } diff --git a/backend/src/types/scheduler-run-snapshot.ts b/backend/src/types/scheduler-run-snapshot.ts index bdfdb9e..da0032d 100644 --- a/backend/src/types/scheduler-run-snapshot.ts +++ b/backend/src/types/scheduler-run-snapshot.ts @@ -183,6 +183,15 @@ export interface GreBgpSnapshotRunSnapshot { errors?: string[] } +export interface InternetPathRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "internet_path" + sampledAt: string + homes: number + snapshotSaved: boolean + fatalError?: string +} + export type SchedulerRunSnapshot = | TrafficRunSnapshot | ResourcesRunSnapshot @@ -190,4 +199,5 @@ export type SchedulerRunSnapshot = | SpeedScheduledRunSnapshot | ServersRestPingRunSnapshot | GreBgpSnapshotRunSnapshot + | InternetPathRunSnapshot | AlertEngineRunSnapshot diff --git a/components/dashboard/internet-path-map.tsx b/components/dashboard/internet-path-map.tsx new file mode 100644 index 0000000..ea65a41 --- /dev/null +++ b/components/dashboard/internet-path-map.tsx @@ -0,0 +1,411 @@ +"use client" + +import { useMemo, useState } from "react" +import { Flag } from "@/components/flag" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { StatusBadge } from "@/components/status-badge" +import { cn } from "@/lib/utils" +import type { InternetPathViewModel } from "@/lib/dashboard-internet-path" +import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react" + +type Pt = { x: number; y: number } +type ServerKind = "home-router" | "jump-host" | "exit-node" + +const W = 1060 +const H = 420 +const ZOOM_MIN = 0.2 +const ZOOM_MAX = 6 + +const STATUS_STYLE = { + online: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.15)" }, + degraded: { fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.15)" }, + offline: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" }, +} + +const TYPE_STYLE: Record = { + "home-router": { label: "HR", fill: "#16a34a", r: 40 }, + "jump-host": { label: "JH", fill: "#7c3aed", r: 34 }, + "exit-node": { label: "EN", fill: "#0369a1", r: 30 }, +} + +const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"] + +function pingColor(ms: number | null) { + if (ms == null) return "#f87171" + if (ms < 15) return "#4ade80" + if (ms < 50) return "#fbbf24" + return "#fb923c" +} + +function edgeBadgePosition(x1: number, y1: number, x2: number, y2: number, t: number, normalPx: number): { mx: number; my: number } { + const px = x1 + (x2 - x1) * t + const py = y1 + (y2 - y1) * t + const dx = x2 - x1 + const dy = y2 - y1 + const len = Math.hypot(dx, dy) || 1 + const nx = -dy / len + const ny = dx / len + return { mx: px + nx * normalPx, my: py + ny * normalPx } +} + +function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: { + zoom: number + onZoomIn: () => void + onZoomOut: () => void + onFit: () => void +}) { + return ( +
+ + + {Math.round(zoom * 100)}% + + +
+ +
+ ) +} + +function PingBadge({ + mx, + my, + ping, + dl, + ul, + color, + monitored, +}: { + mx: number + my: number + ping: number | null + dl: number | null + ul: number | null + color: string + monitored?: boolean +}) { + const hasSpeed = dl != null || ul != null + const dlText = dl != null ? Math.round(dl) : "—" + const ulText = ul != null ? Math.round(ul) : "—" + return ( + + + + {ping == null ? "—" : `${ping} мс`} + + {hasSpeed && ( + + {`↓${dlText} ↑${ulText}`} + + )} + {monitored && ( + + mon + + )} + + ) +} + +function ServerNode({ + pos, + type, + name, + site, + country, + status, + selected, + onClick, +}: { + pos: Pt + type: ServerKind + name: string + site: string + country: string + status: "online" | "offline" | "degraded" + selected: boolean + onClick: () => void +}) { + const ss = STATUS_STYLE[status] + const ts = TYPE_STYLE[type] + return ( + + {status === "online" && ( + + + + + )} + {selected && ( + + )} + + + + {ts.label} + + +
)}> + + + + + {site || name} + +
+
+ + {name} + +
+ ) +} + +export function InternetPathMapCard({ model }: { model: InternetPathViewModel | null }) { + const [zoom, setZoom] = useState(1) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = useState(false) + const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null) + const [selectedNode, setSelectedNode] = useState<"home" | "jh" | "en" | "wan" | null>("home") + + const mapData = useMemo(() => { + if (!model) return null + const hop = model.currentHop ?? model.primaryHop ?? ( + model.activeWanUplink && model.fallbackJumpHost && model.fallbackExitNode + ? { + home: model.homeRouter, + wan: model.activeWanUplink, + jumpHost: model.fallbackJumpHost, + exitNode: model.fallbackExitNode, + } + : null + ) + if (!hop) return null + const axisY = 220 + const homePos = { x: 180, y: axisY } + const wanPos = { x: 400, y: axisY } + const jhPos = { x: 660, y: axisY } + const enPos = { x: 900, y: axisY } + const directPos = { x: 900, y: axisY + 110 } + const pathDiff = + model.primaryPath && + model.currentPath && + (model.primaryPath.wanId !== model.currentPath.wanId || model.primaryPath.jhId !== model.currentPath.jhId || model.primaryPath.exitId !== model.currentPath.exitId) + return { hop, homePos, wanPos, jhPos, enPos, directPos, pathDiff, directWan: model.directWan } + }, [model]) + + function onWheel(e: React.WheelEvent) { + e.preventDefault() + const rect = e.currentTarget.getBoundingClientRect() + const px = e.clientX - rect.left + const py = e.clientY - rect.top + const worldX = (px - pan.x) / zoom + const worldY = (py - pan.y) / zoom + const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1 + const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom * factor)) + setZoom(next) + setPan({ x: px - worldX * next, y: py - worldY * next }) + } + + return ( + + +
+
+ Internet path map +

+ Основной и текущий путь трафика HomeRouter → Internet +

+
+ +
+
+ + {!model && ( +
+ Недостаточно данных для построения маршрута +
+ )} + {model && mapData && ( +
{ + // Когда курсор над картой, колесо управляет только картой (без прокрутки страницы). + e.preventDefault() + e.stopPropagation() + }} + > + { + setIsDragging(true) + setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }) + }} + onMouseMove={(e) => { + if (!isDragging || !dragStart) return + setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }) + }} + onMouseUp={() => { setIsDragging(false); setDragStart(null) }} + onMouseLeave={() => { setIsDragging(false); setDragStart(null) }} + onWheel={onWheel} + > + + + + + {mapData.directWan.enabled && ( + + )} + + setSelectedNode("wan")} style={{ cursor: "pointer" }}> + + + + + + {mapData.hop.wan.name} + + + + setSelectedNode("home")} + /> + {mapData.directWan.enabled && ( + + + NET + + {mapData.directWan.provider ?? "Direct WAN"} + + + )} + setSelectedNode("jh")} + /> + setSelectedNode("en")} + /> + + + + {mapData.directWan.enabled && ( + + )} + + + setZoom((z) => Math.max(ZOOM_MIN, z * 0.9))} + onZoomIn={() => setZoom((z) => Math.min(ZOOM_MAX, z * 1.1))} + onFit={() => { setZoom(1); setPan({ x: 0, y: 0 }) }} + /> +
+ )} + {model && !mapData && ( +
+ Нет полного набора узлов для визуализации пути +
+ )} + {model && ( +
+
+

Primary path

+

{model.primaryPath?.reason ?? "Не определен"}

+
+
+

Current path

+

{model.currentPath?.reason ?? "Не определен"}

+
+ {model.directWan.enabled && ( +
+

Direct WAN path

+

+ {`gateway: ${model.directWan.gateway ?? "—"} · iface: ${model.directWan.iface ?? "—"} · dhcp ip: ${model.directWan.leasedIp ?? "—"} · isp: ${model.directWan.provider ?? "—"}`} +

+
+ )} +
+ )} +
+
+ ) +} diff --git a/lib/dashboard-internet-path.ts b/lib/dashboard-internet-path.ts new file mode 100644 index 0000000..95d4828 --- /dev/null +++ b/lib/dashboard-internet-path.ts @@ -0,0 +1,465 @@ +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, + 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 + }> +} + +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 + } + 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))) + if (byIface) return byIface + const byIp = wans.find((w) => norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`)) + if (byIp) return byIp + } + return wans[0] ?? null +} + +function syntheticWanForHome(home: Server): WanUplink { + return { + id: `wan-auto-${home.id}`, + name: "WAN-AUTO", + isp: "auto", + iface: "auto", + ip: home.host || "0.0.0.0", + maxDl: 100, + maxUl: 100, + } +} + +function bestJhForWan( + home: Server, + wan: WanUplink, + jhs: Server[], + probes: RouteOptimizerSpeedProbe[], +): 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: RouteOptimizerSpeedProbe[]): 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: RouteOptimizerSpeedProbe[], +): 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: RouteOptimizerSpeedProbe[]): 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 +} + +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[] + filtersRulesets: FiltersRulesetRow[] + routeLookupByServerId: Record + wanRuntimeByHomeId?: Record +}): 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 === "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 activeWan = chooseActiveWan( + home, + args.routeLookupByServerId[home.id] ?? null, + runtime, + ) ?? syntheticWanForHome(home) + 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"] = + 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: { + pingMs: primaryJh.latency, + dlMbps: primaryWan.maxDl, + ulMbps: primaryWan.maxUl, + fromMonitoring: false, + }, + jhExitMetrics: { + pingMs: primaryEx.latency, + dlMbps: null, + ulMbps: null, + fromMonitoring: false, + }, + } + : null + + const currentHop: InternetPathHop | null = + currentWan && currentJh && currentExit + ? { + home, + 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, + } + })(), + } + : 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: (path: string, init?: RequestInit) => Promise, + serverId: string, +): Promise { + try { + const payload = await apiFetch<{ output?: string }>(`/api/servers/${serverId}/probes/run`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tool: "route", + target: INTERNET_TARGET, + }), + }) + return parseRouteLookupOutput(payload.output ?? "") + } catch { + return null + } +} diff --git a/lib/scheduler-run-snapshot.ts b/lib/scheduler-run-snapshot.ts index 0f4ffd7..d33595c 100644 --- a/lib/scheduler-run-snapshot.ts +++ b/lib/scheduler-run-snapshot.ts @@ -63,6 +63,15 @@ export interface GreBgpSnapshotRunSnapshot { errors?: string[] } +export interface InternetPathRunSnapshot { + v: number + job: "internet_path" + sampledAt: string + homes: number + snapshotSaved: boolean + fatalError?: string +} + export type SchedulerRunSnapshot = | TrafficRunSnapshot | ResourcesRunSnapshot @@ -70,6 +79,7 @@ export type SchedulerRunSnapshot = | SpeedScheduledRunSnapshot | ServersRestPingRunSnapshot | GreBgpSnapshotRunSnapshot + | InternetPathRunSnapshot | AlertEngineRunSnapshot export interface TrafficServerSnapshot { diff --git a/lib/scheduler-settings.ts b/lib/scheduler-settings.ts index b0a1bc9..2fd24e1 100644 --- a/lib/scheduler-settings.ts +++ b/lib/scheduler-settings.ts @@ -7,6 +7,7 @@ export const SCHEDULER_JOB_KEYS = [ "uptime_resources", "uptime_ping", "uptime_speed", + "internet_path", "gre_bgp", "alert_engine", ] as const @@ -18,6 +19,7 @@ export const SCHEDULER_JOB_LABELS: Record = { uptime_resources: "Uptime: ресурсы", uptime_ping: "Uptime: ping", uptime_speed: "Uptime: speed", + internet_path: "Internet Path", gre_bgp: "GRE + BGP", alert_engine: "Оповещения", } @@ -30,6 +32,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record = { uptime_resources: "CPU, память, температура и др. метрики с устройств.", uptime_ping: "ICMP-пинг по настроенным пробам мониторинга.", uptime_speed: "Фоновые btest / speed-пробы между узлами (при включённых пробах).", + internet_path: "Снимок данных для карты интернет-маршрута на dashboard (WAN/JH/EN, route+runtime, speed-пробы).", gre_bgp: "Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.", alert_engine: