This commit is contained in:
henrygd
2026-04-22 21:40:52 -04:00
parent 8f03cbf11c
commit 5fc774666f
12 changed files with 160 additions and 85 deletions

View File

@@ -161,7 +161,10 @@ func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
// Build set of new keys // Build set of new keys
newKeys := make(map[string]probe.Config, len(configs)) newKeys := make(map[string]probe.Config, len(configs))
for _, cfg := range configs { for _, cfg := range configs {
newKeys[cfg.Key()] = cfg if cfg.ID == "" {
continue
}
newKeys[cfg.ID] = cfg
} }
// Stop removed probes // Stop removed probes
@@ -196,7 +199,7 @@ func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
now := time.Now() now := time.Now()
duration := time.Duration(durationMs) * time.Millisecond duration := time.Duration(durationMs) * time.Millisecond
for key, task := range pm.probes { for _, task := range pm.probes {
task.mu.Lock() task.mu.Lock()
agg := task.aggregateLocked(duration, now) agg := task.aggregateLocked(duration, now)
hourAgg := task.aggregateLocked(time.Hour, now) hourAgg := task.aggregateLocked(time.Hour, now)
@@ -220,7 +223,7 @@ func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
} else { } else {
result = probe.Result{result[0], hourAvg, 0, 0, hourLoss} result = probe.Result{result[0], hourAvg, 0, 0, hourLoss}
} }
results[key] = result results[task.config.ID] = result
} }
return results return results

View File

@@ -72,7 +72,7 @@ func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing
func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) { func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
task := &probeTask{} task := &probeTask{config: probe.Config{ID: "probe-1"}}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-30 * time.Minute)}) task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-30 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-9 * time.Minute)}) task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-9 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 40, timestamp: now.Add(-5 * time.Minute)}) task.addSampleLocked(probeSample{responseMs: 40, timestamp: now.Add(-5 * time.Minute)})
@@ -82,7 +82,7 @@ func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}} pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
results := pm.GetResults(uint16(time.Minute / time.Millisecond)) results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["icmp:example.com"] result, ok := results["probe-1"]
require.True(t, ok) require.True(t, ok)
require.Len(t, result, 5) require.Len(t, result, 5)
assert.Equal(t, 30.0, result[0]) assert.Equal(t, 30.0, result[0])
@@ -94,14 +94,14 @@ func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) { func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
task := &probeTask{} task := &probeTask{config: probe.Config{ID: "probe-1"}}
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-30 * time.Second)}) task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-30 * time.Second)})
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-10 * time.Second)}) task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-10 * time.Second)})
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}} pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
results := pm.GetResults(uint16(time.Minute / time.Millisecond)) results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["icmp:example.com"] result, ok := results["probe-1"]
require.True(t, ok) require.True(t, ok)
require.Len(t, result, 5) require.Len(t, result, 5)
assert.Equal(t, 0.0, result[0]) assert.Equal(t, 0.0, result[0])
@@ -111,23 +111,42 @@ func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
assert.Equal(t, 100.0, result[4]) assert.Equal(t, 100.0, result[4])
} }
func TestProbeConfigResultKeyUsesSyncedID(t *testing.T) {
cfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
assert.Equal(t, "probe-1", cfg.ID)
}
func TestProbeManagerSyncProbesSkipsConfigsWithoutStableID(t *testing.T) {
validCfg := probe.Config{ID: "probe-1", Target: "https://example.com", Protocol: "http", Interval: 10}
invalidCfg := probe.Config{Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
pm := newProbeManager()
pm.SyncProbes([]probe.Config{validCfg, invalidCfg})
defer pm.Stop()
_, validExists := pm.probes[validCfg.ID]
_, invalidExists := pm.probes[invalidCfg.ID]
assert.True(t, validExists)
assert.False(t, invalidExists)
}
func TestProbeManagerSyncProbesStopsRemovedTasksButKeepsExisting(t *testing.T) { func TestProbeManagerSyncProbesStopsRemovedTasksButKeepsExisting(t *testing.T) {
keepCfg := probe.Config{Target: "https://example.com", Protocol: "http", Interval: 10} keepCfg := probe.Config{ID: "probe-1", Target: "https://example.com", Protocol: "http", Interval: 10}
removeCfg := probe.Config{Target: "1.1.1.1", Protocol: "icmp", Interval: 10} removeCfg := probe.Config{ID: "probe-2", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
keptTask := &probeTask{config: keepCfg, cancel: make(chan struct{})} keptTask := &probeTask{config: keepCfg, cancel: make(chan struct{})}
removedTask := &probeTask{config: removeCfg, cancel: make(chan struct{})} removedTask := &probeTask{config: removeCfg, cancel: make(chan struct{})}
pm := &ProbeManager{ pm := &ProbeManager{
probes: map[string]*probeTask{ probes: map[string]*probeTask{
keepCfg.Key(): keptTask, keepCfg.ID: keptTask,
removeCfg.Key(): removedTask, removeCfg.ID: removedTask,
}, },
} }
pm.SyncProbes([]probe.Config{keepCfg}) pm.SyncProbes([]probe.Config{keepCfg})
assert.Same(t, keptTask, pm.probes[keepCfg.Key()]) assert.Same(t, keptTask, pm.probes[keepCfg.ID])
_, exists := pm.probes[removeCfg.Key()] _, exists := pm.probes[removeCfg.ID]
assert.False(t, exists) assert.False(t, exists)
select { select {

View File

@@ -1,13 +1,13 @@
package probe package probe
import "strconv"
// Config defines a network probe task sent from hub to agent. // Config defines a network probe task sent from hub to agent.
type Config struct { type Config struct {
Target string `cbor:"0,keyasint" json:"target"` // ID is the stable network_probes record ID generated by the hub.
Protocol string `cbor:"1,keyasint" json:"protocol"` // "icmp", "tcp", or "http" ID string `cbor:"0,keyasint"`
Port uint16 `cbor:"2,keyasint,omitempty" json:"port,omitempty"` Target string `cbor:"1,keyasint"`
Interval uint16 `cbor:"3,keyasint" json:"interval"` // seconds Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", or "http"
Port uint16 `cbor:"3,keyasint,omitempty"`
Interval uint16 `cbor:"4,keyasint"` // seconds
} }
// Result holds aggregated probe results for a single target. // Result holds aggregated probe results for a single target.
@@ -22,13 +22,3 @@ type Config struct {
// //
// 4: packet loss percentage over the last hour (0-100) // 4: packet loss percentage over the last hour (0-100)
type Result []float64 type Result []float64
// Key returns the map key used for this probe config (e.g. "icmp:1.1.1.1", "tcp:host:443", "http:https://example.com").
func (c Config) Key() string {
switch c.Protocol {
case "tcp":
return c.Protocol + ":" + c.Target + ":" + strconv.FormatUint(uint64(c.Port), 10)
default:
return c.Protocol + ":" + c.Target
}
}

View File

@@ -1,11 +1,20 @@
package hub package hub
import ( import (
"strconv"
"github.com/henrygd/beszel/internal/entities/probe" "github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/systems"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
// generateProbeID creates a stable hash ID for a probe based on its configuration and the system it belongs to.
func generateProbeID(systemId string, config probe.Config) string {
intervalStr := strconv.FormatUint(uint64(config.Interval), 10)
portStr := strconv.FormatUint(uint64(config.Port), 10)
return systems.MakeStableHashId(systemId, config.Protocol, config.Target, portStr, intervalStr)
}
func bindNetworkProbesEvents(h *Hub) { func bindNetworkProbesEvents(h *Hub) {
// on create, make sure the id is set to a stable hash // on create, make sure the id is set to a stable hash
h.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error { h.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
@@ -16,8 +25,7 @@ func bindNetworkProbesEvents(h *Hub) {
Port: uint16(e.Record.GetInt("port")), Port: uint16(e.Record.GetInt("port")),
Interval: uint16(e.Record.GetInt("interval")), Interval: uint16(e.Record.GetInt("interval")),
} }
key := config.Key() id := generateProbeID(systemID, *config)
id := systems.MakeStableHashId(systemID, key)
e.Record.Set("id", id) e.Record.Set("id", id)
return e.Next() return e.Next()
}) })

View File

@@ -0,0 +1,79 @@
package hub
import (
"testing"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/stretchr/testify/assert"
)
func TestGenerateProbeID(t *testing.T) {
tests := []struct {
name string
systemID string
config probe.Config
expected string
}{
{
name: "HTTP probe on example.com",
systemID: "sys123",
config: probe.Config{
Protocol: "http",
Target: "example.com",
Port: 80,
Interval: 60,
},
expected: "d5f27931",
},
{
name: "HTTP probe on example.com with different system ID",
systemID: "sys1234",
config: probe.Config{
Protocol: "http",
Target: "example.com",
Port: 80,
Interval: 60,
},
expected: "6f8b17f1",
},
{
name: "Same probe, different interval",
systemID: "sys1234",
config: probe.Config{
Protocol: "http",
Target: "example.com",
Port: 80,
Interval: 120,
},
expected: "6d4baf8",
},
{
name: "ICMP probe on 1.1.1.1",
systemID: "sys456",
config: probe.Config{
Protocol: "icmp",
Target: "1.1.1.1",
Port: 0,
Interval: 10,
},
expected: "80b5836b",
}, {
name: "ICMP probe on 1.1.1.1 with different system ID",
systemID: "sys4567",
config: probe.Config{
Protocol: "icmp",
Target: "1.1.1.1",
Port: 0,
Interval: 10,
},
expected: "a6652680",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateProbeID(tt.systemID, tt.config)
assert.Equal(t, tt.expected, got, "generateProbeID() = %v, want %v", got, tt.expected)
})
}
}

View File

@@ -335,7 +335,7 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
if !realtimeActive { if !realtimeActive {
db = app.DB() db = app.DB()
nowString = time.Now().UTC().Format(types.DefaultDateLayout) nowString = time.Now().UTC().Format(types.DefaultDateLayout)
sql := fmt.Sprintf("UPDATE %s SET resAvg={:resAvg}, resMin1h={:resMin1h}, resMax1h={:resMax1h}, resAvg1h={:resAvg1h}, loss1h={:loss1h}, updated={:updated} WHERE id={:id}", collectionName) sql := fmt.Sprintf("UPDATE %s SET resAvg={:res}, resMin1h={:resMin1h}, resMax1h={:resMax1h}, resAvg1h={:resAvg1h}, loss1h={:loss1h}, updated={:updated} WHERE id={:id}", collectionName)
updateQuery = db.NewQuery(sql) updateQuery = db.NewQuery(sql)
} }
@@ -365,14 +365,13 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
} }
// update network_probes records // update network_probes records
for key, values := range data { for id, values := range data {
id := MakeStableHashId(systemId, key)
switch realtimeActive { switch realtimeActive {
case true: case true:
var record *core.Record var record *core.Record
record, err = app.FindRecordById(collectionName, id) record, err = app.FindRecordById(collectionName, id)
if err == nil { if err == nil {
record.Set("resAvg", probeMetric(values, 0)) record.Set("res", probeMetric(values, 0))
record.Set("resAvg1h", probeMetric(values, 1)) record.Set("resAvg1h", probeMetric(values, 1))
record.Set("resMin1h", probeMetric(values, 2)) record.Set("resMin1h", probeMetric(values, 2))
record.Set("resMax1h", probeMetric(values, 3)) record.Set("resMax1h", probeMetric(values, 3))
@@ -382,7 +381,7 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
default: default:
_, err = updateQuery.Bind(dbx.Params{ _, err = updateQuery.Bind(dbx.Params{
"id": id, "id": id,
"resAvg": probeMetric(values, 0), "res": probeMetric(values, 0),
"resAvg1h": probeMetric(values, 1), "resAvg1h": probeMetric(values, 1),
"resMin1h": probeMetric(values, 2), "resMin1h": probeMetric(values, 2),
"resMax1h": probeMetric(values, 3), "resMax1h": probeMetric(values, 3),
@@ -391,7 +390,7 @@ func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, syst
}).Execute() }).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", id, "err", err)
} }
} }

View File

@@ -325,7 +325,7 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
configs := sm.GetProbeConfigsForSystem(systemId) configs := sm.GetProbeConfigsForSystem(systemId)
if len(configs) > 0 { if len(configs) > 0 {
if err := system.SyncNetworkProbes(configs); err != nil { if err := system.SyncNetworkProbes(configs); err != nil {
sm.hub.Logger().Warn("failed to sync probes on connect", "system", systemId, "err", err) sm.hub.Logger().Warn("failed to sync probes to agent", "system", systemId, "err", err)
} }
} }
}() }()
@@ -344,26 +344,11 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
// GetProbeConfigsForSystem returns all enabled probe configs for a system. // GetProbeConfigsForSystem returns all enabled probe configs for a system.
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config { func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
records, err := sm.hub.FindRecordsByFilter( var configs []probe.Config
"network_probes", _ = sm.hub.DB().
"system = {:system} && enabled = true", NewQuery("SELECT id, target, protocol, port, interval FROM network_probes WHERE system = {:system} AND enabled = true").
"", Bind(dbx.Params{"system": systemID}).
0, 0, All(&configs)
dbx.Params{"system": systemID},
)
if err != nil || len(records) == 0 {
return nil
}
configs := make([]probe.Config, 0, len(records))
for _, r := range records {
configs = append(configs, probe.Config{
Target: r.GetString("target"),
Protocol: r.GetString("protocol"),
Port: uint16(r.GetInt("port")),
Interval: uint16(r.GetInt("interval")),
})
}
return configs return configs
} }

View File

@@ -124,22 +124,22 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
}, },
{ {
id: "loss", id: "loss",
accessorFn: (record) => record.loss, accessorFn: (record) => record.loss1h,
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Loss`} Icon={WifiOffIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
cell: ({ row }) => { cell: ({ row }) => {
const { loss, res } = row.original const { loss1h, res } = row.original
if (loss === undefined || (!res && !loss)) { if (loss1h === undefined || (!res && !loss1h)) {
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 (loss) { if (loss1h) {
color = loss > 20 ? "bg-red-500" : "bg-yellow-500" color = loss1h > 20 ? "bg-red-500" : "bg-yellow-500"
} }
return ( return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center"> <span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} /> <span className={cn("shrink-0 size-2 rounded-full", color)} />
{loss}% {loss1h}%
</span> </span>
) )
}, },
@@ -232,7 +232,6 @@ function HeaderButton({
> >
{Icon && <Icon className="size-4" />} {Icon && <Icon className="size-4" />}
{name} {name}
{/* <ArrowUpDownIcon className="size-4" /> */}
</Button> </Button>
) )
} }

View File

@@ -102,7 +102,7 @@ export default function NetworkProbesTableNew({
<Trans>Network Probes</Trans> <Trans>Network Probes</Trans>
</CardTitle> </CardTitle>
<div className="text-sm text-muted-foreground flex items-center flex-wrap"> <div className="text-sm text-muted-foreground flex items-center flex-wrap">
<Trans>ICMP/TCP/HTTP response monitoring from agents</Trans> <Trans>Response time monitoring from agents.</Trans>
</div> </div>
</div> </div>
<div className="md:ms-auto flex items-center gap-2"> <div className="md:ms-auto flex items-center gap-2">

View File

@@ -7,7 +7,6 @@ import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/t
import { useMemo } from "react" import { useMemo } from "react"
import { atom } from "nanostores" import { atom } from "nanostores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { probeKey } from "@/lib/use-network-probes"
const $filter = atom("") const $filter = atom("")
@@ -47,7 +46,7 @@ function ProbeChart({
const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h) const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h)
const count = sortedProbes.length const count = sortedProbes.length
const points: DataPoint<NetworkProbeStatsRecord>[] = [] const points: DataPoint<NetworkProbeStatsRecord>[] = []
const visibleKeys: string[] = [] const visibleIDs: string[] = []
const filterTerms = filter const filterTerms = filter
? filter ? filter
.toLowerCase() .toLowerCase()
@@ -56,25 +55,25 @@ function ProbeChart({
: [] : []
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const p = sortedProbes[i] const p = sortedProbes[i]
const key = probeKey(p) const label = p.name || p.target
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term)) const filtered = filterTerms.length > 0 && !filterTerms.some((term) => label.toLowerCase().includes(term))
if (filtered) { if (filtered) {
continue continue
} }
visibleKeys.push(key) visibleIDs.push(p.id)
points.push({ points.push({
order: i, order: i,
label: p.name || p.target, label,
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[valueIndex] ?? "-", dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-",
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))`,
}) })
} }
return { dataPoints: points, visibleKeys } return { dataPoints: points, visibleKeys: visibleIDs }
}, [probes, filter, valueIndex]) }, [probes, filter, valueIndex])
const filteredProbeStats = useMemo(() => { const filteredProbeStats = useMemo(() => {
if (!visibleKeys.length) return probeStats if (!visibleKeys.length) return probeStats
return probeStats.filter((record) => visibleKeys.some((key) => record.stats?.[key] != null)) return probeStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
}, [probeStats, visibleKeys]) }, [probeStats, visibleKeys])
const legend = dataPoints.length < 10 const legend = dataPoints.length < 10

View File

@@ -32,7 +32,7 @@ function appendCacheValue(
} }
const NETWORK_PROBE_FIELDS = const NETWORK_PROBE_FIELDS =
"id,name,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss,enabled,updated" "id,name,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
interface UseNetworkProbesProps { interface UseNetworkProbesProps {
systemId?: string systemId?: string
@@ -245,16 +245,10 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
} }
} }
export function probeKey(p: NetworkProbeRecord) {
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
return `${p.protocol}:${p.target}`
}
function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] { function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
const stats: NetworkProbeStatsRecord["stats"] = {} const stats: NetworkProbeStatsRecord["stats"] = {}
for (const probe of probes) { for (const probe of probes) {
const key = probeKey(probe) stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h]
stats[key] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss]
} }
return stats return stats
} }

View File

@@ -556,7 +556,7 @@ export interface NetworkProbeRecord {
resMin1h: number resMin1h: number
resMax1h: number resMax1h: number
resAvg1h: number resAvg1h: number
loss: number loss1h: number
interval: number interval: number
enabled: boolean enabled: boolean
updated: string updated: string