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>