mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 20:21:49 +02:00
updates
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
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],
|
"latency": probe[0],
|
||||||
"loss": probe[3],
|
"loss": probe[3],
|
||||||
"updated": nowString,
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
maxVal float64
|
|
||||||
lossSum float64
|
|
||||||
count 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,7 +163,7 @@ 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>
|
||||||
|
|||||||
@@ -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 />
|
<NetworkProbesTableNew probes={probes} setProbes={setProbes} />
|
||||||
</div>
|
</div>
|
||||||
<FooterRepoLink />
|
<FooterRepoLink />
|
||||||
</>
|
</>
|
||||||
),
|
|
||||||
[]
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}", {
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user