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 f2d29805..5dc4d0c5 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,27 +13,23 @@ import { type VisibilityState, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" -import { memo, useEffect, useMemo, useRef, useState } from "react" +import { memo, useMemo, useRef, useState } from "react" import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns" import { Card, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { isReadOnlyUser, pb } from "@/lib/api" +import { isReadOnlyUser } from "@/lib/api" import { $allSystemsById } from "@/lib/stores" import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils" import type { NetworkProbeRecord } from "@/types" 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, probes, - setProbes, }: { systemId?: string probes: NetworkProbeRecord[] - setProbes: React.Dispatch> }) { const [sorting, setSorting] = useBrowserStorage( `sort-np-${systemId ? 1 : 0}`, @@ -44,88 +40,6 @@ export default function NetworkProbesTableNew({ const [columnVisibility, setColumnVisibility] = useState({}) const [globalFilter, setGlobalFilter] = useState("") - // clear old data when systemId changes - useEffect(() => { - return setProbes([]) - }, [systemId]) - - useEffect(() => { - function fetchData(systemId?: string) { - pb.collection("network_probes") - .getList(0, 2000, { - fields: NETWORK_PROBE_FIELDS, - filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, - }) - .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, fetch after the system is updated - // return listenKeys($allSystemsById, [systemId], (_newSystems) => { - // fetchData(systemId) - // }) - }, [systemId]) - - // Subscribe to updates - useEffect(() => { - let unsubscribe: (() => void) | undefined - const pbOptions = systemId - ? { fields: NETWORK_PROBE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) } - : { fields: NETWORK_PROBE_FIELDS } - - ;(async () => { - try { - unsubscribe = await pb.collection("network_probes").subscribe( - "*", - (event) => { - const record = event.record - setProbes((currentProbes) => { - const probes = currentProbes ?? [] - const matchesSystemScope = !systemId || record.system === systemId - - if (event.action === "delete") { - return probes.filter((device) => device.id !== record.id) - } - - if (!matchesSystemScope) { - // Record moved out of scope; ensure it disappears locally. - return probes.filter((device) => device.id !== record.id) - } - - const existingIndex = probes.findIndex((device) => device.id === record.id) - if (existingIndex === -1) { - return [record, ...probes] - } - - const next = [...probes] - next[existingIndex] = record - return next - }) - }, - pbOptions - ) - } catch (error) { - console.error("Failed to subscribe to SMART device updates:", error) - } - })() - - return () => { - unsubscribe?.() - } - }, [systemId]) - const { longestName, longestTarget } = useMemo(() => { let longestName = 0 let longestTarget = 0 diff --git a/internal/site/src/components/routes/probes.tsx b/internal/site/src/components/routes/probes.tsx index 2bdae05d..a5591e43 100644 --- a/internal/site/src/components/routes/probes.tsx +++ b/internal/site/src/components/routes/probes.tsx @@ -1,13 +1,13 @@ import { useLingui } from "@lingui/react/macro" -import { memo, useEffect, useState } from "react" +import { memo, useEffect } 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" +import { useNetworkProbesData } from "@/lib/use-network-probes" export default memo(() => { const { t } = useLingui() - const [probes, setProbes] = useState([]) + const { probes } = useNetworkProbesData({}) useEffect(() => { document.title = `${t`Network Probes`} / Beszel` @@ -17,7 +17,7 @@ export default memo(() => { <>
- +
diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index c47bc782..ff36bda4 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -28,7 +28,6 @@ export default memo(function SystemDetail({ id }: { id: string }) { system, systemStats, containerData, - probeStats, chartData, containerChartConfigs, details, @@ -148,8 +147,6 @@ export default memo(function SystemDetail({ id }: { id: string }) { {hasSystemd && } - - {/* */} ) } @@ -198,7 +195,6 @@ export default memo(function SystemDetail({ id }: { id: string }) { {pageBottomExtraMargin > 0 &&
} - {/* */} diff --git a/internal/site/src/components/routes/system/charts/probes-charts.tsx b/internal/site/src/components/routes/system/charts/probes-charts.tsx index f1c59205..8cd91e11 100644 --- a/internal/site/src/components/routes/system/charts/probes-charts.tsx +++ b/internal/site/src/components/routes/system/charts/probes-charts.tsx @@ -15,27 +15,43 @@ function probeKey(p: NetworkProbeRecord) { const $filter = atom("") -export function LatencyChart({ - probeStats, - grid, - probes, - chartData, - empty, -}: { +type ProbeChartProps = { probeStats: NetworkProbeStatsRecord[] grid?: boolean probes: NetworkProbeRecord[] chartData: ChartData empty: boolean -}) { - const { t } = useLingui() +} + +type ProbeChartBaseProps = ProbeChartProps & { + valueIndex: number + title: string + description: string + tickFormatter: (value: number) => string + contentFormatter: ({ value }: { value: number | string }) => string | number + domain?: [number | "auto", number | "auto"] +} + +function ProbeChart({ + probeStats, + grid, + probes, + chartData, + empty, + valueIndex, + title, + description, + tickFormatter, + contentFormatter, + domain, +}: ProbeChartBaseProps) { const filter = useStore($filter) const { dataPoints, visibleKeys } = useMemo(() => { - const count = probes.length + const sortedProbes = [...probes].sort((a, b) => a.name.localeCompare(b.name)) + const count = sortedProbes.length const points: DataPoint[] = [] const visibleKeys: string[] = [] - probes.sort((a, b) => a.name.localeCompare(b.name)) const filterTerms = filter ? filter .toLowerCase() @@ -43,7 +59,7 @@ export function LatencyChart({ .filter((term) => term.length > 0) : [] for (let i = 0; i < count; i++) { - const p = probes[i] + const p = sortedProbes[i] const key = probeKey(p) const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term)) if (filtered) { @@ -52,12 +68,12 @@ export function LatencyChart({ visibleKeys.push(key) points.push({ label: p.name || p.target, - dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? "-", + dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[valueIndex] ?? "-", color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`, }) } return { dataPoints: points, visibleKeys } - }, [probes, filter]) + }, [probes, filter, valueIndex]) const filteredProbeStats = useMemo(() => { if (!visibleKeys.length) return probeStats @@ -71,26 +87,70 @@ export function LatencyChart({ legend={legend} cornerEl={} empty={empty} - title={t`Latency`} - description={t`Average round-trip time (ms)`} + title={title} + description={description} grid={grid} > `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`} - contentFormatter={({ value }) => { - if (value === "-") { - return value - } - return `${decimalString(value, 2)} ms` - }} + tickFormatter={tickFormatter} + contentFormatter={contentFormatter} legend={legend} filter={filter} /> ) } + +export function LatencyChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) { + const { t } = useLingui() + + return ( + `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`} + contentFormatter={({ value }) => { + if (typeof value !== "number") { + return value + } + return `${decimalString(value, 2)} ms` + }} + /> + ) +} + +export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) { + const { t } = useLingui() + + return ( + `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`} + contentFormatter={({ value }) => { + if (typeof value !== "number") { + return value + } + return `${decimalString(value, 2)}%` + }} + /> + ) +} diff --git a/internal/site/src/components/routes/system/lazy-tables.tsx b/internal/site/src/components/routes/system/lazy-tables.tsx index 0b4320b9..1cf3923f 100644 --- a/internal/site/src/components/routes/system/lazy-tables.tsx +++ b/internal/site/src/components/routes/system/lazy-tables.tsx @@ -1,13 +1,11 @@ -import { lazy, useEffect, useRef, useState } from "react" +import { lazy } from "react" import { useIntersectionObserver } from "@/lib/use-intersection-observer" -import { chartTimeData, cn } from "@/lib/utils" -import { NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types" -import { LatencyChart } from "./charts/probes-charts" -import { SystemData } from "./use-system-data" +import { cn } from "@/lib/utils" +import { LatencyChart, LossChart } from "./charts/probes-charts" +import type { 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" +import { useNetworkProbesData } from "@/lib/use-network-probes" const ContainersTable = lazy(() => import("../../containers-table/containers-table")) @@ -44,75 +42,43 @@ export function LazySystemdTable({ systemId }: { systemId: string }) { const NetworkProbesTable = lazy(() => import("@/components/network-probes-table/network-probes-table")) -const cache = new Map() - export function LazyNetworkProbesTable({ 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 && ( - <> - - {!!chartData && ( - - )} - - )} + {isIntersecting && }
) } + +function ProbesTable({ systemId, systemData }: { systemId: string; systemData: SystemData }) { + const { grid, chartData } = systemData ?? {} + const chartTime = useStore($chartTime) + + const { probes, probeStats } = useNetworkProbesData({ systemId, loadStats: !!chartData, chartTime }) + + return ( + <> + + {!!chartData && !!probes.length && ( +
+ + +
+ )} + + ) +} diff --git a/internal/site/src/lib/use-network-probes.ts b/internal/site/src/lib/use-network-probes.ts new file mode 100644 index 00000000..1b02ca22 --- /dev/null +++ b/internal/site/src/lib/use-network-probes.ts @@ -0,0 +1,199 @@ +import { chartTimeData } from "@/lib/utils" +import type { ChartTimes, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types" +import { useEffect, useRef, useState } from "react" +import { getStats, appendData } from "@/components/routes/system/chart-data" +import { pb } from "@/lib/api" +import { toast } from "@/components/ui/use-toast" +import type { RecordListOptions, RecordSubscription } from "pocketbase" + +const cache = new Map() + +const NETWORK_PROBE_FIELDS = "id,name,system,target,protocol,port,interval,latency,loss,enabled,updated" + +interface UseNetworkProbesProps { + systemId?: string + loadStats?: boolean + chartTime?: ChartTimes + existingProbes?: NetworkProbeRecord[] +} + +export function useNetworkProbesData(props: UseNetworkProbesProps) { + const { systemId, loadStats, chartTime, existingProbes } = props + + const [p, setProbes] = useState([]) + const [probeStats, setProbeStats] = useState([]) + const statsRequestId = useRef(0) + const pendingProbeEvents = useRef(new Map>()) + const probeBatchTimeout = useRef | null>(null) + + const probes = existingProbes ?? p + + // clear old data when systemId changes + // useEffect(() => { + // return setProbes([]) + // }, [systemId]) + + // initial load - fetch probes if not provided by caller + useEffect(() => { + if (!existingProbes) { + fetchProbes(systemId).then((probes) => setProbes(probes)) + } + }, [systemId]) + + // Subscribe to updates if probes not provided by caller + useEffect(() => { + if (existingProbes) { + return + } + let unsubscribe: (() => void) | undefined + + function flushPendingProbeEvents() { + probeBatchTimeout.current = null + if (!pendingProbeEvents.current.size) { + return + } + const events = pendingProbeEvents.current + pendingProbeEvents.current = new Map() + setProbes((currentProbes) => { + return applyProbeEvents(currentProbes ?? [], events.values(), systemId) + }) + } + + const pbOptions: RecordListOptions = { fields: NETWORK_PROBE_FIELDS } + if (systemId) { + pbOptions.filter = pb.filter("system = {:system}", { system: systemId }) + } + + ;(async () => { + try { + unsubscribe = await pb.collection("network_probes").subscribe( + "*", + (event) => { + pendingProbeEvents.current.set(event.record.id, event) + if (!probeBatchTimeout.current) { + probeBatchTimeout.current = setTimeout(flushPendingProbeEvents, 50) + } + }, + pbOptions + ) + } catch (error) { + console.error("Failed to subscribe to probes", error) + } + })() + + return () => { + if (probeBatchTimeout.current !== null) { + clearTimeout(probeBatchTimeout.current) + probeBatchTimeout.current = null + } + pendingProbeEvents.current.clear() + unsubscribe?.() + } + }, [systemId]) + + // fetch probe stats when probes update + useEffect(() => { + if (!loadStats || !systemId || !chartTime || chartTime === "1m") { + return + } + + const { expectedInterval } = chartTimeData[chartTime] + const cache_key = `${systemId}${chartTime}` + const requestId = ++statsRequestId.current + + const cachedProbeStats = cache.get(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 + 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(cache_key) || []) as NetworkProbeStatsRecord[] + if (probeStats.length) { + probeStatsData = appendData(probeStatsData, probeStats, expectedInterval, 100) + cache.set(cache_key, probeStatsData) + } + setProbeStats(probeStatsData) + } + ) + }, [chartTime, probes]) + + return { + probes, + probeStats, + } +} + +async function fetchProbes(systemId?: string) { + try { + const res = await pb.collection("network_probes").getList(0, 2000, { + fields: NETWORK_PROBE_FIELDS, + filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, + }) + return res.items + } catch (error) { + toast({ + title: "Error", + description: (error as Error)?.message, + variant: "destructive", + }) + return [] + } +} + +function applyProbeEvents( + probes: NetworkProbeRecord[], + events: Iterable>, + systemId?: string +) { + // Use a map to handle updates/deletes in constant time + const probeById = new Map(probes.map((probe) => [probe.id, probe])) + const createdProbes: NetworkProbeRecord[] = [] + + for (const { action, record } of events) { + const matchesSystemScope = !systemId || record.system === systemId + + if (action === "delete" || !matchesSystemScope) { + probeById.delete(record.id) + continue + } + + if (!probeById.has(record.id)) { + createdProbes.push(record) + } + + probeById.set(record.id, record) + } + + const nextProbes: NetworkProbeRecord[] = [] + // Prepend brand new probes (matching previous behavior) + for (let index = createdProbes.length - 1; index >= 0; index -= 1) { + nextProbes.push(createdProbes[index]) + } + + // Rebuild the final list while preserving original order for existing probes + for (const probe of probes) { + const nextProbe = probeById.get(probe.id) + if (!nextProbe) { + continue + } + nextProbes.push(nextProbe) + probeById.delete(probe.id) + } + + return nextProbes +}