From b9a75b6831e4ef27726bedbab37f385366c33067 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 8 May 2026 00:40:41 +0700 Subject: [PATCH] feat: implement internet path functionality with backend support Added internet path settings and snapshot management to the application. This includes new database tables for internet path settings and snapshots, API routes for fetching and managing internet path data, and integration into the dashboard and data collection pages. Enhanced the scheduler to support internet path jobs, ensuring regular data collection and updates. Updated relevant types and interfaces to accommodate the new functionality. --- app/(main)/dashboard/page.tsx | 166 ++++++- app/(main)/data-collection/page.tsx | 87 +++- backend/src/db/index.ts | 26 + backend/src/db/schema.ts | 20 + backend/src/index.ts | 2 + backend/src/routes/internet-path.ts | 63 +++ backend/src/routes/probes.ts | 20 +- backend/src/routes/servers.ts | 136 +++++ .../src/services/internet-path-collector.ts | 312 ++++++++++++ backend/src/services/scheduler.ts | 23 + backend/src/types/scheduler-run-snapshot.ts | 10 + components/dashboard/internet-path-map.tsx | 411 ++++++++++++++++ lib/dashboard-internet-path.ts | 465 ++++++++++++++++++ lib/scheduler-run-snapshot.ts | 10 + lib/scheduler-settings.ts | 3 + 15 files changed, 1740 insertions(+), 14 deletions(-) create mode 100644 backend/src/routes/internet-path.ts create mode 100644 backend/src/services/internet-path-collector.ts create mode 100644 components/dashboard/internet-path-map.tsx create mode 100644 lib/dashboard-internet-path.ts 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: