From bc0581ea615a9fb23bf5900c3e39d52c92afd40a Mon Sep 17 00:00:00 2001 From: xiaomiku01 Date: Sat, 11 Apr 2026 11:54:22 +0800 Subject: [PATCH] feat: add network probe data to realtime mode Include probe results in the 1-second realtime WebSocket broadcast so the frontend can update probe latency/loss every second, matching the behavior of system and container metrics. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/hub/systems/system_realtime.go | 26 +++++++++++++-- .../site/src/components/routes/system.tsx | 5 +-- .../routes/system/network-probes.tsx | 33 ++++++++++++++++++- .../routes/system/use-system-data.ts | 17 +++++++++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/internal/hub/systems/system_realtime.go b/internal/hub/systems/system_realtime.go index 6c27d0bd..de74fd77 100644 --- a/internal/hub/systems/system_realtime.go +++ b/internal/hub/systems/system_realtime.go @@ -7,10 +7,21 @@ import ( "time" "github.com/henrygd/beszel/internal/common" + "github.com/henrygd/beszel/internal/entities/container" + "github.com/henrygd/beszel/internal/entities/probe" + "github.com/henrygd/beszel/internal/entities/system" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/subscriptions" ) +// realtimePayload wraps system data with optional network probe results for realtime broadcast. +type realtimePayload struct { + Stats system.Stats `json:"stats"` + Info system.Info `json:"info"` + Containers []*container.Stats `json:"container"` + Probes map[string]probe.Result `json:"probes,omitempty"` +} + type subscriptionInfo struct { subscription string connectedClients uint8 @@ -142,16 +153,25 @@ func (sm *SystemManager) startRealtimeWorker() { // fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients. func (sm *SystemManager) fetchRealtimeDataAndNotify() { for systemId, info := range activeSubscriptions { - system, err := sm.GetSystem(systemId) + sys, err := sm.GetSystem(systemId) if err != nil { continue } go func() { - data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) + data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) if err != nil { return } - bytes, err := json.Marshal(data) + payload := realtimePayload{ + Stats: data.Stats, + Info: data.Info, + Containers: data.Containers, + } + // Fetch network probe results (lightweight in-memory read on agent) + if probes, err := sys.FetchNetworkProbeResults(); err == nil && len(probes) > 0 { + payload.Probes = probes + } + bytes, err := json.Marshal(payload) if err == nil { notify(sm.hub, info.subscription, bytes) } diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 4055899d..67cf2e22 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -29,6 +29,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { system, systemStats, containerData, + probeStats, chartData, containerChartConfigs, details, @@ -148,7 +149,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { {hasSystemd && } - + ) @@ -198,7 +199,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { {pageBottomExtraMargin > 0 &&
} - + diff --git a/internal/site/src/components/routes/system/network-probes.tsx b/internal/site/src/components/routes/system/network-probes.tsx index 74e2f78f..44c3ce85 100644 --- a/internal/site/src/components/routes/system/network-probes.tsx +++ b/internal/site/src/components/routes/system/network-probes.tsx @@ -23,10 +23,12 @@ export default function NetworkProbes({ systemId, chartData, grid, + realtimeProbeStats, }: { systemId: string chartData: ChartData grid: boolean + realtimeProbeStats?: NetworkProbeStatsRecord[] }) { const [probes, setProbes] = useState([]) const [stats, setStats] = useState([]) @@ -50,13 +52,42 @@ export default function NetworkProbes({ // Build set of current probe keys to filter out deleted probes from stats const activeProbeKeys = useMemo(() => new Set(probes.map(probeKey)), [probes]) - // Fetch probe stats based on chart time + // Use realtime probe stats when in 1m mode + useEffect(() => { + if (chartTime !== "1m" || !realtimeProbeStats) { + return + } + // Filter stats to only include currently active probes + const data: NetworkProbeStatsRecord[] = realtimeProbeStats.map((r) => { + const filtered: NetworkProbeStatsRecord["stats"] = {} + for (const [key, val] of Object.entries(r.stats)) { + if (activeProbeKeys.has(key)) { + filtered[key] = val + } + } + return { stats: filtered, created: r.created } + }) + setStats(data) + if (data.length > 0) { + const last = data[data.length - 1].stats + const latest: Record = {} + for (const [key, val] of Object.entries(last)) { + latest[key] = { avg: val.avg, loss: val.loss } + } + setLatestResults(latest) + } + }, [chartTime, realtimeProbeStats, activeProbeKeys]) + + // Fetch probe stats based on chart time (skip in realtime mode) useEffect(() => { if (probes.length === 0) { setStats([]) setLatestResults({}) return } + if (chartTime === "1m") { + return + } const controller = new AbortController() const statsType = chartTimeData[chartTime]?.type ?? "1m" 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 c83d2dff..c8f95834 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -19,6 +19,7 @@ import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/uti import type { ChartData, ContainerStatsRecord, + NetworkProbeStatsRecord, SystemDetailsRecord, SystemInfo, SystemRecord, @@ -48,6 +49,7 @@ export function useSystemData(id: string) { const [system, setSystem] = useState({} as SystemRecord) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [containerData, setContainerData] = useState([] as ChartData["containerData"]) + const [probeStats, setProbeStats] = useState([] as NetworkProbeStatsRecord[]) const persistChartTime = useRef(false) const statsRequestId = useRef(0) const [chartLoading, setChartLoading] = useState(true) @@ -119,24 +121,36 @@ export function useSystemData(id: string) { pb.realtime .subscribe( `rt_metrics`, - (data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => { + (data: { + container: ContainerStatsRecord[] + info: SystemInfo + stats: SystemStats + probes?: NetworkProbeStatsRecord["stats"] + }) => { const now = Date.now() const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord const containerPoint = 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 // on first message, make sure we clear out data from other time periods if (isFirst) { isFirst = false setSystemStats([statsPoint]) setContainerData(containerPoint ? [containerPoint] : []) + setProbeStats(probePoint ? [probePoint] : []) return } setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60)) if (containerPoint) { setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60)) } + if (probePoint) { + setProbeStats((prev) => appendData(prev, [probePoint], 1000, 60)) + } }, { query: { system: system.id } } ) @@ -322,6 +336,7 @@ export function useSystemData(id: string) { system, systemStats, containerData, + probeStats, chartData, containerChartConfigs, details,