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.
This commit is contained in:
Denozordec
2026-05-08 00:40:41 +07:00
parent 11ad94f67d
commit b9a75b6831
15 changed files with 1740 additions and 14 deletions
+164 -2
View File
@@ -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<string, { gateway: string | null; routingMark: string | null } | null>
wanRuntimeByHomeId: Record<string, HomeWanRuntime | null>
}
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<EventItem[]>([])
const [eventsLoading, setEventsLoading] = useState(false)
const [eventsError, setEventsError] = useState<string | null>(null)
const [internetPath, setInternetPath] = useState<InternetPathViewModel | null>(null)
const [internetPathLoading, setInternetPathLoading] = useState(false)
const [internetPathError, setInternetPathError] = useState<string | null>(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<BackendServerRow[]>("/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<Array<{ state?: string; prefixesRx?: number }>>("/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<HomeWanRuntime>(`/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() {
</Card>
</div>
<div className="grid grid-cols-1 gap-4">
{internetPathError && isLive && (
<div className="text-sm text-destructive">{internetPathError}</div>
)}
{internetPathLoading && isLive && !internetPath && (
<div className="h-24 rounded-md bg-muted/40 animate-pulse" />
)}
{(!isLive || internetPath || !internetPathLoading) && (
<InternetPathMapCard model={internetPath} />
)}
</div>
{/* Ping probes table */}
<Card>
<CardHeader className="pb-2">
+77 -10
View File
@@ -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 }) {
</div>
)
}
if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
</p>
<dl className="grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-muted-foreground">Home routers</dt>
<dd className="font-mono font-medium">{p.homes}</dd>
</div>
<div>
<dt className="text-muted-foreground">Сохранение snapshot</dt>
<dd className="font-mono font-medium">{p.snapshotSaved ? "ok" : "no"}</dd>
</div>
</dl>
{p.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {p.fatalError}</AlertDescription>
</Alert>
) : null}
</div>
)
}
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<CollectorSettingsDto | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(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<SchedulerRunRowDto[]>([])
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(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<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
apiFetch<CollectorSettingsDto>("/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 (
<tr key={jobKey} className="hover:bg-muted/40">
<td className="px-5 py-3 align-top">
@@ -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)
}}
/>
</span>
@@ -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() {
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
</p>
<p>
Internet Path snapshot:{" "}
{internetPathCollector?.lastCollectedAt
? new Date(internetPathCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {internetPathCollector?.lastDurationMs != null ? `${internetPathCollector.lastDurationMs} мс` : "—"}
</p>
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
{internetPathCollector?.lastError
? `Internet Path: ${internetPathCollector.lastError}`
: "Internet Path: ошибок нет"}
</p>
</div>
</div>
</CardContent>
+26
View File
@@ -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, '', ''
+20
View File
@@ -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
+2
View File
@@ -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" })
+63
View File
@@ -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
+18 -2
View File
@@ -141,10 +141,19 @@ function fmtBandwidth(rows: Array<Record<string, string>>): 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<string, unknown>)["routing-table"] ?? "").trim().toLowerCase()
if (!(rt === "" || rt === "main")) return false
if (String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() === "true") return false
if (String((r as unknown as Record<string, unknown>).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 = [
+136
View File
@@ -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<Array<Record<string, string>>>("/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<string, unknown>).active))
// Эквивалент CLI: routing-table=main
.filter((r) => {
const rt = String((r as unknown as Record<string, unknown>)["routing-table"] ?? "").trim().toLowerCase()
return rt === "" || rt === "main"
})
.filter((r) => String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String((r as unknown as Record<string, unknown>).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<string, unknown> | 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<string, unknown>).active ?? "").toLowerCase() === "true")
.filter((r) => String((r as unknown as Record<string, unknown>)["dst-address"] ?? "").trim() !== "0.0.0.0/0")
.filter((r) => String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String((r as unknown as Record<string, unknown>).inactive ?? "false").toLowerCase() !== "true")
.find((r) => routeHasIp(String((r as unknown as Record<string, unknown>)["dst-address"] ?? ""), gwIp))
)
const defaultInterface =
gatewayIface(immediateGw)
|| gatewayIface(defaultGateway)
|| String((dhcpIfaceByGateway as unknown as Record<string, unknown> | undefined)?.interface ?? "").trim()
|| String(defaultRoute?.interface ?? "").trim()
|| String((directIfaceByGwSubnet as unknown as Record<string, unknown> | 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))
@@ -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<Array<Record<string, string>>>("/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<Array<Record<string, string>>>("/ip/dhcp-client").catch(() => []),
client.get<Array<Record<string, string>>>("/ip/address").catch(() => []),
client.get<Array<Record<string, string>>>("/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<Array<Record<string, string>>>("/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<InternetPathRunSnapshot> {
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<string, { gateway: string | null; routingMark: string | null }> = {}
const wanRuntimeByHomeId: Record<string, unknown> = {}
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
}
+23
View File
@@ -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<void> {
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<void> {
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 },
}
@@ -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
+411
View File
@@ -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<ServerKind, { label: string; fill: string; r: number }> = {
"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 (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-0 rounded-xl border border-white/10 bg-black/75 backdrop-blur-md shadow-xl overflow-hidden">
<button onClick={onZoomOut} className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors">
<ZoomOutIcon className="size-3.5" />
</button>
<span className="px-3 py-2 text-xs font-mono text-white/70 min-w-[52px] text-center border-x border-white/10 select-none">
{Math.round(zoom * 100)}%
</span>
<button onClick={onZoomIn} className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors">
<ZoomInIcon className="size-3.5" />
</button>
<div className="w-px h-5 bg-white/10" />
<button onClick={onFit} className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors">
<Maximize2Icon className="size-3.5" />
</button>
</div>
)
}
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 (
<g transform={`translate(${mx},${my})`}>
<rect x="-34" y={-20} width="68" height={hasSpeed ? 42 : 24} rx="4" fill="#060d1a" stroke={color} strokeWidth="0.7" opacity="0.92" />
<text textAnchor="middle" y={hasSpeed ? "-5" : "4"} fontSize="8.5" fontWeight="600" fill={color} fontFamily="ui-monospace,monospace">
{ping == null ? "—" : `${ping} мс`}
</text>
{hasSpeed && (
<text textAnchor="middle" y="8" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
{`${dlText}${ulText}`}
</text>
)}
{monitored && (
<text textAnchor="middle" y={hasSpeed ? "17" : "14"} fontSize="6" fill="#38bdf8" fontFamily="ui-monospace,monospace">
mon
</text>
)}
</g>
)
}
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 (
<g transform={`translate(${pos.x},${pos.y})`} style={{ cursor: "pointer" }} onClick={onClick}>
{status === "online" && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
<animate attributeName="r" values={`${ts.r + 10};${ts.r + 18};${ts.r + 10}`} dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.8;0.15;0.8" dur="3s" repeatCount="indefinite" />
</circle>
)}
{selected && (
<circle r={ts.r + 10} fill="none" stroke="rgba(255,255,255,0.45)" strokeWidth="1.5" strokeDasharray="4 3" />
)}
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={selected ? 2.5 : 1.8} />
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
<circle r="10" fill={ts.fill} />
<text textAnchor="middle" y="4" fontSize="6.5" fontWeight="bold" fill="#fff">{ts.label}</text>
</g>
<foreignObject x={-110} y={-22} width={220} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, width: "100%", height: "100%", fontFamily: "ui-monospace, monospace" }} {...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}>
<span style={{ flexShrink: 0, lineHeight: 0 }}>
<Flag code={country || "UN"} size={16} />
</span>
<span style={{ maxWidth: 170, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 700, fontSize: 11, color: "#f1f5f9", lineHeight: 1.25 }}>
{site || name}
</span>
</div>
</foreignObject>
<text textAnchor="middle" y={ts.r + 18} fontSize="8.5" fill="#94a3b8" fontFamily="ui-monospace,monospace">
{name}
</text>
</g>
)
}
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<SVGSVGElement>) {
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 (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base">Internet path map</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5 truncate">
Основной и текущий путь трафика HomeRouter Internet
</p>
</div>
<StatusBadge
status={
model?.pathState === "healthy"
? "online"
: model?.pathState === "failover"
? "degraded"
: "offline"
}
/>
</div>
</CardHeader>
<CardContent className="pt-0">
{!model && (
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
Недостаточно данных для построения маршрута
</div>
)}
{model && mapData && (
<div
className="relative rounded-lg border border-[#1f2a3d] bg-gradient-to-b from-[#0a1424] to-[#060d17] overflow-hidden overscroll-contain"
onWheelCapture={(e) => {
// Когда курсор над картой, колесо управляет только картой (без прокрутки страницы).
e.preventDefault()
e.stopPropagation()
}}
>
<svg
viewBox={`0 0 ${W} ${H}`}
className={cn("w-full h-[320px] select-none", isDragging ? "cursor-grabbing" : "cursor-grab")}
onMouseDown={(e) => {
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}
>
<g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
<line x1={mapData.homePos.x} y1={mapData.homePos.y} x2={mapData.wanPos.x} y2={mapData.wanPos.y} stroke={WAN_COLORS[0]} strokeWidth="2.2" opacity="0.75" />
<line
x1={mapData.wanPos.x}
y1={mapData.wanPos.y}
x2={mapData.jhPos.x}
y2={mapData.jhPos.y}
stroke={mapData.pathDiff ? "#fbbf24" : "#4ade80"}
strokeWidth={3.2}
/>
<line
x1={mapData.jhPos.x}
y1={mapData.jhPos.y}
x2={mapData.enPos.x}
y2={mapData.enPos.y}
stroke={mapData.pathDiff ? "#fbbf24" : "#4ade80"}
strokeWidth={3.2}
/>
{mapData.directWan.enabled && (
<line
x1={mapData.wanPos.x}
y1={mapData.wanPos.y}
x2={mapData.directPos.x}
y2={mapData.directPos.y}
stroke="#38bdf8"
strokeWidth="2.4"
opacity="0.9"
/>
)}
<g transform={`translate(${mapData.wanPos.x},${mapData.wanPos.y})`} onClick={() => setSelectedNode("wan")} style={{ cursor: "pointer" }}>
<circle r={24} fill={`${WAN_COLORS[0]}1a`} stroke={WAN_COLORS[0]} strokeWidth="2" />
<path d="M -5 2 Q 0 -5 5 2" fill="none" stroke={WAN_COLORS[0]} strokeWidth="1.5" strokeLinecap="round" />
<path d="M -8 5 Q 0 -10 8 5" fill="none" stroke={WAN_COLORS[0]} strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<circle cx="0" cy="4" r="2" fill={WAN_COLORS[0]} />
<text textAnchor="middle" y="36" fontSize="8" fontWeight="700" fill={WAN_COLORS[0]} fontFamily="ui-monospace,monospace">
{mapData.hop.wan.name}
</text>
</g>
<ServerNode
pos={mapData.homePos}
type="home-router"
name={mapData.hop.home.name}
site={mapData.hop.home.site}
country={mapData.hop.home.country}
status={mapData.hop.home.status}
selected={selectedNode === "home"}
onClick={() => setSelectedNode("home")}
/>
{mapData.directWan.enabled && (
<g transform={`translate(${mapData.directPos.x},${mapData.directPos.y})`}>
<circle r={22} fill="#08243a" stroke="#38bdf8" strokeWidth="1.8" />
<text textAnchor="middle" y="4" fontSize="10" fontWeight="700" fill="#38bdf8">NET</text>
<text textAnchor="middle" y="34" fontSize="8" fill="#7dd3fc" fontFamily="ui-monospace,monospace">
{mapData.directWan.provider ?? "Direct WAN"}
</text>
</g>
)}
<ServerNode
pos={mapData.jhPos}
type="jump-host"
name={mapData.hop.jumpHost.name}
site={mapData.hop.jumpHost.site}
country={mapData.hop.jumpHost.country}
status={mapData.hop.jumpHost.status}
selected={selectedNode === "jh"}
onClick={() => setSelectedNode("jh")}
/>
<ServerNode
pos={mapData.enPos}
type="exit-node"
name={mapData.hop.exitNode.name}
site={mapData.hop.exitNode.site}
country={mapData.hop.exitNode.country}
status={mapData.hop.exitNode.status}
selected={selectedNode === "en"}
onClick={() => setSelectedNode("en")}
/>
<PingBadge
{...edgeBadgePosition(mapData.wanPos.x, mapData.wanPos.y, mapData.jhPos.x, mapData.jhPos.y, 0.52, -24)}
ping={mapData.hop.wanJhMetrics.pingMs}
dl={mapData.hop.wanJhMetrics.dlMbps}
ul={mapData.hop.wanJhMetrics.ulMbps}
color={pingColor(mapData.hop.wanJhMetrics.pingMs)}
monitored={mapData.hop.wanJhMetrics.fromMonitoring}
/>
<PingBadge
{...edgeBadgePosition(mapData.jhPos.x, mapData.jhPos.y, mapData.enPos.x, mapData.enPos.y, 0.5, -24)}
ping={mapData.hop.jhExitMetrics.pingMs}
dl={mapData.hop.jhExitMetrics.dlMbps}
ul={mapData.hop.jhExitMetrics.ulMbps}
color={pingColor(mapData.hop.jhExitMetrics.pingMs)}
monitored={mapData.hop.jhExitMetrics.fromMonitoring}
/>
{mapData.directWan.enabled && (
<PingBadge
{...edgeBadgePosition(mapData.wanPos.x, mapData.wanPos.y, mapData.directPos.x, mapData.directPos.y, 0.55, -22)}
ping={null}
dl={null}
ul={null}
color="#38bdf8"
monitored={false}
/>
)}
</g>
</svg>
<ZoomControls
zoom={zoom}
onZoomOut={() => 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 }) }}
/>
</div>
)}
{model && !mapData && (
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
Нет полного набора узлов для визуализации пути
</div>
)}
{model && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
<div className={cn("rounded-md border p-2", model.pathState === "failover" ? "border-amber-500/40 bg-amber-500/5" : "border-emerald-500/40 bg-emerald-500/5")}>
<p className="font-medium">Primary path</p>
<p className="text-muted-foreground mt-1">{model.primaryPath?.reason ?? "Не определен"}</p>
</div>
<div className={cn("rounded-md border p-2", model.pathState === "failover" ? "border-amber-500/40 bg-amber-500/5" : "border-emerald-500/40 bg-emerald-500/5")}>
<p className="font-medium">Current path</p>
<p className="text-muted-foreground mt-1">{model.currentPath?.reason ?? "Не определен"}</p>
</div>
{model.directWan.enabled && (
<div className="rounded-md border p-2 border-sky-500/40 bg-sky-500/5 md:col-span-2">
<p className="font-medium">Direct WAN path</p>
<p className="text-muted-foreground mt-1">
{`gateway: ${model.directWan.gateway ?? "—"} · iface: ${model.directWan.iface ?? "—"} · dhcp ip: ${model.directWan.leasedIp ?? "—"} · isp: ${model.directWan.provider ?? "—"}`}
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}
+465
View File
@@ -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<string, RouteLookupResult | null>
wanRuntimeByHomeId?: Record<string, HomeWanRuntime | null>
}): InternetPathViewModel | null {
const homes = args.servers.filter((s) => s.type === "home-router" && s.enabled)
const jhs = args.servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
const exits = args.servers.filter((s) => s.type === "exit-node" && s.enabled && s.status !== "offline")
const home = homes[0]
if (!home || !jhs.length || !exits.length) return null
const runtime = args.wanRuntimeByHomeId?.[home.id] ?? null
const optimizerRows: OptimizerApiServer[] = args.servers.map((s) => ({
id: Number.parseInt(s.id, 10) || 0,
name: s.name,
host: s.host,
type: s.type,
site: s.site,
country: s.country,
enabled: s.enabled,
status: s.status === "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: <T>(path: string, init?: RequestInit) => Promise<T>,
serverId: string,
): Promise<RouteLookupResult | null> {
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
}
}
+10
View File
@@ -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 {
+3
View File
@@ -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<string, string> = {
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<string, string> = {
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: