diff --git a/internal/hub/collections.go b/internal/hub/collections.go index 6133fd90..8d546c70 100644 --- a/internal/hub/collections.go +++ b/internal/hub/collections.go @@ -78,7 +78,7 @@ func setCollectionAuthSettings(app core.App) error { return err } - if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{ + if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services", "network_probe_stats"}, collectionRules{ list: &systemScopedReadRule, }); err != nil { return err diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 7100d8ee..99274104 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "hash/fnv" + "log/slog" "math/rand" "net" "strings" @@ -318,21 +319,77 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst if len(data) == 0 { return nil } + var err error collectionName := "network_probes" - nowString := time.Now().UTC().Format(types.DefaultDateLayout) + + // If realtime updates are active, we save via PocketBase records to trigger realtime events. + // Otherwise we can do a more efficient direct update via SQL + realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool { + slog.Info("Checking realtime subscription filter for network probes", "filterQuery", filterQuery) + return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId) + }) + + var db dbx.Builder + var nowString string + var updateQuery *dbx.Query + if !realtimeActive { + db = app.DB() + nowString = time.Now().UTC().Format(types.DefaultDateLayout) + sql := fmt.Sprintf("UPDATE %s SET latency={:latency}, loss={:loss}, updated={:updated} WHERE id={:id}", collectionName) + updateQuery = db.NewQuery(sql) + } + + // insert network probe stats records + switch realtimeActive { + case true: + collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats") + record := core.NewRecord(collection) + record.Set("system", systemId) + record.Set("stats", data) + record.Set("type", "1m") + err = app.SaveNoValidate(record) + default: + if dataJson, e := json.Marshal(data); e == nil { + sql := "INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})" + insertQuery := db.NewQuery(sql) + _, err = insertQuery.Bind(dbx.Params{ + "system": systemId, + "stats": dataJson, + "type": "1m", + "created": nowString, + }).Execute() + } + } + if err != nil { + app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err) + } + + // update network_probes records for key := range data { probe := data[key] id := MakeStableHashId(systemId, key) - params := dbx.Params{ - "latency": probe[0], - "loss": probe[3], - "updated": nowString, + switch realtimeActive { + case true: + var record *core.Record + record, err = app.FindRecordById(collectionName, id) + if err == nil { + record.Set("latency", probe[0]) + record.Set("loss", probe[3]) + err = app.SaveNoValidate(record) + } + default: + _, err = updateQuery.Bind(dbx.Params{ + "id": id, + "latency": probe[0], + "loss": probe[3], + "updated": nowString, + }).Execute() } - _, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": id}).Execute() if err != nil { app.Logger().Warn("Failed to update probe", "system", systemId, "probe", key, "err", err) } } + return nil } diff --git a/internal/hub/utils/utils.go b/internal/hub/utils/utils.go index 43838868..0cfc1fa9 100644 --- a/internal/hub/utils/utils.go +++ b/internal/hub/utils/utils.go @@ -1,7 +1,11 @@ // Package utils provides utility functions for the hub. package utils -import "os" +import ( + "os" + + "github.com/pocketbase/pocketbase/core" +) // GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key. func GetEnv(key string) (value string, exists bool) { @@ -10,3 +14,26 @@ func GetEnv(key string) (value string, exists bool) { } return os.LookupEnv(key) } + +// realtimeActiveForCollection checks if there are active WebSocket subscriptions for the given collection. +func RealtimeActiveForCollection(app core.App, collectionName string, validateFn func(filterQuery string) bool) bool { + broker := app.SubscriptionsBroker() + if broker.TotalClients() == 0 { + return false + } + for _, client := range broker.Clients() { + subs := client.Subscriptions(collectionName) + if len(subs) > 0 { + if validateFn == nil { + return true + } + for k := range subs { + filter := subs[k].Query["filter"] + if validateFn(filter) { + return true + } + } + } + } + return false +} diff --git a/internal/records/records.go b/internal/records/records.go index 83725286..b9d9b9c1 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -8,6 +8,7 @@ import ( "time" "github.com/henrygd/beszel/internal/entities/container" + "github.com/henrygd/beszel/internal/entities/probe" "github.com/henrygd/beszel/internal/entities/system" "github.com/pocketbase/dbx" @@ -507,60 +508,57 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats { // AverageProbeStats averages probe stats across multiple records. // For each probe key: avg of avgs, min of mins, max of maxes, avg of losses. -func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]map[string]float64 { +func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Result { type probeValues struct { - avgSum float64 - minVal float64 - maxVal float64 - lossSum float64 - count float64 + sums probe.Result + count float64 } + query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}") + + // accumulate sums for each probe key across records sums := make(map[string]*probeValues) var row StatsRecord - params := make(dbx.Params, 1) for _, rec := range records { row.Stats = row.Stats[:0] - params["id"] = rec.Id - db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}").Bind(params).One(&row) - var rawStats map[string]map[string]float64 + query.Bind(dbx.Params{"id": rec.Id}).One(&row) + var rawStats map[string]probe.Result if err := json.Unmarshal(row.Stats, &rawStats); err != nil { continue } - for key, vals := range rawStats { s, ok := sums[key] if !ok { - s = &probeValues{minVal: math.MaxFloat64} + s = &probeValues{sums: make(probe.Result, len(vals))} sums[key] = s } - s.avgSum += vals["avg"] - if vals["min"] < s.minVal { - s.minVal = vals["min"] + for i := range vals { + switch i { + case 1: // min fields + if s.count == 0 || vals[i] < s.sums[i] { + s.sums[i] = vals[i] + } + case 2: // max fields + if vals[i] > s.sums[i] { + s.sums[i] = vals[i] + } + default: // average fields + s.sums[i] += vals[i] + } } - if vals["max"] > s.maxVal { - s.maxVal = vals["max"] - } - s.lossSum += vals["loss"] s.count++ } } - result := make(map[string]map[string]float64, len(sums)) + // compute final averages + result := make(map[string]probe.Result, len(sums)) for key, s := range sums { if s.count == 0 { continue } - minVal := s.minVal - if minVal == math.MaxFloat64 { - minVal = 0 - } - result[key] = map[string]float64{ - "avg": twoDecimals(s.avgSum / s.count), - "min": twoDecimals(minVal), - "max": twoDecimals(s.maxVal), - "loss": twoDecimals(s.lossSum / s.count), - } + s.sums[0] = twoDecimals(s.sums[0] / s.count) // avg latency + s.sums[3] = twoDecimals(s.sums[3] / s.count) // packet loss + result[key] = s.sums } return result } diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx index 8861b667..2d723745 100644 --- a/internal/site/src/components/charts/line-chart.tsx +++ b/internal/site/src/components/charts/line-chart.tsx @@ -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} /> ) diff --git a/internal/site/src/components/network-probes-table/network-probes-columns.tsx b/internal/site/src/components/network-probes-table/network-probes-columns.tsx index 9c3e52d3..05d7377a 100644 --- a/internal/site/src/components/network-probes-table/network-probes-columns.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-columns.tsx @@ -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 = { 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 (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target), accessorFn: (record) => record.name || record.target, - header: ({ column }) => , + header: ({ column }) => , cell: ({ getValue }) => (
{getValue() as string} @@ -103,7 +97,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef record.latency, - invertSorting: true, + // invertSorting: true, header: ({ column }) => , cell: ({ row }) => { const val = row.original.latency @@ -111,10 +105,10 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef- } 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 ( diff --git a/internal/site/src/components/network-probes-table/network-probes-table.tsx b/internal/site/src/components/network-probes-table/network-probes-table.tsx index 58553dbe..2f06a98f 100644 --- a/internal/site/src/components/network-probes-table/network-probes-table.tsx +++ b/internal/site/src/components/network-probes-table/network-probes-table.tsx @@ -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([]) +export default function NetworkProbesTableNew({ + systemId, + probes, + setProbes, +}: { + systemId?: string + probes: NetworkProbeRecord[] + setProbes: React.Dispatch> +}) { const [sorting, setSorting] = useBrowserStorage( `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
- {data.length > 0 && ( + {probes.length > 0 && ( { 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 }) { setName(e.target.value)} - placeholder={target || t`e.g. Cloudflare DNS`} + placeholder={targetName || t`e.g. Cloudflare DNS`} />
- diff --git a/internal/site/src/components/routes/probes.tsx b/internal/site/src/components/routes/probes.tsx index 35f2ee48..2bdae05d 100644 --- a/internal/site/src/components/routes/probes.tsx +++ b/internal/site/src/components/routes/probes.tsx @@ -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([]) useEffect(() => { document.title = `${t`Network Probes`} / Beszel` }, [t]) - return useMemo( - () => ( - <> -
- - -
- - - ), - [] + return ( + <> +
+ + +
+ + ) }) diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 17edaeb5..68f95a98 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -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 && } - + {/* */} @@ -203,7 +197,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { {pageBottomExtraMargin > 0 &&
} - + {/* */} diff --git a/internal/site/src/components/routes/system/chart-data.ts b/internal/site/src/components/routes/system/chart-data.ts index 8769caaf..763aeef3 100644 --- a/internal/site/src/components/routes/system/chart-data.ts +++ b/internal/site/src/components/routes/system/chart-data.ts @@ -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( return result } -export async function getStats( +export async function getStats( collection: string, systemId: string, - chartTime: ChartTimes + chartTime: ChartTimes, + cachedStats?: { created: string | number | null }[] ): Promise { - const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined const lastCached = cachedStats?.at(-1)?.created as number return await pb.collection(collection).getFullList({ filter: pb.filter("system={:id} && created > {:created} && type={:type}", { diff --git a/internal/site/src/components/routes/system/charts/probes-charts.tsx b/internal/site/src/components/routes/system/charts/probes-charts.tsx new file mode 100644 index 00000000..f567f879 --- /dev/null +++ b/internal/site/src/components/routes/system/charts/probes-charts.tsx @@ -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[] = 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 ( + } + empty={empty} + title={t`Latency`} + description={t`Average round-trip time (ms)`} + grid={grid} + > + `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`} + contentFormatter={({ value }) => `${decimalString(value, 2)} ms`} + legend + filter={filter} + /> + + ) +} diff --git a/internal/site/src/components/routes/system/lazy-tables.tsx b/internal/site/src/components/routes/system/lazy-tables.tsx index 02a8df10..62cdbde0 100644 --- a/internal/site/src/components/routes/system/lazy-tables.tsx +++ b/internal/site/src/components/routes/system/lazy-tables.tsx @@ -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() + +export function LazyNetworkProbesTableNew({ systemId, systemData }: { systemId: string; systemData: SystemData }) { + const { grid, chartData } = systemData ?? {} + const [probes, setProbes] = useState([]) + const chartTime = useStore($chartTime) + const [probeStats, setProbeStats] = useState([]) 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("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 (
- {isIntersecting && } + {isIntersecting && ( + <> + + {!!chartData && ( + + )} + + )}
) } diff --git a/internal/site/src/components/routes/system/network-probes.tsx b/internal/site/src/components/routes/system/network-probes.tsx index 7c141209..d3144fc2 100644 --- a/internal/site/src/components/routes/system/network-probes.tsx +++ b/internal/site/src/components/routes/system/network-probes.tsx @@ -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 = {} 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("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 = {} 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({ // // ) // } + // + // console.log("Rendering NetworkProbes", { probes, stats }) return (
@@ -245,7 +259,7 @@ export default function NetworkProbes({ ICMP/TCP/HTTP latency monitoring from this agent
- + {/* */} diff --git a/internal/site/src/components/routes/system/use-system-data.ts b/internal/site/src/components/routes/system/use-system-data.ts index c8f95834..22689485 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -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("system_stats", systemId, chartTime), - getStats("container_stats", systemId, chartTime), + getStats("system_stats", systemId, chartTime, cachedSystemStats), + getStats("container_stats", systemId, chartTime, cachedContainerData), ]).then(([systemStats, containerStats]) => { // If another request has been made since this one, ignore the results if (requestId !== statsRequestId.current) { diff --git a/internal/site/src/components/ui/chart.tsx b/internal/site/src/components/ui/chart.tsx index 5fc4e2fe..43ea9445 100644 --- a/internal/site/src/components/ui/chart.tsx +++ b/internal/site/src/components/ui/chart.tsx @@ -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 = ( diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 69b4cf0d..c476fa1c 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -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 + stats: Record created: number // unix timestamp (ms) for Recharts xAxis }