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

@@ -78,7 +78,7 @@ func setCollectionAuthSettings(app core.App) error {
return err 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, list: &systemScopedReadRule,
}); err != nil { }); err != nil {
return err return err

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"log/slog"
"math/rand" "math/rand"
"net" "net"
"strings" "strings"
@@ -318,21 +319,77 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
if len(data) == 0 { if len(data) == 0 {
return nil return nil
} }
var err error
collectionName := "network_probes" 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 { for key := range data {
probe := data[key] probe := data[key]
id := MakeStableHashId(systemId, key) id := MakeStableHashId(systemId, key)
params := dbx.Params{ switch realtimeActive {
"latency": probe[0], case true:
"loss": probe[3], var record *core.Record
"updated": nowString, 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 { if err != nil {
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", key, "err", err) app.Logger().Warn("Failed to update probe", "system", systemId, "probe", key, "err", err)
} }
} }
return nil return nil
} }

View File

@@ -1,7 +1,11 @@
// Package utils provides utility functions for the hub. // Package utils provides utility functions for the hub.
package utils 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. // 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) { func GetEnv(key string) (value string, exists bool) {
@@ -10,3 +14,26 @@ func GetEnv(key string) (value string, exists bool) {
} }
return os.LookupEnv(key) 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
}

View File

@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -507,60 +508,57 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
// AverageProbeStats averages probe stats across multiple records. // AverageProbeStats averages probe stats across multiple records.
// For each probe key: avg of avgs, min of mins, max of maxes, avg of losses. // 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 { type probeValues struct {
avgSum float64 sums probe.Result
minVal float64 count float64
maxVal float64
lossSum float64
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) sums := make(map[string]*probeValues)
var row StatsRecord var row StatsRecord
params := make(dbx.Params, 1)
for _, rec := range records { for _, rec := range records {
row.Stats = row.Stats[:0] row.Stats = row.Stats[:0]
params["id"] = rec.Id query.Bind(dbx.Params{"id": rec.Id}).One(&row)
db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}").Bind(params).One(&row) var rawStats map[string]probe.Result
var rawStats map[string]map[string]float64
if err := json.Unmarshal(row.Stats, &rawStats); err != nil { if err := json.Unmarshal(row.Stats, &rawStats); err != nil {
continue continue
} }
for key, vals := range rawStats { for key, vals := range rawStats {
s, ok := sums[key] s, ok := sums[key]
if !ok { if !ok {
s = &probeValues{minVal: math.MaxFloat64} s = &probeValues{sums: make(probe.Result, len(vals))}
sums[key] = s sums[key] = s
} }
s.avgSum += vals["avg"] for i := range vals {
if vals["min"] < s.minVal { switch i {
s.minVal = vals["min"] 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++ 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 { for key, s := range sums {
if s.count == 0 { if s.count == 0 {
continue continue
} }
minVal := s.minVal s.sums[0] = twoDecimals(s.sums[0] / s.count) // avg latency
if minVal == math.MaxFloat64 { s.sums[3] = twoDecimals(s.sums[3] / s.count) // packet loss
minVal = 0 result[key] = s.sums
}
result[key] = map[string]float64{
"avg": twoDecimals(s.avgSum / s.count),
"min": twoDecimals(minVal),
"max": twoDecimals(s.maxVal),
"loss": twoDecimals(s.lossSum / s.count),
}
} }
return result return result
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -561,7 +561,18 @@ export interface NetworkProbeRecord {
updated: string 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 { 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 created: number // unix timestamp (ms) for Recharts xAxis
} }