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

@@ -106,7 +106,7 @@ export default function LineChartDefault({
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
// activeDot={dataPoint.activeDot ?? true}
activeDot={dataPoint.activeDot ?? true}
connectNulls={connectNulls}
/>
)

View File

@@ -3,7 +3,6 @@ import { Button } from "@/components/ui/button"
import { cn, decimalString, hourWithSeconds } from "@/lib/utils"
import {
GlobeIcon,
TagIcon,
TimerIcon,
ActivityIcon,
WifiOffIcon,
@@ -12,6 +11,7 @@ import {
MoreHorizontalIcon,
ServerIcon,
ClockIcon,
NetworkIcon,
} from "lucide-react"
import { t } from "@lingui/core/macro"
import type { NetworkProbeRecord } from "@/types"
@@ -22,12 +22,6 @@ import { toast } from "../ui/use-toast"
import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
// export interface ProbeRow extends NetworkProbeRecord {
// key: string
// latency?: number
// loss?: number
// }
const protocolColors: Record<string, string> = {
icmp: "bg-blue-500/15 text-blue-400",
tcp: "bg-purple-500/15 text-purple-400",
@@ -48,7 +42,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
id: "name",
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
accessorFn: (record) => record.name || record.target,
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TagIcon} />,
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
cell: ({ getValue }) => (
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
{getValue() as string}
@@ -103,7 +97,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
{
id: "latency",
accessorFn: (record) => record.latency,
invertSorting: true,
// invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
cell: ({ row }) => {
const val = row.original.latency
@@ -111,10 +105,10 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
return <span className="ms-1.5 text-muted-foreground">-</span>
}
let color = "bg-green-500"
if (val > 200) {
if (!val || val > 200) {
color = "bg-yellow-500"
}
if (!val || val > 2000) {
if (val > 2000) {
color = "bg-red-500"
}
return (

View File

@@ -13,7 +13,6 @@ import {
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { listenKeys } from "nanostores"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
@@ -27,9 +26,15 @@ import { AddProbeDialog } from "./probe-dialog"
const NETWORK_PROBE_FIELDS = "id,name,system,target,protocol,port,interval,latency,loss,enabled,updated"
export default function NetworkProbesTableNew({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<NetworkProbeRecord[]>([])
export default function NetworkProbesTableNew({
systemId,
probes,
setProbes,
}: {
systemId?: string
probes: NetworkProbeRecord[]
setProbes: React.Dispatch<React.SetStateAction<NetworkProbeRecord[]>>
}) {
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-np-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
@@ -41,7 +46,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
// clear old data when systemId changes
useEffect(() => {
return setData([])
return setProbes([])
}, [systemId])
useEffect(() => {
@@ -51,26 +56,26 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
fields: NETWORK_PROBE_FIELDS,
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then((res) => setData(res.items))
.then((res) => setProbes(res.items))
}
// initial load
fetchData(systemId)
// if no systemId, pull after every system update
if (!systemId) {
return $allSystemsById.listen((_value, _oldValue, systemId) => {
// exclude initial load of systems
if (Date.now() - loadTime > 500) {
fetchData(systemId)
}
})
}
// if (!systemId) {
// return $allSystemsById.listen((_value, _oldValue, systemId) => {
// // exclude initial load of systems
// if (Date.now() - loadTime > 500) {
// fetchData(systemId)
// }
// })
// }
// if systemId, fetch after the system is updated
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
fetchData(systemId)
})
// return listenKeys($allSystemsById, [systemId], (_newSystems) => {
// fetchData(systemId)
// })
}, [systemId])
// Subscribe to updates
@@ -86,7 +91,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
"*",
(event) => {
const record = event.record
setData((currentProbes) => {
setProbes((currentProbes) => {
const probes = currentProbes ?? []
const matchesSystemScope = !systemId || record.system === systemId
@@ -124,12 +129,12 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
const { longestName, longestTarget } = useMemo(() => {
let longestName = 0
let longestTarget = 0
for (const p of data) {
for (const p of probes) {
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
}
return { longestName, longestTarget }
}, [data])
}, [probes])
// Filter columns based on whether systemId is provided
const columns = useMemo(() => {
@@ -140,7 +145,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
}, [systemId, longestName, longestTarget])
const table = useReactTable({
data,
data: probes,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
@@ -187,7 +192,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
</div>
</div>
<div className="md:ms-auto flex items-center gap-2">
{data.length > 0 && (
{probes.length > 0 && (
<Input
placeholder={t`Filter...`}
value={globalFilter}

View File

@@ -31,6 +31,7 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
const systems = useStore($systems)
const { toast } = useToast()
const { t } = useLingui()
const targetName = target.replace(/^https?:\/\//, "")
const resetForm = () => {
setProtocol("icmp")
@@ -47,7 +48,7 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
try {
await pb.collection("network_probes").create({
system: systemId ?? selectedSystemId,
name,
name: name || targetName,
target,
protocol,
port: protocol === "tcp" ? Number(port) : 0,
@@ -162,11 +163,11 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={target || t`e.g. Cloudflare DNS`}
placeholder={targetName || t`e.g. Cloudflare DNS`}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
</Button>
</DialogFooter>

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) {

View File

@@ -402,7 +402,7 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
let cachedAxis: JSX.Element
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
if (cachedAxis && ticks === cachedAxis.props.ticks) {
return cachedAxis
}
cachedAxis = (

View File

@@ -561,7 +561,18 @@ export interface NetworkProbeRecord {
updated: string
}
/**
* 0: avg latency in ms
*
* 1: min latency in ms
*
* 2: max latency in ms
*
* 3: packet loss in %
*/
type ProbeResult = number[]
export interface NetworkProbeStatsRecord {
stats: Record<string, { avg: number; min: number; max: number; loss: number }>
stats: Record<string, ProbeResult>
created: number // unix timestamp (ms) for Recharts xAxis
}