mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 12:11:49 +02:00
updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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{
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { cn, decimalString, hourWithSeconds } from "@/lib/utils"
|
||||
import {
|
||||
GlobeIcon,
|
||||
TagIcon,
|
||||
TimerIcon,
|
||||
ActivityIcon,
|
||||
WifiOffIcon,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
MoreHorizontalIcon,
|
||||
ServerIcon,
|
||||
ClockIcon,
|
||||
NetworkIcon,
|
||||
} from "lucide-react"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
@@ -22,12 +22,6 @@ import { toast } from "../ui/use-toast"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
// export interface ProbeRow extends NetworkProbeRecord {
|
||||
// key: string
|
||||
// latency?: number
|
||||
// loss?: number
|
||||
// }
|
||||
|
||||
const protocolColors: Record<string, string> = {
|
||||
icmp: "bg-blue-500/15 text-blue-400",
|
||||
tcp: "bg-purple-500/15 text-purple-400",
|
||||
@@ -48,7 +42,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
||||
id: "name",
|
||||
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
||||
accessorFn: (record) => record.name || record.target,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TagIcon} />,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{getValue() as string}
|
||||
@@ -103,7 +97,7 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
||||
{
|
||||
id: "latency",
|
||||
accessorFn: (record) => record.latency,
|
||||
invertSorting: true,
|
||||
// invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.latency
|
||||
@@ -111,10 +105,10 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||
}
|
||||
let color = "bg-green-500"
|
||||
if (val > 200) {
|
||||
if (!val || val > 200) {
|
||||
color = "bg-yellow-500"
|
||||
}
|
||||
if (!val || val > 2000) {
|
||||
if (val > 2000) {
|
||||
color = "bg-red-500"
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import { listenKeys } from "nanostores"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -27,9 +26,15 @@ import { AddProbeDialog } from "./probe-dialog"
|
||||
|
||||
const NETWORK_PROBE_FIELDS = "id,name,system,target,protocol,port,interval,latency,loss,enabled,updated"
|
||||
|
||||
export default function NetworkProbesTableNew({ systemId }: { systemId?: string }) {
|
||||
const loadTime = Date.now()
|
||||
const [data, setData] = useState<NetworkProbeRecord[]>([])
|
||||
export default function NetworkProbesTableNew({
|
||||
systemId,
|
||||
probes,
|
||||
setProbes,
|
||||
}: {
|
||||
systemId?: string
|
||||
probes: NetworkProbeRecord[]
|
||||
setProbes: React.Dispatch<React.SetStateAction<NetworkProbeRecord[]>>
|
||||
}) {
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-np-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
@@ -41,7 +46,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
|
||||
|
||||
// clear old data when systemId changes
|
||||
useEffect(() => {
|
||||
return setData([])
|
||||
return setProbes([])
|
||||
}, [systemId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,26 +56,26 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
|
||||
fields: NETWORK_PROBE_FIELDS,
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.then((res) => setData(res.items))
|
||||
.then((res) => setProbes(res.items))
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchData(systemId)
|
||||
|
||||
// if no systemId, pull after every system update
|
||||
if (!systemId) {
|
||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||
// exclude initial load of systems
|
||||
if (Date.now() - loadTime > 500) {
|
||||
fetchData(systemId)
|
||||
}
|
||||
})
|
||||
}
|
||||
// if (!systemId) {
|
||||
// return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||
// // exclude initial load of systems
|
||||
// if (Date.now() - loadTime > 500) {
|
||||
// fetchData(systemId)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// if systemId, fetch after the system is updated
|
||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||
fetchData(systemId)
|
||||
})
|
||||
// return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||
// fetchData(systemId)
|
||||
// })
|
||||
}, [systemId])
|
||||
|
||||
// Subscribe to updates
|
||||
@@ -86,7 +91,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
|
||||
"*",
|
||||
(event) => {
|
||||
const record = event.record
|
||||
setData((currentProbes) => {
|
||||
setProbes((currentProbes) => {
|
||||
const probes = currentProbes ?? []
|
||||
const matchesSystemScope = !systemId || record.system === systemId
|
||||
|
||||
@@ -124,12 +129,12 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
|
||||
const { longestName, longestTarget } = useMemo(() => {
|
||||
let longestName = 0
|
||||
let longestTarget = 0
|
||||
for (const p of data) {
|
||||
for (const p of probes) {
|
||||
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
|
||||
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
|
||||
}
|
||||
return { longestName, longestTarget }
|
||||
}, [data])
|
||||
}, [probes])
|
||||
|
||||
// Filter columns based on whether systemId is provided
|
||||
const columns = useMemo(() => {
|
||||
@@ -140,7 +145,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
|
||||
}, [systemId, longestName, longestTarget])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: probes,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -187,7 +192,7 @@ export default function NetworkProbesTableNew({ systemId }: { systemId?: string
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:ms-auto flex items-center gap-2">
|
||||
{data.length > 0 && (
|
||||
{probes.length > 0 && (
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
||||
const systems = useStore($systems)
|
||||
const { toast } = useToast()
|
||||
const { t } = useLingui()
|
||||
const targetName = target.replace(/^https?:\/\//, "")
|
||||
|
||||
const resetForm = () => {
|
||||
setProtocol("icmp")
|
||||
@@ -47,7 +48,7 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
||||
try {
|
||||
await pb.collection("network_probes").create({
|
||||
system: systemId ?? selectedSystemId,
|
||||
name,
|
||||
name: name || targetName,
|
||||
target,
|
||||
protocol,
|
||||
port: protocol === "tcp" ? Number(port) : 0,
|
||||
@@ -162,7 +163,7 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={target || t`e.g. Cloudflare DNS`}
|
||||
placeholder={targetName || t`e.g. Cloudflare DNS`}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect, useMemo } from "react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
|
||||
export default memo(() => {
|
||||
const { t } = useLingui()
|
||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t`Network Probes`} / Beszel`
|
||||
}, [t])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<NetworkProbesTableNew />
|
||||
<NetworkProbesTableNew probes={probes} setProbes={setProbes} />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,13 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||
import {
|
||||
LazyContainersTable,
|
||||
LazyNetworkProbesTable,
|
||||
LazySmartTable,
|
||||
LazySystemdTable,
|
||||
LazyNetworkProbesTableNew,
|
||||
} from "./system/lazy-tables"
|
||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTableNew } from "./system/lazy-tables"
|
||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||
import { GpuIcon } from "../ui/icons"
|
||||
@@ -153,7 +147,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
|
||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||
|
||||
<LazyNetworkProbesTableNew systemId={system.id} />
|
||||
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
|
||||
|
||||
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
|
||||
</>
|
||||
@@ -203,7 +197,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
<LazyNetworkProbesTableNew systemId={system.id} />
|
||||
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
|
||||
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { timeTicks } from "d3-time"
|
||||
import { getPbTimestamp, pb } from "@/lib/api"
|
||||
import { chartTimeData } from "@/lib/utils"
|
||||
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
|
||||
import type { ChartData, ChartTimes, ContainerStatsRecord, NetworkProbeStatsRecord, SystemStatsRecord } from "@/types"
|
||||
|
||||
type ChartTimeData = {
|
||||
time: number
|
||||
@@ -66,12 +66,12 @@ export function appendData<T extends { created: string | number | null }>(
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkProbeStatsRecord>(
|
||||
collection: string,
|
||||
systemId: string,
|
||||
chartTime: ChartTimes
|
||||
chartTime: ChartTimes,
|
||||
cachedStats?: { created: string | number | null }[]
|
||||
): Promise<T[]> {
|
||||
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
|
||||
const lastCached = cachedStats?.at(-1)?.created as number
|
||||
return await pb.collection<T>(collection).getFullList({
|
||||
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { lazy } from "react"
|
||||
import { lazy, useEffect, useRef, useState } from "react"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { chartTimeData, cn } from "@/lib/utils"
|
||||
import { NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
||||
import { LatencyChart } from "./charts/probes-charts"
|
||||
import { SystemData } from "./use-system-data"
|
||||
import { $chartTime } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import system from "../system"
|
||||
import { getStats, appendData } from "./chart-data"
|
||||
|
||||
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
||||
|
||||
@@ -37,11 +44,75 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||
|
||||
const NetworkProbesTableNew = lazy(() => import("@/components/network-probes-table/network-probes-table"))
|
||||
|
||||
export function LazyNetworkProbesTableNew({ systemId }: { systemId: string }) {
|
||||
const cache = new Map<string, any>()
|
||||
|
||||
export function LazyNetworkProbesTableNew({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
|
||||
const { grid, chartData } = systemData ?? {}
|
||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
||||
const chartTime = useStore($chartTime)
|
||||
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
|
||||
const statsRequestId = useRef(0)
|
||||
|
||||
// get stats when system "changes." (Not just system to system,
|
||||
// also when new info comes in via systemManager realtime connection, indicating an update)
|
||||
useEffect(() => {
|
||||
if (!systemId || !chartTime || chartTime === "1m") {
|
||||
return
|
||||
}
|
||||
|
||||
const { expectedInterval } = chartTimeData[chartTime]
|
||||
const ss_cache_key = `${systemId}${chartTime}`
|
||||
const requestId = ++statsRequestId.current
|
||||
|
||||
const cachedProbeStats = cache.get(ss_cache_key) as NetworkProbeStatsRecord[] | undefined
|
||||
|
||||
// Render from cache immediately if available
|
||||
// if (cachedProbeStats?.length) {
|
||||
// setProbeStats(cachedProbeStats)
|
||||
|
||||
// // Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
||||
// const lastCreated = cachedProbeStats.at(-1)?.created as number | undefined
|
||||
// if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
getStats<NetworkProbeStatsRecord>("network_probe_stats", systemId, chartTime, cachedProbeStats).then(
|
||||
(probeStats) => {
|
||||
// If another request has been made since this one, ignore the results
|
||||
if (requestId !== statsRequestId.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// make new system stats
|
||||
let probeStatsData = (cache.get(ss_cache_key) || []) as NetworkProbeStatsRecord[]
|
||||
if (probeStats.length) {
|
||||
probeStatsData = appendData(probeStatsData, probeStats, expectedInterval, 100)
|
||||
cache.set(ss_cache_key, probeStatsData)
|
||||
}
|
||||
setProbeStats(probeStatsData)
|
||||
}
|
||||
)
|
||||
}, [system, chartTime, probes])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && <NetworkProbesTableNew systemId={systemId} />}
|
||||
{isIntersecting && (
|
||||
<>
|
||||
<NetworkProbesTableNew systemId={systemId} probes={probes} setProbes={setProbes} />
|
||||
{!!chartData && (
|
||||
<LatencyChart
|
||||
probeStats={probeStats}
|
||||
grid={grid}
|
||||
probes={probes}
|
||||
chartData={chartData}
|
||||
empty={!probeStats.length}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { chartTimeData, cn, toFixedFloat, decimalString, getVisualStringWidth }
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { appendData } from "./chart-data"
|
||||
import { AddProbeDialog } from "./probe-dialog"
|
||||
// import { AddProbeDialog } from "./probe-dialog"
|
||||
import { ChartCard } from "./chart-card"
|
||||
import LineChartDefault, { type DataPoint } from "@/components/charts/line-chart"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
@@ -89,7 +89,7 @@ export default function NetworkProbes({
|
||||
if (data[i].stats) {
|
||||
const latest: Record<string, { avg: number; loss: number }> = {}
|
||||
for (const [key, val] of Object.entries(data[i].stats)) {
|
||||
latest[key] = { avg: val.avg, loss: val.loss }
|
||||
latest[key] = { avg: val?.[0], loss: val?.[3] }
|
||||
}
|
||||
setLatestResults(latest)
|
||||
break
|
||||
@@ -110,13 +110,22 @@ export default function NetworkProbes({
|
||||
const controller = new AbortController()
|
||||
const { type: statsType = "1m", expectedInterval } = chartTimeData[chartTime] ?? {}
|
||||
|
||||
pb.send<{ stats: NetworkProbeStatsRecord["stats"]; created: string }[]>("/api/beszel/network-probe-stats", {
|
||||
query: { system: systemId, type: statsType },
|
||||
signal: controller.signal,
|
||||
console.log("Fetching probe stats", { systemId, statsType, expectedInterval })
|
||||
|
||||
pb.collection<NetworkProbeStatsRecord>("network_probe_stats")
|
||||
.getList(0, 2000, {
|
||||
fields: "stats,created",
|
||||
filter: pb.filter("system={:system} && type={:type} && created <= {:created}", {
|
||||
system: systemId,
|
||||
type: statsType,
|
||||
created: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
sort: "-created",
|
||||
})
|
||||
.then((raw) => {
|
||||
console.log("Fetched probe stats", { raw })
|
||||
// Filter stats to only include currently active probes
|
||||
const mapped: NetworkProbeStatsRecord[] = raw.map((r) => {
|
||||
const mapped: NetworkProbeStatsRecord[] = raw.items.map((r) => {
|
||||
const filtered: NetworkProbeStatsRecord["stats"] = {}
|
||||
for (const [key, val] of Object.entries(r.stats)) {
|
||||
if (activeProbeKeys.has(key)) {
|
||||
@@ -132,12 +141,15 @@ export default function NetworkProbes({
|
||||
const last = mapped[mapped.length - 1].stats
|
||||
const latest: Record<string, { avg: number; loss: number }> = {}
|
||||
for (const [key, val] of Object.entries(last)) {
|
||||
latest[key] = { avg: val.avg, loss: val.loss }
|
||||
latest[key] = { avg: val?.[0], loss: val?.[3] }
|
||||
}
|
||||
setLatestResults(latest)
|
||||
}
|
||||
})
|
||||
.catch(() => setStats([]))
|
||||
.catch((e) => {
|
||||
console.error("Error fetching probe stats", e)
|
||||
setStats([])
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [system, chartTime, probes, activeProbeKeys])
|
||||
@@ -160,7 +172,7 @@ export default function NetworkProbes({
|
||||
const key = probeKey(p)
|
||||
return {
|
||||
label: p.name || p.target,
|
||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.avg ?? null,
|
||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null,
|
||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||
}
|
||||
})
|
||||
@@ -231,6 +243,8 @@ export default function NetworkProbes({
|
||||
// </Card>
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// console.log("Rendering NetworkProbes", { probes, stats })
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
@@ -245,7 +259,7 @@ export default function NetworkProbes({
|
||||
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<AddProbeDialog systemId={systemId} onCreated={fetchProbes} />
|
||||
{/* <AddProbeDialog systemId={systemId} onCreated={fetchProbes} /> */}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@@ -133,9 +133,7 @@ export function useSystemData(id: string) {
|
||||
data.container?.length > 0
|
||||
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
|
||||
: null
|
||||
const probePoint: NetworkProbeStatsRecord | null = data.probes
|
||||
? { stats: data.probes, created: now }
|
||||
: null
|
||||
const probePoint: NetworkProbeStatsRecord | null = data.probes ? { stats: data.probes, created: now } : null
|
||||
// on first message, make sure we clear out data from other time periods
|
||||
if (isFirst) {
|
||||
isFirst = false
|
||||
@@ -214,8 +212,8 @@ export function useSystemData(id: string) {
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
// If another request has been made since this one, ignore the results
|
||||
if (requestId !== statsRequestId.current) {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
13
internal/site/src/types.d.ts
vendored
13
internal/site/src/types.d.ts
vendored
@@ -561,7 +561,18 @@ export interface NetworkProbeRecord {
|
||||
updated: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 0: avg latency in ms
|
||||
*
|
||||
* 1: min latency in ms
|
||||
*
|
||||
* 2: max latency in ms
|
||||
*
|
||||
* 3: packet loss in %
|
||||
*/
|
||||
type ProbeResult = number[]
|
||||
|
||||
export interface NetworkProbeStatsRecord {
|
||||
stats: Record<string, { avg: number; min: number; max: number; loss: number }>
|
||||
stats: Record<string, ProbeResult>
|
||||
created: number // unix timestamp (ms) for Recharts xAxis
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user