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:
@@ -10,6 +10,7 @@ import { StatusBadge } from "@/components/status-badge"
|
|||||||
import { Sparkline } from "@/components/sparkline"
|
import { Sparkline } from "@/components/sparkline"
|
||||||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||||
|
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
|
||||||
import {
|
import {
|
||||||
servers as mockServers,
|
servers as mockServers,
|
||||||
pingProbes,
|
pingProbes,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
serverFilterRulesets,
|
serverFilterRulesets,
|
||||||
} from "@/lib/data"
|
} from "@/lib/data"
|
||||||
import type { PingProbe, Server, ServerStatus, ServerType } 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 { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
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 { cn } from "@/lib/utils"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
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 { listEvents } from "@/shared/api/events"
|
||||||
import type { EventItem } from "@/packages/contracts/src/events"
|
import type { EventItem } from "@/packages/contracts/src/events"
|
||||||
|
|
||||||
@@ -135,9 +144,85 @@ interface BackendServerRow {
|
|||||||
os: string | null
|
os: string | null
|
||||||
model: string | null
|
model: string | null
|
||||||
sessions?: number
|
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 {
|
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 {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name || s.host,
|
||||||
@@ -152,6 +237,7 @@ function mapBackendToServer(s: BackendServerRow): Server {
|
|||||||
status: (s.status ?? "offline") as ServerStatus,
|
status: (s.status ?? "offline") as ServerStatus,
|
||||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||||
sessions: s.sessions ?? 0,
|
sessions: s.sessions ?? 0,
|
||||||
|
wanUplinks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +301,9 @@ export default function DashboardPage() {
|
|||||||
const [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
const [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
||||||
const [eventsLoading, setEventsLoading] = useState(false)
|
const [eventsLoading, setEventsLoading] = useState(false)
|
||||||
const [eventsError, setEventsError] = useState<string | null>(null)
|
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(() => {
|
const probeServerCatalog = useMemo(() => {
|
||||||
if (!isLive) return mockServers
|
if (!isLive) return mockServers
|
||||||
@@ -224,20 +313,26 @@ export default function DashboardPage() {
|
|||||||
const fetchProbes = useCallback(async (silent: boolean) => {
|
const fetchProbes = useCallback(async (silent: boolean) => {
|
||||||
if (!isLive) return
|
if (!isLive) return
|
||||||
if (!silent) setProbesLoading(true)
|
if (!silent) setProbesLoading(true)
|
||||||
|
if (!silent) setInternetPathLoading(true)
|
||||||
try {
|
try {
|
||||||
const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h")
|
const overview = await apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h")
|
||||||
setLiveProbes(overview.probes)
|
setLiveProbes(overview.probes)
|
||||||
setProbesError(null)
|
setProbesError(null)
|
||||||
|
setInternetPathError(null)
|
||||||
|
let serversMapped: Server[] = []
|
||||||
try {
|
try {
|
||||||
const backendServers = await apiFetch<BackendServerRow[]>("/api/servers")
|
const backendServers = await apiFetch<BackendServerRow[]>("/api/servers")
|
||||||
setLiveServersResolved(backendServers.map(mapBackendToServer))
|
serversMapped = backendServers.map(mapBackendToServer)
|
||||||
|
setLiveServersResolved(serversMapped)
|
||||||
} catch {
|
} catch {
|
||||||
|
serversMapped = []
|
||||||
setLiveServersResolved([])
|
setLiveServersResolved([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const [fr, br] = await Promise.allSettled([
|
const [fr, br, ipRes] = await Promise.allSettled([
|
||||||
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
||||||
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
|
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
|
||||||
|
apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"),
|
||||||
])
|
])
|
||||||
|
|
||||||
let filtersPart: LiveKpiSnapshot["filters"] = null
|
let filtersPart: LiveKpiSnapshot["filters"] = null
|
||||||
@@ -263,16 +358,69 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLiveKpi({ filters: filtersPart, bgp: bgpPart })
|
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) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы"
|
const msg = e instanceof Error ? e.message : "Не удалось загрузить пробы"
|
||||||
setProbesError(msg)
|
setProbesError(msg)
|
||||||
setLiveKpi(null)
|
setLiveKpi(null)
|
||||||
|
setInternetPathError(msg)
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLiveProbes(null)
|
setLiveProbes(null)
|
||||||
setLiveServersResolved(null)
|
setLiveServersResolved(null)
|
||||||
|
setInternetPath(null)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setProbesLoading(false)
|
if (!silent) setProbesLoading(false)
|
||||||
|
if (!silent) setInternetPathLoading(false)
|
||||||
}
|
}
|
||||||
}, [apiFetch, isLive])
|
}, [apiFetch, isLive])
|
||||||
|
|
||||||
@@ -302,6 +450,8 @@ export default function DashboardPage() {
|
|||||||
setLiveServersResolved(null)
|
setLiveServersResolved(null)
|
||||||
setLiveKpi(null)
|
setLiveKpi(null)
|
||||||
setProbesError(null)
|
setProbesError(null)
|
||||||
|
setInternetPath(null)
|
||||||
|
setInternetPathError(null)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -807,6 +957,18 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 */}
|
{/* Ping probes table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type AlertEngineRuleDiagSnapshot,
|
type AlertEngineRuleDiagSnapshot,
|
||||||
type AlertEngineRunSnapshot,
|
type AlertEngineRunSnapshot,
|
||||||
type GreBgpSnapshotRunSnapshot,
|
type GreBgpSnapshotRunSnapshot,
|
||||||
|
type InternetPathRunSnapshot,
|
||||||
type PingRunSnapshot,
|
type PingRunSnapshot,
|
||||||
type ResourcesRunSnapshot,
|
type ResourcesRunSnapshot,
|
||||||
type SchedulerRunSnapshot,
|
type SchedulerRunSnapshot,
|
||||||
@@ -436,6 +437,33 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</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") {
|
if (snap.job === "alert_engine") {
|
||||||
const a = snap as AlertEngineRunSnapshot
|
const a = snap as AlertEngineRunSnapshot
|
||||||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
||||||
@@ -640,6 +668,7 @@ export default function DataCollectionPage() {
|
|||||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
|
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
|
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
|
||||||
|
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||||||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||||||
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
||||||
@@ -652,6 +681,8 @@ export default function DataCollectionPage() {
|
|||||||
const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true)
|
const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true)
|
||||||
const [draftPingEnabled, setDraftPingEnabled] = useState(true)
|
const [draftPingEnabled, setDraftPingEnabled] = useState(true)
|
||||||
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
|
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
|
||||||
|
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
|
||||||
|
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
|
||||||
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
||||||
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
||||||
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
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])
|
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
||||||
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
||||||
: "?limit=80"
|
: "?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/traffic/settings"),
|
||||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||||||
|
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
|
||||||
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||||||
])
|
])
|
||||||
setTrafficCollector(traffic)
|
setTrafficCollector(traffic)
|
||||||
setServersApiCollector(serversApi)
|
setServersApiCollector(serversApi)
|
||||||
setUptimeCollector(uptime)
|
setUptimeCollector(uptime)
|
||||||
|
setInternetPathCollector(internetPath)
|
||||||
setSchedulerRuns(runsRes.runs ?? [])
|
setSchedulerRuns(runsRes.runs ?? [])
|
||||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||||
@@ -705,6 +738,8 @@ export default function DataCollectionPage() {
|
|||||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
||||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
||||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||||||
|
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||||
|
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -717,6 +752,7 @@ export default function DataCollectionPage() {
|
|||||||
setTrafficCollector(null)
|
setTrafficCollector(null)
|
||||||
setServersApiCollector(null)
|
setServersApiCollector(null)
|
||||||
setUptimeCollector(null)
|
setUptimeCollector(null)
|
||||||
|
setInternetPathCollector(null)
|
||||||
setSchedulerRuns([])
|
setSchedulerRuns([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -744,8 +780,10 @@ export default function DataCollectionPage() {
|
|||||||
if (draftResourcesEnabled) n += 1
|
if (draftResourcesEnabled) n += 1
|
||||||
if (draftPingEnabled) n += 1
|
if (draftPingEnabled) n += 1
|
||||||
if (draftSpeedEnabled) n += 1
|
if (draftSpeedEnabled) n += 1
|
||||||
|
if (draftInternetPathEnabled) n += 1
|
||||||
return n
|
return n
|
||||||
}, [
|
}, [
|
||||||
|
draftInternetPathEnabled,
|
||||||
draftPingEnabled,
|
draftPingEnabled,
|
||||||
draftResourcesEnabled,
|
draftResourcesEnabled,
|
||||||
draftServersApiEnabled,
|
draftServersApiEnabled,
|
||||||
@@ -905,44 +943,52 @@ export default function DataCollectionPage() {
|
|||||||
? draftTrafficEnabled
|
? draftTrafficEnabled
|
||||||
: jobKey === "servers_rest_ping"
|
: jobKey === "servers_rest_ping"
|
||||||
? draftServersApiEnabled
|
? draftServersApiEnabled
|
||||||
: jobKey === "uptime_resources"
|
: jobKey === "uptime_resources"
|
||||||
? draftResourcesEnabled
|
? draftResourcesEnabled
|
||||||
: jobKey === "uptime_ping"
|
: jobKey === "uptime_ping"
|
||||||
? draftPingEnabled
|
? draftPingEnabled
|
||||||
: draftSpeedEnabled
|
: jobKey === "uptime_speed"
|
||||||
|
? draftSpeedEnabled
|
||||||
|
: draftInternetPathEnabled
|
||||||
const iv = fixedSchedule
|
const iv = fixedSchedule
|
||||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
? trafficIntervalDraft
|
? trafficIntervalDraft
|
||||||
: jobKey === "servers_rest_ping"
|
: jobKey === "servers_rest_ping"
|
||||||
? serversApiIntervalDraft
|
? serversApiIntervalDraft
|
||||||
: jobKey === "uptime_resources"
|
: jobKey === "uptime_resources"
|
||||||
? uptimeResourceIntervalDraft
|
? uptimeResourceIntervalDraft
|
||||||
: jobKey === "uptime_ping"
|
: jobKey === "uptime_ping"
|
||||||
? uptimeIntervalDraft
|
? uptimeIntervalDraft
|
||||||
: uptimeSpeedIntervalDraft
|
: jobKey === "uptime_speed"
|
||||||
|
? uptimeSpeedIntervalDraft
|
||||||
|
: internetPathIntervalDraft
|
||||||
const setIv = fixedSchedule
|
const setIv = fixedSchedule
|
||||||
? () => {}
|
? () => {}
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
? setTrafficIntervalDraft
|
? setTrafficIntervalDraft
|
||||||
: jobKey === "servers_rest_ping"
|
: jobKey === "servers_rest_ping"
|
||||||
? setServersApiIntervalDraft
|
? setServersApiIntervalDraft
|
||||||
: jobKey === "uptime_resources"
|
: jobKey === "uptime_resources"
|
||||||
? setUptimeResourceIntervalDraft
|
? setUptimeResourceIntervalDraft
|
||||||
: jobKey === "uptime_ping"
|
: jobKey === "uptime_ping"
|
||||||
? setUptimeIntervalDraft
|
? setUptimeIntervalDraft
|
||||||
: setUptimeSpeedIntervalDraft
|
: jobKey === "uptime_speed"
|
||||||
|
? setUptimeSpeedIntervalDraft
|
||||||
|
: setInternetPathIntervalDraft
|
||||||
const defSec = fixedSchedule
|
const defSec = fixedSchedule
|
||||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
? 30
|
? 30
|
||||||
: jobKey === "servers_rest_ping"
|
: jobKey === "servers_rest_ping"
|
||||||
? 120
|
? 120
|
||||||
: jobKey === "uptime_resources"
|
: jobKey === "uptime_resources"
|
||||||
? 300
|
? 300
|
||||||
: jobKey === "uptime_ping"
|
: jobKey === "uptime_ping"
|
||||||
? 15
|
? 15
|
||||||
: 60
|
: jobKey === "uptime_speed"
|
||||||
|
? 60
|
||||||
|
: 300
|
||||||
return (
|
return (
|
||||||
<tr key={jobKey} className="hover:bg-muted/40">
|
<tr key={jobKey} className="hover:bg-muted/40">
|
||||||
<td className="px-5 py-3 align-top">
|
<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 === "servers_rest_ping") setDraftServersApiEnabled(v)
|
||||||
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
|
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
|
||||||
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
|
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
|
||||||
else setDraftSpeedEnabled(v)
|
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
|
||||||
|
else setDraftInternetPathEnabled(v)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -1082,6 +1129,7 @@ export default function DataCollectionPage() {
|
|||||||
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
|
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
|
||||||
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
|
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
|
||||||
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
|
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", {
|
await apiFetch("/api/traffic/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1109,6 +1157,13 @@ export default function DataCollectionPage() {
|
|||||||
retentionDays: uRet,
|
retentionDays: uRet,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
await apiFetch("/api/internet-path/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: draftInternetPathEnabled,
|
||||||
|
intervalSec: ipInt,
|
||||||
|
}),
|
||||||
|
})
|
||||||
await loadCollectors()
|
await loadCollectors()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
@@ -1163,6 +1218,18 @@ export default function DataCollectionPage() {
|
|||||||
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
|
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
|
||||||
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
|
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -205,6 +205,26 @@ CREATE TABLE IF NOT EXISTS scheduler_runs (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
|
||||||
ON scheduler_runs(job_key, finished_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at TEXT NOT NULL,
|
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);
|
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(`
|
sqlite.exec(`
|
||||||
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
|
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
|
||||||
SELECT 1, '', ''
|
SELECT 1, '', ''
|
||||||
|
|||||||
@@ -464,6 +464,24 @@ export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
|
|||||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
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 ─────────────────────────────────────────────────────────────
|
// ── inferred types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Server = typeof servers.$inferSelect
|
export type Server = typeof servers.$inferSelect
|
||||||
@@ -481,6 +499,8 @@ export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
|
|||||||
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
|
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
|
||||||
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
||||||
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$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 SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
||||||
export type EventRow = typeof events.$inferSelect
|
export type EventRow = typeof events.$inferSelect
|
||||||
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import trafficRoutes from "./routes/traffic.js"
|
|||||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||||
import uptimeRoutes from "./routes/uptime.js"
|
import uptimeRoutes from "./routes/uptime.js"
|
||||||
import networkRoutes from "./routes/network.js"
|
import networkRoutes from "./routes/network.js"
|
||||||
|
import internetPathRoutes from "./routes/internet-path.js"
|
||||||
import evobgpRoutes from "./routes/evobgp.js"
|
import evobgpRoutes from "./routes/evobgp.js"
|
||||||
import probesRoutes from "./routes/probes.js"
|
import probesRoutes from "./routes/probes.js"
|
||||||
import schedulerRoutes from "./routes/scheduler.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(serversApiPingRoutes, { prefix: "/api" })
|
||||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||||
await app.register(networkRoutes, { prefix: "/api" })
|
await app.register(networkRoutes, { prefix: "/api" })
|
||||||
|
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||||
await app.register(probesRoutes, { prefix: "/api" })
|
await app.register(probesRoutes, { prefix: "/api" })
|
||||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -141,10 +141,19 @@ function fmtBandwidth(rows: Array<Record<string, string>>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): 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 matches = routes.filter((r) => {
|
||||||
const dst = String(r["dst-address"] ?? "").trim()
|
const dst = String(r["dst-address"] ?? "").trim()
|
||||||
if (!dst) return false
|
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)
|
return ipMatchesRoute(destIp, dst)
|
||||||
})
|
})
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
@@ -153,7 +162,14 @@ function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
|
|||||||
matches.sort((a, b) => {
|
matches.sort((a, b) => {
|
||||||
const da = parseDstRoute(String(a["dst-address"] ?? ""))
|
const da = parseDstRoute(String(a["dst-address"] ?? ""))
|
||||||
const db = parseDstRoute(String(b["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 best = matches[0]!
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
@@ -90,6 +90,142 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.send({ host: server.host, ipv4 })
|
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
|
// POST /api/servers
|
||||||
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
|
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
|
||||||
return reply.status(201).send(createServer(req.body as ServerCreateRequest))
|
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
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
scheduleAlertEngineAfterDataCollectors,
|
scheduleAlertEngineAfterDataCollectors,
|
||||||
wireAlertEngineRunner,
|
wireAlertEngineRunner,
|
||||||
} from "./alert-collector-hooks.js"
|
} from "./alert-collector-hooks.js"
|
||||||
|
import {
|
||||||
|
collectInternetPathSnapshotOnce,
|
||||||
|
getInternetPathSettings,
|
||||||
|
} from "./internet-path-collector.js"
|
||||||
import {
|
import {
|
||||||
endSchedulerJob,
|
endSchedulerJob,
|
||||||
isSchedulerJobRunning,
|
isSchedulerJobRunning,
|
||||||
@@ -51,6 +55,7 @@ export const JOB_KEYS = [
|
|||||||
"uptime_resources",
|
"uptime_resources",
|
||||||
"uptime_ping",
|
"uptime_ping",
|
||||||
"uptime_speed",
|
"uptime_speed",
|
||||||
|
"internet_path",
|
||||||
"gre_bgp",
|
"gre_bgp",
|
||||||
"alert_engine",
|
"alert_engine",
|
||||||
] as const
|
] as const
|
||||||
@@ -111,6 +116,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
|||||||
case "gre_bgp":
|
case "gre_bgp":
|
||||||
snapshot = await collectGreBgpSnapshotOnce()
|
snapshot = await collectGreBgpSnapshotOnce()
|
||||||
break
|
break
|
||||||
|
case "internet_path":
|
||||||
|
snapshot = await collectInternetPathSnapshotOnce()
|
||||||
|
break
|
||||||
case "alert_engine": {
|
case "alert_engine": {
|
||||||
const r = await runAlertEngineOnce()
|
const r = await runAlertEngineOnce()
|
||||||
snapshot = {
|
snapshot = {
|
||||||
@@ -158,6 +166,7 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
|||||||
jobKey === "uptime_resources" ||
|
jobKey === "uptime_resources" ||
|
||||||
jobKey === "uptime_ping" ||
|
jobKey === "uptime_ping" ||
|
||||||
jobKey === "uptime_speed" ||
|
jobKey === "uptime_speed" ||
|
||||||
|
jobKey === "internet_path" ||
|
||||||
jobKey === "gre_bgp"
|
jobKey === "gre_bgp"
|
||||||
) {
|
) {
|
||||||
scheduleAlertEngineAfterDataCollectors()
|
scheduleAlertEngineAfterDataCollectors()
|
||||||
@@ -243,6 +252,7 @@ export function refreshScheduler(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiPing = getServersApiPingSettings()
|
const apiPing = getServersApiPingSettings()
|
||||||
|
const internetPath = getInternetPathSettings()
|
||||||
if (apiPing.enabled) {
|
if (apiPing.enabled) {
|
||||||
const apiMs = Math.max(10_000, apiPing.intervalSec * 1000)
|
const apiMs = Math.max(10_000, apiPing.intervalSec * 1000)
|
||||||
void executeSchedulerJob("servers_rest_ping").catch(() => {})
|
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
|
const greBgpMs = 30_000
|
||||||
void executeSchedulerJob("gre_bgp").catch(() => {})
|
void executeSchedulerJob("gre_bgp").catch(() => {})
|
||||||
timers.set(
|
timers.set(
|
||||||
@@ -331,6 +352,7 @@ export function getSchedulerStatus() {
|
|||||||
const traffic = getTrafficSettings()
|
const traffic = getTrafficSettings()
|
||||||
const uptime = getUptimeSettings()
|
const uptime = getUptimeSettings()
|
||||||
const apiPing = getServersApiPingSettings()
|
const apiPing = getServersApiPingSettings()
|
||||||
|
const internetPath = getInternetPathSettings()
|
||||||
|
|
||||||
const resOn = uptime.resourcesEnabled ?? uptime.enabled
|
const resOn = uptime.resourcesEnabled ?? uptime.enabled
|
||||||
const pingOn = uptime.pingEnabled ?? uptime.enabled
|
const pingOn = uptime.pingEnabled ?? uptime.enabled
|
||||||
@@ -342,6 +364,7 @@ export function getSchedulerStatus() {
|
|||||||
uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec },
|
uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec },
|
||||||
uptime_ping: { enabled: pingOn, intervalSec: uptime.probeIntervalSec },
|
uptime_ping: { enabled: pingOn, intervalSec: uptime.probeIntervalSec },
|
||||||
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
|
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
|
||||||
|
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
|
||||||
gre_bgp: { enabled: true, intervalSec: 30 },
|
gre_bgp: { enabled: true, intervalSec: 30 },
|
||||||
alert_engine: { enabled: true, intervalSec: 20 },
|
alert_engine: { enabled: true, intervalSec: 20 },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,15 @@ export interface GreBgpSnapshotRunSnapshot {
|
|||||||
errors?: string[]
|
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 =
|
export type SchedulerRunSnapshot =
|
||||||
| TrafficRunSnapshot
|
| TrafficRunSnapshot
|
||||||
| ResourcesRunSnapshot
|
| ResourcesRunSnapshot
|
||||||
@@ -190,4 +199,5 @@ export type SchedulerRunSnapshot =
|
|||||||
| SpeedScheduledRunSnapshot
|
| SpeedScheduledRunSnapshot
|
||||||
| ServersRestPingRunSnapshot
|
| ServersRestPingRunSnapshot
|
||||||
| GreBgpSnapshotRunSnapshot
|
| GreBgpSnapshotRunSnapshot
|
||||||
|
| InternetPathRunSnapshot
|
||||||
| AlertEngineRunSnapshot
|
| AlertEngineRunSnapshot
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,15 @@ export interface GreBgpSnapshotRunSnapshot {
|
|||||||
errors?: string[]
|
errors?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InternetPathRunSnapshot {
|
||||||
|
v: number
|
||||||
|
job: "internet_path"
|
||||||
|
sampledAt: string
|
||||||
|
homes: number
|
||||||
|
snapshotSaved: boolean
|
||||||
|
fatalError?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SchedulerRunSnapshot =
|
export type SchedulerRunSnapshot =
|
||||||
| TrafficRunSnapshot
|
| TrafficRunSnapshot
|
||||||
| ResourcesRunSnapshot
|
| ResourcesRunSnapshot
|
||||||
@@ -70,6 +79,7 @@ export type SchedulerRunSnapshot =
|
|||||||
| SpeedScheduledRunSnapshot
|
| SpeedScheduledRunSnapshot
|
||||||
| ServersRestPingRunSnapshot
|
| ServersRestPingRunSnapshot
|
||||||
| GreBgpSnapshotRunSnapshot
|
| GreBgpSnapshotRunSnapshot
|
||||||
|
| InternetPathRunSnapshot
|
||||||
| AlertEngineRunSnapshot
|
| AlertEngineRunSnapshot
|
||||||
|
|
||||||
export interface TrafficServerSnapshot {
|
export interface TrafficServerSnapshot {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const SCHEDULER_JOB_KEYS = [
|
|||||||
"uptime_resources",
|
"uptime_resources",
|
||||||
"uptime_ping",
|
"uptime_ping",
|
||||||
"uptime_speed",
|
"uptime_speed",
|
||||||
|
"internet_path",
|
||||||
"gre_bgp",
|
"gre_bgp",
|
||||||
"alert_engine",
|
"alert_engine",
|
||||||
] as const
|
] as const
|
||||||
@@ -18,6 +19,7 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
|
|||||||
uptime_resources: "Uptime: ресурсы",
|
uptime_resources: "Uptime: ресурсы",
|
||||||
uptime_ping: "Uptime: ping",
|
uptime_ping: "Uptime: ping",
|
||||||
uptime_speed: "Uptime: speed",
|
uptime_speed: "Uptime: speed",
|
||||||
|
internet_path: "Internet Path",
|
||||||
gre_bgp: "GRE + BGP",
|
gre_bgp: "GRE + BGP",
|
||||||
alert_engine: "Оповещения",
|
alert_engine: "Оповещения",
|
||||||
}
|
}
|
||||||
@@ -30,6 +32,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
|||||||
uptime_resources: "CPU, память, температура и др. метрики с устройств.",
|
uptime_resources: "CPU, память, температура и др. метрики с устройств.",
|
||||||
uptime_ping: "ICMP-пинг по настроенным пробам мониторинга.",
|
uptime_ping: "ICMP-пинг по настроенным пробам мониторинга.",
|
||||||
uptime_speed: "Фоновые btest / speed-пробы между узлами (при включённых пробах).",
|
uptime_speed: "Фоновые btest / speed-пробы между узлами (при включённых пробах).",
|
||||||
|
internet_path: "Снимок данных для карты интернет-маршрута на dashboard (WAN/JH/EN, route+runtime, speed-пробы).",
|
||||||
gre_bgp:
|
gre_bgp:
|
||||||
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
|
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
|
||||||
alert_engine:
|
alert_engine:
|
||||||
|
|||||||
Reference in New Issue
Block a user