This commit is contained in:
henrygd
2026-04-20 21:24:46 -04:00
parent 3a881e1d5e
commit cef5ab10a5
17 changed files with 371 additions and 122 deletions

View File

@@ -1,26 +1,25 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect, useMemo } from "react"
import { memo, useEffect, useState } from "react"
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
import type { NetworkProbeRecord } from "@/types"
export default memo(() => {
const { t } = useLingui()
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
useEffect(() => {
document.title = `${t`Network Probes`} / Beszel`
}, [t])
return useMemo(
() => (
<>
<div className="grid gap-4">
<ActiveAlerts />
<NetworkProbesTableNew />
</div>
<FooterRepoLink />
</>
),
[]
return (
<>
<div className="grid gap-4">
<ActiveAlerts />
<NetworkProbesTableNew probes={probes} setProbes={setProbes} />
</div>
<FooterRepoLink />
</>
)
})

View File

@@ -11,13 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
import {
LazyContainersTable,
LazyNetworkProbesTable,
LazySmartTable,
LazySystemdTable,
LazyNetworkProbesTableNew,
} from "./system/lazy-tables"
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTableNew } from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart"
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
import { GpuIcon } from "../ui/icons"
@@ -153,7 +147,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{hasSystemd && <LazySystemdTable systemId={system.id} />}
<LazyNetworkProbesTableNew systemId={system.id} />
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
</>
@@ -203,7 +197,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div>
<LazyNetworkProbesTableNew systemId={system.id} />
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
</TabsContent>

View File

@@ -1,7 +1,7 @@
import { timeTicks } from "d3-time"
import { getPbTimestamp, pb } from "@/lib/api"
import { chartTimeData } from "@/lib/utils"
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
import type { ChartData, ChartTimes, ContainerStatsRecord, NetworkProbeStatsRecord, SystemStatsRecord } from "@/types"
type ChartTimeData = {
time: number
@@ -66,12 +66,12 @@ export function appendData<T extends { created: string | number | null }>(
return result
}
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkProbeStatsRecord>(
collection: string,
systemId: string,
chartTime: ChartTimes
chartTime: ChartTimes,
cachedStats?: { created: string | number | null }[]
): Promise<T[]> {
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
const lastCached = cachedStats?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {

View File

@@ -0,0 +1,80 @@
import LineChartDefault, { DataPoint } from "@/components/charts/line-chart"
import { pinnedAxisDomain } from "@/components/ui/chart"
import { toFixedFloat, decimalString } from "@/lib/utils"
import { useLingui } from "@lingui/react/macro"
import { ChartCard, FilterBar } from "../chart-card"
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
import { useMemo } from "react"
import { atom } from "nanostores"
import { useStore } from "@nanostores/react"
function probeKey(p: NetworkProbeRecord) {
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
return `${p.protocol}:${p.target}`
}
const $filter = atom("")
export function LatencyChart({
probeStats,
grid,
probes,
chartData,
empty,
}: {
probeStats: NetworkProbeStatsRecord[]
grid?: boolean
probes: NetworkProbeRecord[]
chartData: ChartData
empty: boolean
}) {
const { t } = useLingui()
const filter = useStore($filter)
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
const count = probes.length
return probes
.sort((a, b) => a.name.localeCompare(b.name))
.map((p, i) => {
const key = probeKey(p)
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return {
label: p.name || p.target,
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null,
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
strokeOpacity,
activeDot: !filtered,
}
})
}, [probes, filter])
return (
<ChartCard
legend
cornerEl={<FilterBar store={$filter} />}
empty={empty}
title={t`Latency`}
description={t`Average round-trip time (ms)`}
grid={grid}
>
<LineChartDefault
chartData={chartData}
customData={probeStats}
dataPoints={dataPoints}
domain={pinnedAxisDomain()}
connectNulls
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
legend
filter={filter}
/>
</ChartCard>
)
}

View File

@@ -1,6 +1,13 @@
import { lazy } from "react"
import { lazy, useEffect, useRef, useState } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
import { chartTimeData, cn } from "@/lib/utils"
import { NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
import { LatencyChart } from "./charts/probes-charts"
import { SystemData } from "./use-system-data"
import { $chartTime } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import system from "../system"
import { getStats, appendData } from "./chart-data"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
@@ -37,11 +44,75 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
const NetworkProbesTableNew = lazy(() => import("@/components/network-probes-table/network-probes-table"))
export function LazyNetworkProbesTableNew({ systemId }: { systemId: string }) {
const cache = new Map<string, any>()
export function LazyNetworkProbesTableNew({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
const { grid, chartData } = systemData ?? {}
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
const chartTime = useStore($chartTime)
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
const { isIntersecting, ref } = useIntersectionObserver()
const statsRequestId = useRef(0)
// get stats when system "changes." (Not just system to system,
// also when new info comes in via systemManager realtime connection, indicating an update)
useEffect(() => {
if (!systemId || !chartTime || chartTime === "1m") {
return
}
const { expectedInterval } = chartTimeData[chartTime]
const ss_cache_key = `${systemId}${chartTime}`
const requestId = ++statsRequestId.current
const cachedProbeStats = cache.get(ss_cache_key) as NetworkProbeStatsRecord[] | undefined
// Render from cache immediately if available
// if (cachedProbeStats?.length) {
// setProbeStats(cachedProbeStats)
// // Skip the fetch if the latest cached point is recent enough that no new point is expected yet
// const lastCreated = cachedProbeStats.at(-1)?.created as number | undefined
// if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
// return
// }
// }
getStats<NetworkProbeStatsRecord>("network_probe_stats", systemId, chartTime, cachedProbeStats).then(
(probeStats) => {
// If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) {
return
}
// make new system stats
let probeStatsData = (cache.get(ss_cache_key) || []) as NetworkProbeStatsRecord[]
if (probeStats.length) {
probeStatsData = appendData(probeStatsData, probeStats, expectedInterval, 100)
cache.set(ss_cache_key, probeStatsData)
}
setProbeStats(probeStatsData)
}
)
}, [system, chartTime, probes])
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <NetworkProbesTableNew systemId={systemId} />}
{isIntersecting && (
<>
<NetworkProbesTableNew systemId={systemId} probes={probes} setProbes={setProbes} />
{!!chartData && (
<LatencyChart
probeStats={probeStats}
grid={grid}
probes={probes}
chartData={chartData}
empty={!probeStats.length}
/>
)}
</>
)}
</div>
)
}

View File

@@ -7,7 +7,7 @@ import { chartTimeData, cn, toFixedFloat, decimalString, getVisualStringWidth }
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { appendData } from "./chart-data"
import { AddProbeDialog } from "./probe-dialog"
// import { AddProbeDialog } from "./probe-dialog"
import { ChartCard } from "./chart-card"
import LineChartDefault, { type DataPoint } from "@/components/charts/line-chart"
import { pinnedAxisDomain } from "@/components/ui/chart"
@@ -89,7 +89,7 @@ export default function NetworkProbes({
if (data[i].stats) {
const latest: Record<string, { avg: number; loss: number }> = {}
for (const [key, val] of Object.entries(data[i].stats)) {
latest[key] = { avg: val.avg, loss: val.loss }
latest[key] = { avg: val?.[0], loss: val?.[3] }
}
setLatestResults(latest)
break
@@ -110,13 +110,22 @@ export default function NetworkProbes({
const controller = new AbortController()
const { type: statsType = "1m", expectedInterval } = chartTimeData[chartTime] ?? {}
pb.send<{ stats: NetworkProbeStatsRecord["stats"]; created: string }[]>("/api/beszel/network-probe-stats", {
query: { system: systemId, type: statsType },
signal: controller.signal,
})
console.log("Fetching probe stats", { systemId, statsType, expectedInterval })
pb.collection<NetworkProbeStatsRecord>("network_probe_stats")
.getList(0, 2000, {
fields: "stats,created",
filter: pb.filter("system={:system} && type={:type} && created <= {:created}", {
system: systemId,
type: statsType,
created: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
}),
sort: "-created",
})
.then((raw) => {
console.log("Fetched probe stats", { raw })
// Filter stats to only include currently active probes
const mapped: NetworkProbeStatsRecord[] = raw.map((r) => {
const mapped: NetworkProbeStatsRecord[] = raw.items.map((r) => {
const filtered: NetworkProbeStatsRecord["stats"] = {}
for (const [key, val] of Object.entries(r.stats)) {
if (activeProbeKeys.has(key)) {
@@ -132,12 +141,15 @@ export default function NetworkProbes({
const last = mapped[mapped.length - 1].stats
const latest: Record<string, { avg: number; loss: number }> = {}
for (const [key, val] of Object.entries(last)) {
latest[key] = { avg: val.avg, loss: val.loss }
latest[key] = { avg: val?.[0], loss: val?.[3] }
}
setLatestResults(latest)
}
})
.catch(() => setStats([]))
.catch((e) => {
console.error("Error fetching probe stats", e)
setStats([])
})
return () => controller.abort()
}, [system, chartTime, probes, activeProbeKeys])
@@ -160,7 +172,7 @@ export default function NetworkProbes({
const key = probeKey(p)
return {
label: p.name || p.target,
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.avg ?? null,
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null,
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
}
})
@@ -231,6 +243,8 @@ export default function NetworkProbes({
// </Card>
// )
// }
//
// console.log("Rendering NetworkProbes", { probes, stats })
return (
<div className="grid gap-4">
@@ -245,7 +259,7 @@ export default function NetworkProbes({
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
</CardDescription>
</div>
<AddProbeDialog systemId={systemId} onCreated={fetchProbes} />
{/* <AddProbeDialog systemId={systemId} onCreated={fetchProbes} /> */}
</div>
</CardHeader>

View File

@@ -133,9 +133,7 @@ export function useSystemData(id: string) {
data.container?.length > 0
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
: null
const probePoint: NetworkProbeStatsRecord | null = data.probes
? { stats: data.probes, created: now }
: null
const probePoint: NetworkProbeStatsRecord | null = data.probes ? { stats: data.probes, created: now } : null
// on first message, make sure we clear out data from other time periods
if (isFirst) {
isFirst = false
@@ -214,8 +212,8 @@ export function useSystemData(id: string) {
}
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
]).then(([systemStats, containerStats]) => {
// If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) {