Compare commits

..

39 Commits

Author SHA1 Message Date
henrygd
5fc774666f updates 2026-04-22 21:40:52 -04:00
henrygd
8f03cbf11c updates 2026-04-22 19:40:21 -04:00
henrygd
1c5808f430 update 2026-04-22 19:29:36 -04:00
henrygd
a35cc6ef39 upupdate 2026-04-22 18:03:31 -04:00
henrygd
16e0f6c4a2 updates 2026-04-22 17:42:11 -04:00
henrygd
6472af1ba4 updates 2026-04-21 21:57:24 -04:00
henrygd
e931165566 updates 2026-04-21 15:44:08 -04:00
henrygd
48fe407292 use network probes 2026-04-21 15:29:46 -04:00
henrygd
a95376b4a2 updates 2026-04-21 12:33:16 -04:00
henrygd
732983493a update 2026-04-20 21:28:09 -04:00
henrygd
264b17f429 updte 2026-04-20 21:27:16 -04:00
henrygd
cef5ab10a5 updates 2026-04-20 21:24:46 -04:00
henrygd
3a881e1d5e add probes page 2026-04-20 11:52:37 -04:00
henrygd
209bb4ebb4 update 2026-04-20 10:48:05 -04:00
henrygd
e71ffd4d2a updates 2026-04-19 21:44:21 -04:00
henrygd
ea19ef6334 updates 2026-04-19 19:12:04 -04:00
henrygd
40da2b4358 updates 2026-04-18 20:28:22 -04:00
henrygd
d0d5912d85 updates 2026-04-18 18:09:45 -04:00
Claude
4162186ae0 Merge remote-tracking branch 'upstream/main' into feat/network-probes
# Conflicts:
#	agent/connection_manager.go
2026-04-18 01:19:49 +00:00
Uğur Tafralı
a71617e058 feat(agent): Add EXIT_ON_DNS_ERROR environment variable (#1929)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-17 19:26:11 -04:00
xiaomiku01
578ba985e9 Merge branch 'main' into feat/network-probes
Resolved conflict in internal/records/records.go:
- Upstream refactor moved deletion code to records_deletion.go and
  switched averaging functions from package-level globals to local
  variables (var row StatsRecord / params := make(dbx.Params, 1)).
- Kept AverageProbeStats and rewrote it to match the new local-variable
  pattern.
- Dropped duplicated deletion helpers from records.go (they now live in
  records_deletion.go).
- Added "network_probe_stats" to the collections list in
  records_deletion.go:deleteOldSystemStats so probe stats keep the same
  retention policy.
2026-04-17 13:49:18 +08:00
xiaomiku01
485830452e fix(agent): exclude DNS resolution from TCP probe latency
Resolve the target hostname before starting the timer so the
measurement reflects pure TCP handshake time only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:21:15 +08:00
xiaomiku01
2fd00cd0b5 feat(agent): use native ICMP sockets with fallback to system ping
Replace the ping-command-only implementation with a three-tier
approach using golang.org/x/net/icmp:

1. Raw socket (ip4:icmp) — works with root or CAP_NET_RAW
2. Unprivileged datagram socket (udp4) — works on Linux/macOS
   without special privileges
3. System ping command — fallback when neither socket works

The method is auto-detected on first probe and cached for all
subsequent calls, avoiding repeated failed attempts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:09:12 +08:00
xiaomiku01
853a294157 fix(ui): add gap detection to probe chart and fix color limit
- Apply appendData() for gap detection in both realtime and non-realtime
  modes, so the latency chart shows breaks instead of smooth lines when
  data is missing during service interruptions
- Handle null stats in gap marker entries to prevent runtime crashes
- Fix color assignment: use CSS variables (--chart-1..5) for ≤5 probes,
  switch to dynamic HSL distribution for >5 probes so all lines are
  visible with distinct colors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:46:03 +08:00
xiaomiku01
aa9ab49654 fix(ui): auto-refresh probe stats when system data updates
Pass system record to NetworkProbes component and use it as a
dependency in the non-realtime fetch effect, matching the pattern
used by system_stats and container_stats in use-system-data.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:44:09 +08:00
xiaomiku01
9a5959b57e fix: address network probe code quality issues
- Use shared http.Client in ProbeManager to avoid connection/transport leak
- Skip probe goroutine and agent request when system has no enabled probes
- Validate HTTP probe target URL scheme (http:// or https://) on creation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:40:27 +08:00
xiaomiku01
50f8548479 fix: add migration for network probe collections on existing databases
Existing databases from main branch lack the network_probes and
network_probe_stats collections, which were only in the initial snapshot.
This separate migration ensures they are created on upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:26:58 +08:00
xiaomiku01
bc0581ea61 feat: add network probe data to realtime mode
Include probe results in the 1-second realtime WebSocket broadcast so
the frontend can update probe latency/loss every second, matching the
behavior of system and container metrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:22 +08:00
xiaomiku01
fab5e8a656 fix(ui): filter deleted probes from latency chart stats
Stats records in the DB contain historical data for all probes including
deleted ones. Now filters stats by active probe keys and clears state
when all probes are removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
3a0896e57e fix(ui): address code quality review findings for network probes
- Rename setInterval to setProbeInterval to avoid shadowing global
- Move probeKey function outside component (pure function)
- Fix probes.length dependency to use probes directly
- Use proper type for stats fetch instead of any
- Fix name column fallback to show target instead of dash

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
7fdc403470 feat(ui): integrate network probes into system detail page
Lazy-load the NetworkProbes component in both default and tabbed
layouts so the probes table and latency chart appear on the system
detail page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
e833d44c43 feat(ui): add network probes table and latency chart section
Displays probe list with protocol badges, latency/loss stats, and
delete functionality. Includes a latency line chart using ChartCard
with data sourced from the network-probe-stats API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
77dd4bdaf5 feat(ui): add network probe creation dialog
Dialog component for adding ICMP/TCP/HTTP network probes with
protocol selection, target, port, interval, and name fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
ecba63c4bb feat(ui): add NetworkProbeRecord and NetworkProbeStatsRecord types
Add TypeScript interfaces for the network probes feature API responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
f9feaf5343 feat(hub): add network probe API, sync, result collection, and aggregation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
ddf5e925c8 feat: add network_probes and network_probe_stats PocketBase collections 2026-04-11 01:21:38 +08:00
xiaomiku01
865e6db90f feat(agent): add ProbeManager with ICMP/TCP/HTTP probes and handlers
Implements the core probe execution engine (ProbeManager) that runs
network probes on configurable intervals, collects latency samples,
and aggregates results over a 60s sliding window. Adds two new
WebSocket handlers (SyncNetworkProbes, GetNetworkProbeResults) for
hub-agent communication and integrates probe lifecycle into the agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:21:38 +08:00
xiaomiku01
a42d899e64 feat: add shared probe entity types (Config, Result) 2026-04-11 01:21:38 +08:00
xiaomiku01
3eaf12a7d5 feat: add SyncNetworkProbes and GetNetworkProbeResults action types 2026-04-11 01:21:38 +08:00
46 changed files with 3211 additions and 124 deletions

View File

@@ -48,6 +48,7 @@ type Agent struct {
keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services
probeManager *ProbeManager // Manages network probes
}
// NewAgent creates a new agent with the given data directory for persisting data.
@@ -121,6 +122,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize handler registry
agent.handlerRegistry = NewHandlerRegistry()
// initialize probe manager
agent.probeManager = newProbeManager()
// initialize disk info
agent.initializeDiskInfo()
@@ -178,6 +182,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
}
}
if a.probeManager != nil {
data.Probes = a.probeManager.GetResults(cacheTimeMs)
slog.Debug("Probes", "data", data.Probes)
}
// skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
totalCount := uint16(a.systemdManager.getServiceStatsCount())

View File

@@ -4,11 +4,15 @@ import (
"context"
"errors"
"log/slog"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/henrygd/beszel/agent/health"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
@@ -111,13 +115,37 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
_ = health.Update()
case <-sigCtx.Done():
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
return c.stop()
}
}
}
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
//
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
//
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
// ctx, cancel := context.WithCancel(context.Background())
// c.cancel = cancel
//
// for {
// select {
// case <-ctx.Done():
// return c.stop()
// }
// }
// }
//
// func (c *ConnectionManager) Stop() {
// c.cancel()
// }
func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer()
c.agent.probeManager.Stop()
c.closeWebSocket()
return health.CleanUp()
}
// handleEvent processes connection events and updates the connection state accordingly.
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
switch event {
@@ -185,9 +213,16 @@ func (c *ConnectionManager) connect() {
// Try WebSocket first, if it fails, start SSH server
err := c.startWebSocketConnection()
if err != nil && c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
if err != nil {
if shouldExitOnErr(err) {
time.Sleep(2 * time.Second) // prevent tight restart loop
_ = c.stop()
os.Exit(1)
}
if c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
}
}
}
@@ -224,3 +259,14 @@ func (c *ConnectionManager) closeWebSocket() {
c.wsClient.Close()
}
}
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
func shouldExitOnErr(err error) bool {
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
if opErr, ok := errors.AsType[*net.OpError](err); ok {
return strings.Contains(opErr.Err.Error(), "lookup")
}
}
return false
}

View File

@@ -4,6 +4,7 @@ package agent
import (
"crypto/ed25519"
"errors"
"fmt"
"net"
"net/url"
@@ -298,3 +299,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
cm.connect()
}, "Connect should not panic without WebSocket client")
}
func TestShouldExitOnErr(t *testing.T) {
createDialErr := func(msg string) error {
return &net.OpError{
Op: "dial",
Net: "tcp",
Err: errors.New(msg),
}
}
tests := []struct {
name string
err error
envValue string
expected bool
}{
{
name: "no env var",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "",
expected: false,
},
{
name: "env var false",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "false",
expected: false,
},
{
name: "env var true, matching error",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "true",
expected: true,
},
{
name: "env var true, matching error with extra context",
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
envValue: "true",
expected: true,
},
{
name: "env var true, non-matching error",
err: errors.New("connection refused"),
envValue: "true",
expected: false,
},
{
name: "env var true, dial but not lookup",
err: createDialErr("connection timeout"),
envValue: "true",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
result := shouldExitOnErr(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/smart"
"log/slog"
@@ -51,6 +52,7 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
registry.Register(common.SyncNetworkProbes, &SyncNetworkProbesHandler{})
return registry
}
@@ -203,3 +205,19 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
return hctx.SendResponse(details, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// SyncNetworkProbesHandler handles probe configuration sync from hub
type SyncNetworkProbesHandler struct{}
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
var configs []probe.Config
if err := cbor.Unmarshal(hctx.Request.Data, &configs); err != nil {
return err
}
hctx.Agent.probeManager.SyncProbes(configs)
slog.Info("network probes synced", "count", len(configs))
return hctx.SendResponse("ok", hctx.RequestID)
}

387
agent/probe.go Normal file
View File

@@ -0,0 +1,387 @@
package agent
import (
"fmt"
"math"
"net"
"net/http"
"sync"
"time"
"log/slog"
"github.com/henrygd/beszel/internal/entities/probe"
)
// Probe functionality overview:
// Probes run at user-defined intervals (e.g., every 10s).
// To keep memory usage low and constant, data is stored in two layers:
// 1. Raw samples: The most recent individual results (kept for probeRawRetention).
// 2. Minute buckets: A fixed-size ring buffer of 61 buckets, each representing one
// wall-clock minute. Samples collected within the same minute are aggregated
// (sum, min, max, count) into a single bucket.
//
// Short-term requests (<= 2m) use raw samples for perfect accuracy.
// Long-term requests (up to 1h) use the minute buckets to avoid storing thousands
// of individual data points.
const (
// probeRawRetention is the duration to keep individual samples for high-precision short-term requests
probeRawRetention = 80 * time.Second
// probeMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
probeMinuteBucketLen int32 = 61
)
// ProbeManager manages network probe tasks.
type ProbeManager struct {
mu sync.RWMutex
probes map[string]*probeTask // key = probe.Config.Key()
httpClient *http.Client
}
// probeTask owns retention buffers and cancellation for a single probe config.
type probeTask struct {
config probe.Config
cancel chan struct{}
mu sync.Mutex
samples []probeSample
buckets [probeMinuteBucketLen]probeBucket
}
// probeSample stores one probe attempt and its collection time.
type probeSample struct {
responseMs float64 // -1 means loss
timestamp time.Time
}
// probeBucket stores one minute of aggregated probe data.
type probeBucket struct {
minute int32
filled bool
stats probeAggregate
}
// probeAggregate accumulates successful response stats and total sample counts.
type probeAggregate struct {
sumMs float64
minMs float64
maxMs float64
totalCount int
successCount int
}
func newProbeManager() *ProbeManager {
return &ProbeManager{
probes: make(map[string]*probeTask),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// newProbeAggregate initializes an aggregate with an unset minimum value.
func newProbeAggregate() probeAggregate {
return probeAggregate{minMs: math.MaxFloat64}
}
// addResponse folds a single probe sample into the aggregate.
func (agg *probeAggregate) addResponse(responseMs float64) {
agg.totalCount++
if responseMs < 0 {
return
}
agg.successCount++
agg.sumMs += responseMs
if responseMs < agg.minMs {
agg.minMs = responseMs
}
if responseMs > agg.maxMs {
agg.maxMs = responseMs
}
}
// addAggregate merges another aggregate into this one.
func (agg *probeAggregate) addAggregate(other probeAggregate) {
if other.totalCount == 0 {
return
}
agg.totalCount += other.totalCount
agg.successCount += other.successCount
agg.sumMs += other.sumMs
if other.successCount == 0 {
return
}
if agg.minMs == math.MaxFloat64 || other.minMs < agg.minMs {
agg.minMs = other.minMs
}
if other.maxMs > agg.maxMs {
agg.maxMs = other.maxMs
}
}
// hasData reports whether the aggregate contains any samples.
func (agg probeAggregate) hasData() bool {
return agg.totalCount > 0
}
// result converts the aggregate into the probe result slice format.
func (agg probeAggregate) result() probe.Result {
avg := agg.avgResponse()
minMs := 0.0
if agg.successCount > 0 {
minMs = math.Round(agg.minMs*100) / 100
}
return probe.Result{
avg,
minMs,
math.Round(agg.maxMs*100) / 100,
agg.lossPercentage(),
}
}
// avgResponse returns the rounded average of successful samples.
func (agg probeAggregate) avgResponse() float64 {
if agg.successCount == 0 {
return 0
}
return math.Round(agg.sumMs/float64(agg.successCount)*100) / 100
}
// lossPercentage returns the rounded failure rate for the aggregate.
func (agg probeAggregate) lossPercentage() float64 {
if agg.totalCount == 0 {
return 0
}
return math.Round(float64(agg.totalCount-agg.successCount)/float64(agg.totalCount)*10000) / 100
}
// SyncProbes replaces all probe tasks with the given configs.
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
pm.mu.Lock()
defer pm.mu.Unlock()
// Build set of new keys
newKeys := make(map[string]probe.Config, len(configs))
for _, cfg := range configs {
if cfg.ID == "" {
continue
}
newKeys[cfg.ID] = cfg
}
// Stop removed probes
for key, task := range pm.probes {
if _, exists := newKeys[key]; !exists {
close(task.cancel)
delete(pm.probes, key)
}
}
// Start new probes (skip existing ones with same key)
for key, cfg := range newKeys {
if _, exists := pm.probes[key]; exists {
continue
}
task := &probeTask{
config: cfg,
cancel: make(chan struct{}),
samples: make([]probeSample, 0, 64),
}
pm.probes[key] = task
go pm.runProbe(task)
}
}
// GetResults returns aggregated results for all probes over the last supplied duration in ms.
func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
pm.mu.RLock()
defer pm.mu.RUnlock()
results := make(map[string]probe.Result, len(pm.probes))
now := time.Now()
duration := time.Duration(durationMs) * time.Millisecond
for _, task := range pm.probes {
task.mu.Lock()
agg := task.aggregateLocked(duration, now)
hourAgg := task.aggregateLocked(time.Hour, now)
task.mu.Unlock()
if !agg.hasData() {
continue
}
result := agg.result()
hourAvg := hourAgg.avgResponse()
hourLoss := hourAgg.lossPercentage()
if hourAgg.successCount > 0 {
result = probe.Result{
result[0],
hourAvg,
math.Round(hourAgg.minMs*100) / 100,
math.Round(hourAgg.maxMs*100) / 100,
hourLoss,
}
} else {
result = probe.Result{result[0], hourAvg, 0, 0, hourLoss}
}
results[task.config.ID] = result
}
return results
}
// Stop stops all probe tasks.
func (pm *ProbeManager) Stop() {
pm.mu.Lock()
defer pm.mu.Unlock()
for key, task := range pm.probes {
close(task.cancel)
delete(pm.probes, key)
}
}
// runProbe executes a single probe task in a loop.
func (pm *ProbeManager) runProbe(task *probeTask) {
interval := time.Duration(task.config.Interval) * time.Second
if interval < time.Second {
interval = 10 * time.Second
}
ticker := time.Tick(interval)
// Run immediately on start
pm.executeProbe(task)
for {
select {
case <-task.cancel:
return
case <-ticker:
pm.executeProbe(task)
}
}
}
// aggregateLocked collects probe data for the requested time window.
func (task *probeTask) aggregateLocked(duration time.Duration, now time.Time) probeAggregate {
cutoff := now.Add(-duration)
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
if duration <= probeRawRetention {
return aggregateSamplesSince(task.samples, cutoff)
}
return aggregateBucketsSince(task.buckets[:], cutoff, now)
}
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
func aggregateSamplesSince(samples []probeSample, cutoff time.Time) probeAggregate {
agg := newProbeAggregate()
for _, sample := range samples {
if sample.timestamp.Before(cutoff) {
continue
}
agg.addResponse(sample.responseMs)
}
return agg
}
// aggregateBucketsSince aggregates minute buckets overlapping the requested window.
func aggregateBucketsSince(buckets []probeBucket, cutoff, now time.Time) probeAggregate {
agg := newProbeAggregate()
startMinute := int32(cutoff.Unix() / 60)
endMinute := int32(now.Unix() / 60)
for _, bucket := range buckets {
if !bucket.filled || bucket.minute < startMinute || bucket.minute > endMinute {
continue
}
agg.addAggregate(bucket.stats)
}
return agg
}
// addSampleLocked stores a fresh sample in both raw and per-minute retention buffers.
func (task *probeTask) addSampleLocked(sample probeSample) {
cutoff := sample.timestamp.Add(-probeRawRetention)
start := 0
for i := range task.samples {
if !task.samples[i].timestamp.Before(cutoff) {
start = i
break
}
if i == len(task.samples)-1 {
start = len(task.samples)
}
}
if start > 0 {
size := copy(task.samples, task.samples[start:])
task.samples = task.samples[:size]
}
task.samples = append(task.samples, sample)
minute := int32(sample.timestamp.Unix() / 60)
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per probe.
bucket := &task.buckets[minute%probeMinuteBucketLen]
if !bucket.filled || bucket.minute != minute {
bucket.minute = minute
bucket.filled = true
bucket.stats = newProbeAggregate()
}
bucket.stats.addResponse(sample.responseMs)
}
// executeProbe runs the configured probe and records the sample.
func (pm *ProbeManager) executeProbe(task *probeTask) {
var responseMs float64
switch task.config.Protocol {
case "icmp":
responseMs = probeICMP(task.config.Target)
case "tcp":
responseMs = probeTCP(task.config.Target, task.config.Port)
case "http":
responseMs = probeHTTP(pm.httpClient, task.config.Target)
default:
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
return
}
sample := probeSample{
responseMs: responseMs,
timestamp: time.Now(),
}
task.mu.Lock()
task.addSampleLocked(sample)
task.mu.Unlock()
}
// probeTCP measures pure TCP handshake response (excluding DNS resolution).
// Returns -1 on failure.
func probeTCP(target string, port uint16) float64 {
// Resolve DNS first, outside the timing window
ips, err := net.LookupHost(target)
if err != nil || len(ips) == 0 {
return -1
}
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
// Measure only the TCP handshake
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return -1
}
conn.Close()
return float64(time.Since(start).Microseconds()) / 1000.0
}
// probeHTTP measures HTTP GET request response. Returns -1 on failure.
func probeHTTP(client *http.Client, url string) float64 {
start := time.Now()
resp, err := client.Get(url)
if err != nil {
return -1
}
resp.Body.Close()
if resp.StatusCode >= 400 {
return -1
}
return float64(time.Since(start).Microseconds()) / 1000.0
}

242
agent/probe_ping.go Normal file
View File

@@ -0,0 +1,242 @@
package agent
import (
"net"
"os"
"os/exec"
"regexp"
"runtime"
"strconv"
"sync"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"log/slog"
)
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
type icmpPacketConn interface {
Close() error
}
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
// all native methods fail, the choice is cached so subsequent probes skip
// the trial-and-error overhead.
type icmpMethod int
const (
icmpUntried icmpMethod = iota // haven't tried yet
icmpRaw // privileged raw socket
icmpDatagram // unprivileged datagram socket
icmpExecFallback // shell out to system ping command
)
// icmpFamily holds the network parameters and cached detection result for one address family.
type icmpFamily struct {
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
dgramNetwork string // e.g. "udp4" or "udp6"
listenAddr string // "0.0.0.0" or "::"
echoType icmp.Type // outgoing echo request type
replyType icmp.Type // expected echo reply type
proto int // IANA protocol number for parsing replies
isIPv6 bool
mode icmpMethod // cached detection result (guarded by icmpModeMu)
}
var (
icmpV4 = icmpFamily{
rawNetwork: "ip4:icmp",
dgramNetwork: "udp4",
listenAddr: "0.0.0.0",
echoType: ipv4.ICMPTypeEcho,
replyType: ipv4.ICMPTypeEchoReply,
proto: 1,
}
icmpV6 = icmpFamily{
rawNetwork: "ip6:ipv6-icmp",
dgramNetwork: "udp6",
listenAddr: "::",
echoType: ipv6.ICMPTypeEchoRequest,
replyType: ipv6.ICMPTypeEchoReply,
proto: 58,
isIPv6: true,
}
icmpModeMu sync.Mutex
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
return icmp.ListenPacket(network, listenAddr)
}
)
// probeICMP sends an ICMP echo request and measures round-trip response.
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
// unprivileged datagram, or exec fallback) is detected once per address
// family and cached for subsequent probes.
// Returns response in milliseconds, or -1 on failure.
func probeICMP(target string) float64 {
family, ip := resolveICMPTarget(target)
if family == nil {
return -1
}
icmpModeMu.Lock()
if family.mode == icmpUntried {
family.mode = detectICMPMode(family, icmpListen)
}
mode := family.mode
icmpModeMu.Unlock()
switch mode {
case icmpRaw:
return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
case icmpDatagram:
return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
case icmpExecFallback:
return probeICMPExec(target, family.isIPv6)
default:
return -1
}
}
// resolveICMPTarget resolves a target hostname or IP to determine the address
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
func resolveICMPTarget(target string) (*icmpFamily, net.IP) {
if ip := net.ParseIP(target); ip != nil {
if ip.To4() != nil {
return &icmpV4, ip.To4()
}
return &icmpV6, ip
}
ips, err := net.LookupIP(target)
if err != nil || len(ips) == 0 {
return nil, nil
}
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
return &icmpV4, v4
}
}
return &icmpV6, ips[0]
}
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
label := "IPv4"
if family.isIPv6 {
label = "IPv6"
}
if conn, err := listen(family.rawNetwork, family.listenAddr); err == nil {
conn.Close()
slog.Info("ICMP probe using raw socket", "family", label)
return icmpRaw
} else {
slog.Debug("ICMP raw socket unavailable", "family", label, "err", err)
}
if conn, err := listen(family.dgramNetwork, family.listenAddr); err == nil {
conn.Close()
slog.Info("ICMP probe using unprivileged datagram socket", "family", label)
return icmpDatagram
} else {
slog.Debug("ICMP datagram socket unavailable", "family", label, "err", err)
}
slog.Info("ICMP probe falling back to system ping command", "family", label)
return icmpExecFallback
}
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
conn, err := icmp.ListenPacket(network, family.listenAddr)
if err != nil {
return -1
}
defer conn.Close()
// Build ICMP echo request
msg := &icmp.Message{
Type: family.echoType,
Code: 0,
Body: &icmp.Echo{
ID: os.Getpid() & 0xffff,
Seq: 1,
Data: []byte("beszel-probe"),
},
}
msgBytes, err := msg.Marshal(nil)
if err != nil {
return -1
}
// Set deadline before sending
conn.SetDeadline(time.Now().Add(3 * time.Second))
start := time.Now()
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
return -1
}
// Read reply
buf := make([]byte, 1500)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
return -1
}
reply, err := icmp.ParseMessage(family.proto, buf[:n])
if err != nil {
return -1
}
if reply.Type == family.replyType {
return float64(time.Since(start).Microseconds()) / 1000.0
}
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
}
}
// probeICMPExec falls back to the system ping command. Returns -1 on failure.
func probeICMPExec(target string, isIPv6 bool) float64 {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
if isIPv6 {
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
} else {
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
}
default: // linux, darwin, freebsd
if isIPv6 {
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
} else {
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
}
}
start := time.Now()
output, err := cmd.Output()
if err != nil {
// If ping fails but we got output, still try to parse
if len(output) == 0 {
return -1
}
}
matches := pingTimeRegex.FindSubmatch(output)
if len(matches) >= 2 {
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
return ms
}
}
// Fallback: use wall clock time if ping succeeded but parsing failed
if err == nil {
return float64(time.Since(start).Microseconds()) / 1000.0
}
return -1
}

118
agent/probe_ping_test.go Normal file
View File

@@ -0,0 +1,118 @@
//go:build testing
package agent
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testICMPPacketConn struct{}
func (testICMPPacketConn) Close() error { return nil }
func TestDetectICMPMode(t *testing.T) {
tests := []struct {
name string
family *icmpFamily
rawErr error
udpErr error
want icmpMethod
wantNetworks []string
}{
{
name: "IPv4 prefers raw socket when available",
family: &icmpV4,
want: icmpRaw,
wantNetworks: []string{"ip4:icmp"},
},
{
name: "IPv4 uses datagram when raw unavailable",
family: &icmpV4,
rawErr: errors.New("operation not permitted"),
want: icmpDatagram,
wantNetworks: []string{"ip4:icmp", "udp4"},
},
{
name: "IPv4 falls back to exec when both unavailable",
family: &icmpV4,
rawErr: errors.New("operation not permitted"),
udpErr: errors.New("protocol not supported"),
want: icmpExecFallback,
wantNetworks: []string{"ip4:icmp", "udp4"},
},
{
name: "IPv6 prefers raw socket when available",
family: &icmpV6,
want: icmpRaw,
wantNetworks: []string{"ip6:ipv6-icmp"},
},
{
name: "IPv6 uses datagram when raw unavailable",
family: &icmpV6,
rawErr: errors.New("operation not permitted"),
want: icmpDatagram,
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
},
{
name: "IPv6 falls back to exec when both unavailable",
family: &icmpV6,
rawErr: errors.New("operation not permitted"),
udpErr: errors.New("protocol not supported"),
want: icmpExecFallback,
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls := make([]string, 0, 2)
listen := func(network, listenAddr string) (icmpPacketConn, error) {
require.Equal(t, tt.family.listenAddr, listenAddr)
calls = append(calls, network)
switch network {
case tt.family.rawNetwork:
if tt.rawErr != nil {
return nil, tt.rawErr
}
case tt.family.dgramNetwork:
if tt.udpErr != nil {
return nil, tt.udpErr
}
default:
t.Fatalf("unexpected network %q", network)
}
return testICMPPacketConn{}, nil
}
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
assert.Equal(t, tt.wantNetworks, calls)
})
}
}
func TestResolveICMPTarget(t *testing.T) {
t.Run("IPv4 literal", func(t *testing.T) {
family, ip := resolveICMPTarget("127.0.0.1")
require.NotNil(t, family)
assert.False(t, family.isIPv6)
assert.Equal(t, "127.0.0.1", ip.String())
})
t.Run("IPv6 literal", func(t *testing.T) {
family, ip := resolveICMPTarget("::1")
require.NotNil(t, family)
assert.True(t, family.isIPv6)
assert.Equal(t, "::1", ip.String())
})
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
family, ip := resolveICMPTarget("::ffff:127.0.0.1")
require.NotNil(t, family)
assert.False(t, family.isIPv6)
assert.Equal(t, "127.0.0.1", ip.String())
})
}

216
agent/probe_test.go Normal file
View File

@@ -0,0 +1,216 @@
package agent
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProbeTaskAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
task := &probeTask{}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-90 * time.Second)})
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-30 * time.Second)})
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-10 * time.Second)})
agg := task.aggregateLocked(time.Minute, now)
require.True(t, agg.hasData())
assert.Equal(t, 2, agg.totalCount)
assert.Equal(t, 1, agg.successCount)
assert.Equal(t, 20.0, agg.result()[0])
assert.Equal(t, 20.0, agg.result()[1])
assert.Equal(t, 20.0, agg.result()[2])
assert.Equal(t, 50.0, agg.result()[3])
}
func TestProbeTaskAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
task := &probeTask{}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-11 * 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: -1, timestamp: now.Add(-90 * time.Second)})
task.addSampleLocked(probeSample{responseMs: 30, timestamp: now.Add(-30 * time.Second)})
agg := task.aggregateLocked(10*time.Minute, now)
require.True(t, agg.hasData())
assert.Equal(t, 4, agg.totalCount)
assert.Equal(t, 3, agg.successCount)
assert.Equal(t, 30.0, agg.result()[0])
assert.Equal(t, 20.0, agg.result()[1])
assert.Equal(t, 40.0, agg.result()[2])
assert.Equal(t, 25.0, agg.result()[3])
}
func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
task := &probeTask{}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-10 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now})
require.Len(t, task.samples, 1)
assert.Equal(t, 20.0, task.samples[0].responseMs)
agg := task.aggregateLocked(10*time.Minute, now)
require.True(t, agg.hasData())
assert.Equal(t, 2, agg.totalCount)
assert.Equal(t, 2, agg.successCount)
assert.Equal(t, 15.0, agg.result()[0])
assert.Equal(t, 10.0, agg.result()[1])
assert.Equal(t, 20.0, agg.result()[2])
assert.Equal(t, 0.0, agg.result()[3])
}
func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
now := time.Now().UTC()
task := &probeTask{config: probe.Config{ID: "probe-1"}}
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: 40, timestamp: now.Add(-5 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-90 * time.Second)})
task.addSampleLocked(probeSample{responseMs: 30, timestamp: now.Add(-30 * time.Second)})
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["probe-1"]
require.True(t, ok)
require.Len(t, result, 5)
assert.Equal(t, 30.0, result[0])
assert.Equal(t, 25.0, result[1])
assert.Equal(t, 10.0, result[2])
assert.Equal(t, 40.0, result[3])
assert.Equal(t, 20.0, result[4])
}
func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
now := time.Now().UTC()
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(-10 * time.Second)})
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["probe-1"]
require.True(t, ok)
require.Len(t, result, 5)
assert.Equal(t, 0.0, result[0])
assert.Equal(t, 0.0, result[1])
assert.Equal(t, 0.0, result[2])
assert.Equal(t, 0.0, result[3])
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) {
keepCfg := probe.Config{ID: "probe-1", Target: "https://example.com", Protocol: "http", 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{})}
removedTask := &probeTask{config: removeCfg, cancel: make(chan struct{})}
pm := &ProbeManager{
probes: map[string]*probeTask{
keepCfg.ID: keptTask,
removeCfg.ID: removedTask,
},
}
pm.SyncProbes([]probe.Config{keepCfg})
assert.Same(t, keptTask, pm.probes[keepCfg.ID])
_, exists := pm.probes[removeCfg.ID]
assert.False(t, exists)
select {
case <-removedTask.cancel:
default:
t.Fatal("expected removed probe task to be cancelled")
}
select {
case <-keptTask.cancel:
t.Fatal("expected existing probe task to remain active")
default:
}
}
func TestProbeHTTP(t *testing.T) {
t.Run("success", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
responseMs := probeHTTP(server.Client(), server.URL)
assert.GreaterOrEqual(t, responseMs, 0.0)
})
t.Run("server error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer server.Close()
assert.Equal(t, -1.0, probeHTTP(server.Client(), server.URL))
})
}
func TestProbeTCP(t *testing.T) {
t.Run("success", func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
accepted := make(chan struct{})
go func() {
defer close(accepted)
conn, err := listener.Accept()
if err == nil {
_ = conn.Close()
}
}()
port := uint16(listener.Addr().(*net.TCPAddr).Port)
responseMs := probeTCP("127.0.0.1", port)
assert.GreaterOrEqual(t, responseMs, 0.0)
<-accepted
})
t.Run("connection failure", func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := uint16(listener.Addr().(*net.TCPAddr).Port)
require.NoError(t, listener.Close())
assert.Equal(t, -1.0, probeTCP("127.0.0.1", port))
})
}

2
go.mod
View File

@@ -20,6 +20,7 @@ require (
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.52.0
golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
@@ -56,7 +57,6 @@ require (
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.41.0 // indirect

View File

@@ -195,6 +195,6 @@ func main() {
}
if err := a.Start(serverConfig); err != nil {
log.Fatal("Failed to start server: ", err)
log.Fatal("Failed to start: ", err)
}
}

View File

@@ -22,6 +22,8 @@ const (
GetSmartData
// Request detailed systemd service info from agent
GetSystemdInfo
// Sync network probe configuration to agent
SyncNetworkProbes
// Add new actions here...
)

View File

@@ -0,0 +1,24 @@
package probe
// Config defines a network probe task sent from hub to agent.
type Config struct {
// ID is the stable network_probes record ID generated by the hub.
ID string `cbor:"0,keyasint"`
Target string `cbor:"1,keyasint"`
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.
//
// 0: avg response in ms
//
// 1: average response over the last hour in ms
//
// 2: min response over the last hour in ms
//
// 3: max response over the last hour in ms
//
// 4: packet loss percentage over the last hour (0-100)
type Result []float64

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/systemd"
)
@@ -174,9 +175,10 @@ type Details struct {
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
Probes map[string]probe.Result `cbor:"5,keyasint,omitempty"`
}

View File

@@ -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
@@ -92,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
return err
}
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
create: &systemScopedWriteRule,

View File

@@ -81,6 +81,7 @@ func (h *Hub) StartHub() error {
}
// register middlewares
h.registerMiddlewares(e)
// bind events that aren't set up in different
// register api routes
if err := h.registerApiRoutes(e); err != nil {
return err
@@ -109,6 +110,8 @@ func (h *Hub) StartHub() error {
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
bindNetworkProbesEvents(h)
pb, ok := h.App.(*pocketbase.PocketBase)
if !ok {
return errors.New("not a pocketbase app")

62
internal/hub/probes.go Normal file
View File

@@ -0,0 +1,62 @@
package hub
import (
"strconv"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/hub/systems"
"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) {
// on create, make sure the id is set to a stable hash
h.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
systemID := e.Record.GetString("system")
config := &probe.Config{
Target: e.Record.GetString("target"),
Protocol: e.Record.GetString("protocol"),
Port: uint16(e.Record.GetInt("port")),
Interval: uint16(e.Record.GetInt("interval")),
}
id := generateProbeID(systemID, *config)
e.Record.Set("id", id)
return e.Next()
})
// sync probe to agent on creation
h.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
systemID := e.Record.GetString("system")
h.syncProbesToAgent(systemID)
return e.Next()
})
// sync probe to agent on delete
h.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
systemID := e.Record.GetString("system")
h.syncProbesToAgent(systemID)
return e.Next()
})
// TODO: if enabled changes, sync to agent
}
// syncProbesToAgent fetches enabled probes for a system and sends them to the agent.
func (h *Hub) syncProbesToAgent(systemID string) {
system, err := h.sm.GetSystem(systemID)
if err != nil {
return
}
configs := h.sm.GetProbeConfigsForSystem(systemID)
go func() {
if err := system.SyncNetworkProbes(configs); err != nil {
h.Logger().Warn("failed to sync probes to agent", "system", systemID, "err", err)
}
}()
}

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

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"hash/fnv"
"log/slog"
"math/rand"
"net"
"strings"
@@ -18,6 +19,7 @@ import (
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
@@ -29,6 +31,7 @@ import (
"github.com/lxzan/gws"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/crypto/ssh"
)
@@ -238,6 +241,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
}
}
if data.Probes != nil {
if err := updateNetworkProbesRecords(txApp, data.Probes, sys.Id); err != nil {
return err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", data.Info)
@@ -289,7 +298,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
for i, service := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, service.Name)
params["id"+suffix] = MakeStableHashId(systemId, service.Name)
params["name"+suffix] = service.Name
params["state"+suffix] = service.State
params["sub"+suffix] = service.Sub
@@ -306,6 +315,95 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
return err
}
func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, systemId string) error {
if len(data) == 0 {
return nil
}
var err error
collectionName := "network_probes"
// 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 resAvg={:res}, resMin1h={:resMin1h}, resMax1h={:resMax1h}, resAvg1h={:resAvg1h}, loss1h={:loss1h}, 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, marshalErr := json.Marshal(data); marshalErr == 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 id, values := range data {
switch realtimeActive {
case true:
var record *core.Record
record, err = app.FindRecordById(collectionName, id)
if err == nil {
record.Set("res", probeMetric(values, 0))
record.Set("resAvg1h", probeMetric(values, 1))
record.Set("resMin1h", probeMetric(values, 2))
record.Set("resMax1h", probeMetric(values, 3))
record.Set("loss1h", probeMetric(values, 4))
err = app.SaveNoValidate(record)
}
default:
_, err = updateQuery.Bind(dbx.Params{
"id": id,
"res": probeMetric(values, 0),
"resAvg1h": probeMetric(values, 1),
"resMin1h": probeMetric(values, 2),
"resMax1h": probeMetric(values, 3),
"loss1h": probeMetric(values, 4),
"updated": nowString,
}).Execute()
}
if err != nil {
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", id, "err", err)
}
}
return nil
}
func probeMetric(values probe.Result, index int) float64 {
if index < len(values) {
return values[index]
}
return 0
}
// createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 {
@@ -540,7 +638,7 @@ func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error)
return result, err
}
func makeStableHashId(strings ...string) string {
func MakeStableHashId(strings ...string) string {
hash := fnv.New32a()
for _, str := range strings {
hash.Write([]byte(str))

View File

@@ -7,6 +7,7 @@ import (
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap"
@@ -15,6 +16,7 @@ import (
"github.com/henrygd/beszel"
"github.com/blang/semver"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh"
@@ -317,6 +319,17 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
if err := sm.AddRecord(systemRecord, system); err != nil {
return err
}
// Sync network probes to the newly connected agent
go func() {
configs := sm.GetProbeConfigsForSystem(systemId)
if len(configs) > 0 {
if err := system.SyncNetworkProbes(configs); err != nil {
sm.hub.Logger().Warn("failed to sync probes to agent", "system", systemId, "err", err)
}
}
}()
return nil
}
@@ -329,6 +342,16 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
}
}
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
var configs []probe.Config
_ = sm.hub.DB().
NewQuery("SELECT id, target, protocol, port, interval FROM network_probes WHERE system = {:system} AND enabled = true").
Bind(dbx.Params{"system": systemID}).
All(&configs)
return configs
}
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey("")

View File

@@ -0,0 +1,57 @@
package systems
import (
"context"
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/probe"
)
// SyncNetworkProbes sends probe configurations to the agent.
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
return sys.request(ctx, common.SyncNetworkProbes, configs, &result)
}
// FetchNetworkProbeResults fetches probe results from the agent.
// func (sys *System) FetchNetworkProbeResults() (map[string]probe.Result, error) {
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// defer cancel()
// var results map[string]probe.Result
// err := sys.request(ctx, common.GetNetworkProbeResults, nil, &results)
// return results, err
// }
// hasEnabledProbes returns true if this system has any enabled network probes.
// func (sys *System) hasEnabledProbes() bool {
// count, err := sys.manager.hub.CountRecords("network_probes",
// dbx.NewExp("system = {:system} AND enabled = true", dbx.Params{"system": sys.Id}))
// return err == nil && count > 0
// }
// fetchAndSaveProbeResults fetches probe results and saves them to the database.
// func (sys *System) fetchAndSaveProbeResults() {
// hub := sys.manager.hub
// results, err := sys.FetchNetworkProbeResults()
// if err != nil || len(results) == 0 {
// return
// }
// collection, err := hub.FindCachedCollectionByNameOrId("network_probe_stats")
// if err != nil {
// return
// }
// record := core.NewRecord(collection)
// record.Set("system", sys.Id)
// record.Set("stats", results)
// record.Set("type", "1m")
// if err := hub.SaveNoValidate(record); err != nil {
// hub.Logger().Warn("failed to save probe stats", "system", sys.Id, "err", err)
// }
// }

View File

@@ -84,7 +84,7 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
hub := sys.manager.hub
recordID := makeStableHashId(sys.Id, deviceKey)
recordID := MakeStableHashId(sys.Id, deviceKey)
record, err := hub.FindRecordById(collection, recordID)
if err != nil {

View File

@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
serviceName := "nginx.service"
// Call multiple times and ensure same result
id1 := makeStableHashId(systemId, serviceName)
id2 := makeStableHashId(systemId, serviceName)
id3 := makeStableHashId(systemId, serviceName)
id1 := MakeStableHashId(systemId, serviceName)
id2 := MakeStableHashId(systemId, serviceName)
id3 := MakeStableHashId(systemId, serviceName)
assert.Equal(t, id1, id2)
assert.Equal(t, id2, id3)
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
serviceName1 := "nginx.service"
serviceName2 := "apache.service"
id1 := makeStableHashId(systemId1, serviceName1)
id2 := makeStableHashId(systemId2, serviceName1)
id3 := makeStableHashId(systemId1, serviceName2)
id4 := makeStableHashId(systemId2, serviceName2)
id1 := MakeStableHashId(systemId1, serviceName1)
id2 := MakeStableHashId(systemId2, serviceName1)
id3 := MakeStableHashId(systemId1, serviceName2)
id4 := MakeStableHashId(systemId2, serviceName2)
// All IDs should be different
assert.NotEqual(t, id1, id2)
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
}
for _, tc := range testCases {
id := makeStableHashId(tc.systemId, tc.serviceName)
id := MakeStableHashId(tc.systemId, tc.serviceName)
// FNV-32 produces 8 hex characters
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
}
})
t.Run("hexadecimal output", func(t *testing.T) {
id := makeStableHashId("test-system", "test-service")
id := MakeStableHashId("test-system", "test-service")
assert.NotEmpty(t, id)
// Should only contain hexadecimal characters

View File

@@ -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
}

View File

@@ -1699,6 +1699,223 @@ func init() {
"type": "base",
"updateRule": null,
"viewRule": null
},
{
"id": "np_probes_001",
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "network_probes",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "np_system",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "np_name",
"max": 200,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "np_target",
"max": 500,
"min": 1,
"name": "target",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "np_protocol",
"maxSelect": 1,
"name": "protocol",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": ["icmp", "tcp", "http"]
},
{
"hidden": false,
"id": "np_port",
"max": 65535,
"min": 0,
"name": "port",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "np_interval",
"max": 3600,
"min": 1,
"name": "interval",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "np_enabled",
"name": "enabled",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `enabled` + "`" + `\n)"
],
"system": false
},
{
"id": "np_stats_001",
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "network_probe_stats",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "nps_system",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "nps_stats",
"maxSize": 2000000,
"name": "stats",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "nps_type",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": ["1m", "10m", "20m", "120m", "480m"]
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
],
"system": false
}
]`

View File

@@ -0,0 +1,58 @@
//go:build testing
package records_test
import (
"testing"
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAverageProbeStats(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
user, err := tests.CreateUser(hub, "probe-avg@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "probe-avg-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
recordA, err := tests.CreateRecord(hub, "network_probe_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"icmp:1.1.1.1":[10,80,8,14,1]}`,
})
require.NoError(t, err)
recordB, err := tests.CreateRecord(hub, "network_probe_stats", map[string]any{
"system": system.Id,
"type": "1m",
"stats": `{"icmp:1.1.1.1":[40,100,9,50,5]}`,
})
require.NoError(t, err)
result := rm.AverageProbeStats(hub.DB(), records.RecordIds{
{Id: recordA.Id},
{Id: recordB.Id},
})
stats, ok := result["icmp:1.1.1.1"]
require.True(t, ok)
require.Len(t, stats, 5)
assert.Equal(t, 25.0, stats[0])
assert.Equal(t, 90.0, stats[1])
assert.Equal(t, 8.0, stats[2])
assert.Equal(t, 50.0, stats[3])
assert.Equal(t, 3.0, stats[4])
}

View File

@@ -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"
@@ -70,7 +71,7 @@ func (rm *RecordManager) CreateLongerRecords() {
// wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error {
var err error
collections := [2]*core.Collection{}
collections := [3]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return err
@@ -79,6 +80,10 @@ func (rm *RecordManager) CreateLongerRecords() {
if err != nil {
return err
}
collections[2], err = txApp.FindCachedCollectionByNameOrId("network_probe_stats")
if err != nil {
return err
}
var systems RecordIds
db := txApp.DB()
@@ -138,8 +143,9 @@ func (rm *RecordManager) CreateLongerRecords() {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
case "network_probe_stats":
longerRecord.Set("stats", rm.AverageProbeStats(db, recordIds))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err)
@@ -500,6 +506,80 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
return result
}
// AverageProbeStats averages probe stats across multiple records.
// For each probe key: avg of average fields, min of mins, and max of maxes.
func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Result {
type probeValues struct {
sums probe.Result
counts []int
}
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
for _, rec := range records {
row.Stats = row.Stats[:0]
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{sums: make(probe.Result, len(vals)), counts: make([]int, len(vals))}
sums[key] = s
}
if len(vals) > len(s.sums) {
expandedSums := make(probe.Result, len(vals))
copy(expandedSums, s.sums)
s.sums = expandedSums
expandedCounts := make([]int, len(vals))
copy(expandedCounts, s.counts)
s.counts = expandedCounts
}
for i := range vals {
switch i {
case 2: // min fields
if s.counts[i] == 0 || vals[i] < s.sums[i] {
s.sums[i] = vals[i]
}
case 3: // max fields
if s.counts[i] == 0 || vals[i] > s.sums[i] {
s.sums[i] = vals[i]
}
default: // average fields
s.sums[i] += vals[i]
}
s.counts[i]++
}
}
}
// compute final averages
result := make(map[string]probe.Result, len(sums))
for key, s := range sums {
if len(s.counts) == 0 {
continue
}
for i := range s.sums {
switch i {
case 2, 3: // min and max fields should not be averaged
continue
default:
if s.counts[i] > 0 {
s.sums[i] = twoDecimals(s.sums[i] / float64(s.counts[i]))
}
}
}
result[key] = s.sums
}
return result
}
/* Round float to two decimals */
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100

View File

@@ -59,7 +59,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
// Deletes system_stats records older than what is displayed in the UI
func deleteOldSystemStats(app core.App) error {
// Collections to process
collections := [2]string{"system_stats", "container_stats"}
collections := [3]string{"system_stats", "container_stats", "network_probe_stats"}
// Record types and their retention periods
type RecordDeletionData struct {

View File

@@ -146,7 +146,7 @@ export default function AreaChartDefault({
axisLine={false}
/>
)}
{xAxis(chartData)}
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}

View File

@@ -41,6 +41,7 @@ export default function LineChartDefault({
filter,
truncate = false,
chartProps,
connectNulls,
}: {
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
@@ -62,6 +63,7 @@ export default function LineChartDefault({
filter?: string
truncate?: boolean
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
connectNulls?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
@@ -104,7 +106,8 @@ export default function LineChartDefault({
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
// activeDot={dataPoint.activeDot ?? true}
activeDot={dataPoint.activeDot ?? true}
connectNulls={connectNulls}
/>
)
})
@@ -145,7 +148,7 @@ export default function LineChartDefault({
axisLine={false}
/>
)}
{xAxis(chartData)}
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}

View File

@@ -8,6 +8,7 @@ import {
LogOutIcon,
LogsIcon,
MenuIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
ServerIcon,
@@ -109,6 +110,10 @@ export default function Navbar() {
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<span>S.M.A.R.T.</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "probes"))} className="flex items-center">
<NetworkIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<Trans>Network Probes</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
className="flex items-center"
@@ -180,6 +185,21 @@ export default function Navbar() {
</TooltipTrigger>
<TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "probes")}
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Network Probes"
onMouseEnter={() => import("@/components/routes/probes")}
>
<NetworkIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>Network Probes</Trans>
</TooltipContent>
</Tooltip>
<LangToggle />
<ModeToggle />
<Tooltip>

View File

@@ -0,0 +1,237 @@
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { cn, decimalString, hourWithSeconds } from "@/lib/utils"
import {
GlobeIcon,
TimerIcon,
WifiOffIcon,
Trash2Icon,
ArrowLeftRightIcon,
MoreHorizontalIcon,
ServerIcon,
ClockIcon,
NetworkIcon,
RefreshCwIcon,
} from "lucide-react"
import { t } from "@lingui/core/macro"
import type { NetworkProbeRecord } from "@/types"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Trans } from "@lingui/react/macro"
import { pb } from "@/lib/api"
import { toast } from "../ui/use-toast"
import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
const protocolColors: Record<string, string> = {
icmp: "bg-blue-500/15 text-blue-400",
tcp: "bg-purple-500/15 text-purple-400",
http: "bg-green-500/15 text-green-400",
}
async function deleteProbe(id: string) {
try {
await pb.collection("network_probes").delete(id)
} catch (err: unknown) {
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
}
}
export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<NetworkProbeRecord>[] {
return [
{
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={NetworkIcon} />,
cell: ({ getValue }) => (
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
{getValue() as string}
</div>
),
},
{
id: "system",
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
const systemNameA = allSystems[a.original.system]?.name ?? ""
const systemNameB = allSystems[b.original.system]?.name ?? ""
return systemNameA.localeCompare(systemNameB)
},
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-20 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
{
id: "target",
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
accessorFn: (record) => record.target,
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
cell: ({ getValue }) => (
<div className="ms-1.5 tabular-nums block truncate max-w-44" style={{ width: `${longestTarget / 1.05}ch` }}>
{getValue() as string}
</div>
),
},
{
id: "protocol",
accessorFn: (record) => record.protocol,
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => {
const protocol = getValue() as string
return (
<span className={cn("ms-1.5 px-2 py-0.5 rounded text-xs font-medium uppercase", protocolColors[protocol])}>
{protocol}
</span>
)
},
},
{
id: "interval",
accessorFn: (record) => record.interval,
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
},
{
id: "res",
accessorFn: (record) => record.res,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "res1h",
accessorFn: (record) => record.resAvg1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "max1h",
accessorFn: (record) => record.resMax1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "min1h",
accessorFn: (record) => record.resMin1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
cell: responseTimeCell,
},
{
id: "loss",
accessorFn: (record) => record.loss1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
cell: ({ row }) => {
const { loss1h, res } = row.original
if (loss1h === undefined || (!res && !loss1h)) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
let color = "bg-green-500"
if (loss1h) {
color = loss1h > 20 ? "bg-red-500" : "bg-yellow-500"
}
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} />
{loss1h}%
</span>
)
},
},
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
},
},
{
id: "actions",
enableSorting: false,
header: () => null,
size: 40,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-10"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation()
deleteProbe(row.original.id)
}}
>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
}
function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
const val = cell.getValue() as number | undefined
if (!val) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
let color = "bg-green-500"
if (val > 200) {
color = "bg-yellow-500"
}
if (val > 2000) {
color = "bg-red-500"
}
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} />
{decimalString(val, val < 100 ? 2 : 1).toLocaleString()}ms
</span>
)
}
function HeaderButton({
column,
name,
Icon,
}: {
column: Column<NetworkProbeRecord>
name: string
Icon: React.ElementType
}) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
</Button>
)
}

View File

@@ -0,0 +1,225 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type Row,
type SortingState,
type Table as TableType,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { memo, useMemo, useRef, useState } from "react"
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { isReadOnlyUser } from "@/lib/api"
import { $allSystemsById } from "@/lib/stores"
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
import type { NetworkProbeRecord } from "@/types"
import { AddProbeDialog } from "./probe-dialog"
export default function NetworkProbesTableNew({
systemId,
probes,
}: {
systemId?: string
probes: NetworkProbeRecord[]
}) {
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-np-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [globalFilter, setGlobalFilter] = useState("")
const { longestName, longestTarget } = useMemo(() => {
let longestName = 0
let longestTarget = 0
for (const p of probes) {
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
}
return { longestName, longestTarget }
}, [probes])
// Filter columns based on whether systemId is provided
const columns = useMemo(() => {
let columns = getProbeColumns(longestName, longestTarget)
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
columns = isReadOnlyUser() ? columns.filter((col) => col.id !== "actions") : columns
return columns
}, [systemId, longestName, longestTarget])
const table = useReactTable({
data: probes,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
defaultColumn: {
sortUndefined: "last",
size: 900,
minSize: 0,
},
state: {
sorting,
columnFilters,
columnVisibility,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const probe = row.original
const systemName = $allSystemsById.get()[probe.system]?.name ?? ""
const searchString = `${probe.name}${probe.target}${probe.protocol}${systemName}`.toLocaleLowerCase()
return (filterValue as string)
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
},
})
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
return (
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Network Probes</Trans>
</CardTitle>
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
<Trans>Response time monitoring from agents.</Trans>
</div>
</div>
<div className="md:ms-auto flex items-center gap-2">
{probes.length > 0 && (
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-64"
/>
)}
{!isReadOnlyUser() ? <AddProbeDialog systemId={systemId} /> : null}
</div>
</div>
</CardHeader>
<div className="rounded-md">
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
</Card>
)
}
const NetworkProbesTable = memo(function NetworkProbeTable({
table,
rows,
colLength,
}: {
table: TableType<NetworkProbeRecord>
rows: Row<NetworkProbeRecord>[]
colLength: number
}) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 54,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
return (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<NetworkProbeTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <NetworkProbeTableRow key={row.id} row={row} virtualRow={virtualRow} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
</div>
)
})
function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</tr>
))}
</TableHeader>
)
}
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
row,
virtualRow,
}: {
row: Row<NetworkProbeRecord>
virtualRow: VirtualItem
}) {
return (
<TableRow data-state={row.getIsSelected() && "selected"} className="transition-opacity">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0"
style={{
width: `${cell.column.getSize()}px`,
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})

View File

@@ -0,0 +1,194 @@
import { useState } from "react"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { pb } from "@/lib/api"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PlusIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores"
import * as v from "valibot"
const Schema = v.object({
system: v.string(),
target: v.string(),
protocol: v.picklist(["icmp", "tcp", "http"]),
port: v.number(),
interval: v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)),
enabled: v.boolean(),
name: v.optional(v.string()),
})
export function AddProbeDialog({ systemId }: { systemId?: string }) {
const [open, setOpen] = useState(false)
const [protocol, setProtocol] = useState<string>("icmp")
const [target, setTarget] = useState("")
const [port, setPort] = useState("")
const [probeInterval, setProbeInterval] = useState("30")
const [name, setName] = useState("")
const [loading, setLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState("")
const systems = useStore($systems)
const { toast } = useToast()
const { t } = useLingui()
const targetName = target.replace(/^https?:\/\//, "")
const resetForm = () => {
setProtocol("icmp")
setTarget("")
setPort("")
setProbeInterval("30")
setName("")
setSelectedSystemId("")
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const payload = v.parse(Schema, {
system: systemId ?? selectedSystemId,
target,
protocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: probeInterval,
enabled: true,
})
if (name && name !== target) {
payload.name = name
}
await pb.collection("network_probes").create(payload)
resetForm()
setOpen(false)
} catch (err: unknown) {
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<PlusIcon className="size-4 me-1" />
<Trans>Add {{ foo: t`Probe` }}</Trans>
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Configure response monitoring from this agent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
{!systemId && (
<div className="grid gap-2">
<Label>
<Trans>System</Trans>
</Label>
<Select value={selectedSystemId} onValueChange={setSelectedSystemId} required>
<SelectTrigger>
<SelectValue placeholder={t`Select a system`} />
</SelectTrigger>
<SelectContent>
{systems.map((sys) => (
<SelectItem key={sys.id} value={sys.id}>
{sys.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Target</Trans>
</Label>
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Protocol</Trans>
</Label>
<Select value={protocol} onValueChange={setProtocol}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
</div>
{protocol === "tcp" && (
<div className="grid gap-2">
<Label>
<Trans>Port</Trans>
</Label>
<Input
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="443"
min={1}
max={65535}
required
/>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Interval (seconds)</Trans>
</Label>
<Input
type="number"
value={probeInterval}
onChange={(e) => setProbeInterval(e.target.value)}
min={1}
max={3600}
required
/>
</div>
<div className="grid gap-2">
<Label>
<Trans>Name (optional)</Trans>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={targetName || t`e.g. Cloudflare DNS`}
/>
</div>
<DialogFooter>
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -4,6 +4,7 @@ const routes = {
home: "/",
containers: "/containers",
smart: "/smart",
probes: "/probes",
system: `/system/:id`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,

View File

@@ -0,0 +1,25 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect } 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 { useNetworkProbesData } from "@/lib/use-network-probes"
export default memo(() => {
const { t } = useLingui()
const { probes } = useNetworkProbesData({})
useEffect(() => {
document.title = `${t`Network Probes`} / Beszel`
}, [t])
return (
<>
<div className="grid gap-4">
<ActiveAlerts />
<NetworkProbesTableNew probes={probes} />
</div>
<FooterRepoLink />
</>
)
})

View File

@@ -11,9 +11,9 @@ 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, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTable } from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart"
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
import { ContainerIcon, CpuIcon, HardDriveIcon, NetworkIcon, TerminalSquareIcon } from "lucide-react"
import { GpuIcon } from "../ui/icons"
import SystemdTable from "../systemd-table/systemd-table"
import ContainersTable from "../containers-table/containers-table"
@@ -65,7 +65,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasGpu = hasGpuData || hasGpuPowerData
// keep tabsRef in sync for keyboard navigation
const tabs = ["core", "disk"]
const tabs = ["core", "network", "disk"]
if (hasGpu) tabs.push("gpu")
if (hasContainers) tabs.push("containers")
if (hasSystemd) tabs.push("services")
@@ -145,6 +145,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
{hasSystemd && <LazySystemdTable systemId={system.id} />}
<LazyNetworkProbesTable systemId={system.id} systemData={systemData} />
</>
)
}
@@ -157,6 +159,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<CpuIcon className="size-3.5" />
<Trans context="Core system metrics">Core</Trans>
</TabsTrigger>
<TabsTrigger value="network" className="w-full flex items-center gap-1.5">
<NetworkIcon className="size-3.5" />
<Trans>Network</Trans>
</TabsTrigger>
<TabsTrigger value="disk" className="w-full flex items-center gap-1.5">
<HardDriveIcon className="size-3.5" />
<Trans>Disk</Trans>
@@ -184,16 +190,26 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
<div className="grid xl:grid-cols-2 gap-4">
<CpuChart {...coreProps} />
<MemoryChart {...coreProps} />
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
<BandwidthChart {...coreProps} systemStats={systemStats} />
<MemoryChart {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<BatteryChart {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div>
</TabsContent>
<TabsContent value="network" forceMount className={activeTab === "network" ? "contents" : "hidden"}>
{mountedTabs.has("network") && (
<>
<div className="grid xl:grid-cols-2 gap-4">
<BandwidthChart {...coreProps} systemStats={systemStats} />
</div>
<LazyNetworkProbesTable systemId={system.id} systemData={systemData} />
</>
)}
</TabsContent>
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
{mountedTabs.has("disk") && (
<>

View File

@@ -1,7 +1,6 @@
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
@@ -17,27 +16,6 @@ export const cache = new Map<
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
>()
// create ticks and domain for charts
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td") as ChartTimeData | undefined
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
// const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now())
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set("td", { time: now.getTime(), data, chartTime })
return data
}
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
export function appendData<T extends { created: string | number | null }>(
@@ -66,12 +44,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}", {

View File

@@ -0,0 +1,152 @@
import LineChartDefault from "@/components/charts/line-chart"
import type { DataPoint } from "@/components/charts/line-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"
const $filter = atom("")
type ProbeChartProps = {
probeStats: NetworkProbeStatsRecord[]
grid?: boolean
probes: NetworkProbeRecord[]
chartData: ChartData
empty: boolean
}
type ProbeChartBaseProps = ProbeChartProps & {
valueIndex: number
title: string
description: string
tickFormatter: (value: number) => string
contentFormatter: ({ value }: { value: number | string }) => string | number
domain?: [number | "auto", number | "auto"]
}
function ProbeChart({
probeStats,
grid,
probes,
chartData,
empty,
valueIndex,
title,
description,
tickFormatter,
contentFormatter,
domain,
}: ProbeChartBaseProps) {
const filter = useStore($filter)
const { dataPoints, visibleKeys } = useMemo(() => {
const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h)
const count = sortedProbes.length
const points: DataPoint<NetworkProbeStatsRecord>[] = []
const visibleIDs: string[] = []
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
for (let i = 0; i < count; i++) {
const p = sortedProbes[i]
const label = p.name || p.target
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => label.toLowerCase().includes(term))
if (filtered) {
continue
}
visibleIDs.push(p.id)
points.push({
order: i,
label,
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-",
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
})
}
return { dataPoints: points, visibleKeys: visibleIDs }
}, [probes, filter, valueIndex])
const filteredProbeStats = useMemo(() => {
if (!visibleKeys.length) return probeStats
return probeStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
}, [probeStats, visibleKeys])
const legend = dataPoints.length < 10
return (
<ChartCard
legend={legend}
cornerEl={<FilterBar store={$filter} />}
empty={empty}
title={title}
description={description}
grid={grid}
>
<LineChartDefault
chartData={chartData}
customData={filteredProbeStats}
dataPoints={dataPoints}
domain={domain ?? ["auto", "auto"]}
connectNulls
tickFormatter={tickFormatter}
contentFormatter={contentFormatter}
legend={legend}
filter={filter}
/>
</ChartCard>
)
}
export function ResponseChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
const { t } = useLingui()
return (
<ProbeChart
probeStats={probeStats}
grid={grid}
probes={probes}
chartData={chartData}
empty={empty}
valueIndex={0}
title={t`Response`}
description={t`Average response time (ms)`}
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
contentFormatter={({ value }) => {
if (typeof value !== "number") {
return value
}
return `${decimalString(value, 2)} ms`
}}
/>
)
}
export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
const { t } = useLingui()
return (
<ProbeChart
probeStats={probeStats}
grid={grid}
probes={probes}
chartData={chartData}
empty={empty}
valueIndex={4}
title={t`Loss`}
description={t`Packet loss (%)`}
domain={[0, 100]}
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
contentFormatter={({ value }) => {
if (typeof value !== "number") {
return value
}
return `${decimalString(value, 2)}%`
}}
/>
)
}

View File

@@ -1,6 +1,11 @@
import { lazy } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
import { ResponseChart, LossChart } from "./charts/probes-charts"
import type { SystemData } from "./use-system-data"
import { $chartTime } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { useNetworkProbesData } from "@/lib/use-network-probes"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
@@ -34,3 +39,46 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
</div>
)
}
const NetworkProbesTable = lazy(() => import("@/components/network-probes-table/network-probes-table"))
export function LazyNetworkProbesTable({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <ProbesTable systemId={systemId} systemData={systemData} />}
</div>
)
}
function ProbesTable({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
const { grid, chartData } = systemData ?? {}
const chartTime = useStore($chartTime)
const { probes, probeStats } = useNetworkProbesData({ systemId, loadStats: !!chartData, chartTime })
return (
<>
<NetworkProbesTable systemId={systemId} probes={probes} />
{!!chartData && !!probes.length && (
<div className="grid xl:grid-cols-2 gap-4">
<ResponseChart
probeStats={probeStats}
grid={grid}
probes={probes}
chartData={chartData}
empty={!probeStats.length}
/>
<LossChart
probeStats={probeStats}
grid={grid}
probes={probes}
chartData={chartData}
empty={!probeStats.length}
/>
</div>
)}
</>
)
}

View File

@@ -26,7 +26,7 @@ import type {
SystemStatsRecord,
} from "@/types"
import { $router, navigate } from "../../router"
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
import { appendData, cache, getStats, makeContainerData, makeContainerPoint } from "./chart-data"
export type SystemData = ReturnType<typeof useSystemData>
@@ -151,16 +151,11 @@ export function useSystemData(id: string) {
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
)
return {
systemStats,
containerData,
chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
agentVersion,
}
}, [systemStats, containerData, direction])
@@ -200,8 +195,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) {

View File

@@ -3,9 +3,10 @@ import { useLingui } from "@lingui/react/macro"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { chartTimeData, cn } from "@/lib/utils"
import type { ChartData } from "@/types"
import type { ChartTimes } from "@/types"
import { Separator } from "./separator"
import { AxisDomain } from "recharts/types/util/types"
import { timeTicks } from "d3-time"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
@@ -400,26 +401,37 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
}
let cachedAxis: JSX.Element
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
return cachedAxis
let cachedAxis: {
time: number
el: JSX.Element
}
const xAxis = (chartTime: ChartTimes, lastCreationTime: number) => {
if (Math.abs(lastCreationTime - cachedAxis?.time) < 1000) {
return cachedAxis.el
}
cachedAxis = (
<RechartsPrimitive.XAxis
dataKey="created"
domain={domain}
ticks={ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={12}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
)
return cachedAxis
const now = new Date(lastCreationTime + 1000)
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const domain = [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()]
cachedAxis = {
time: lastCreationTime,
el: (
<RechartsPrimitive.XAxis
dataKey="created"
domain={domain}
ticks={ticks}
allowDataOverflow
type="number"
scale="time"
minTickGap={12}
tickMargin={8}
axisLine={false}
tickFormatter={chartTimeData[chartTime].format}
/>
),
}
return cachedAxis.el
}
export {

View File

@@ -0,0 +1,314 @@
import { chartTimeData } from "@/lib/utils"
import type { ChartTimes, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
import { useEffect, useRef, useState } from "react"
import { getStats, appendData } from "@/components/routes/system/chart-data"
import { pb } from "@/lib/api"
import { toast } from "@/components/ui/use-toast"
import type { RecordListOptions, RecordSubscription } from "pocketbase"
const cache = new Map<string, NetworkProbeStatsRecord[]>()
function getCacheValue(systemId: string, chartTime: ChartTimes | "rt") {
return cache.get(`${systemId}${chartTime}`) || []
}
function appendCacheValue(
systemId: string,
chartTime: ChartTimes | "rt",
newStats: NetworkProbeStatsRecord[],
maxPoints = 100
) {
const cache_key = `${systemId}${chartTime}`
const existingStats = getCacheValue(systemId, chartTime)
if (existingStats) {
const { expectedInterval } = chartTimeData[chartTime]
const updatedStats = appendData(existingStats, newStats, expectedInterval, maxPoints)
cache.set(cache_key, updatedStats)
return updatedStats
} else {
cache.set(cache_key, newStats)
return newStats
}
}
const NETWORK_PROBE_FIELDS =
"id,name,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
interface UseNetworkProbesProps {
systemId?: string
loadStats?: boolean
chartTime?: ChartTimes
existingProbes?: NetworkProbeRecord[]
}
export function useNetworkProbesData(props: UseNetworkProbesProps) {
const { systemId, loadStats, chartTime, existingProbes } = props
const [p, setProbes] = useState<NetworkProbeRecord[]>([])
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
const statsRequestId = useRef(0)
const pendingProbeEvents = useRef(new Map<string, RecordSubscription<NetworkProbeRecord>>())
const probeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const probes = existingProbes ?? p
// clear old data when systemId changes
// useEffect(() => {
// return setProbes([])
// }, [systemId])
// initial load - fetch probes if not provided by caller
useEffect(() => {
if (!existingProbes) {
fetchProbes(systemId).then((probes) => setProbes(probes))
}
}, [systemId])
// Subscribe to updates if probes not provided by caller
useEffect(() => {
if (existingProbes) {
return
}
let unsubscribe: (() => void) | undefined
function flushPendingProbeEvents() {
probeBatchTimeout.current = null
if (!pendingProbeEvents.current.size) {
return
}
const events = pendingProbeEvents.current
pendingProbeEvents.current = new Map()
setProbes((currentProbes) => {
return applyProbeEvents(currentProbes ?? [], events.values(), systemId)
})
}
const pbOptions: RecordListOptions = { fields: NETWORK_PROBE_FIELDS }
if (systemId) {
pbOptions.filter = pb.filter("system = {:system}", { system: systemId })
}
;(async () => {
try {
unsubscribe = await pb.collection<NetworkProbeRecord>("network_probes").subscribe(
"*",
(event) => {
pendingProbeEvents.current.set(event.record.id, event)
if (!probeBatchTimeout.current) {
probeBatchTimeout.current = setTimeout(flushPendingProbeEvents, 50)
}
},
pbOptions
)
} catch (error) {
console.error("Failed to subscribe to probes", error)
}
})()
return () => {
if (probeBatchTimeout.current !== null) {
clearTimeout(probeBatchTimeout.current)
probeBatchTimeout.current = null
}
pendingProbeEvents.current.clear()
unsubscribe?.()
}
}, [systemId])
// Subscribe to new probe stats
useEffect(() => {
if (!loadStats || !systemId) {
return
}
let unsubscribe: (() => void) | undefined
const pbOptions = {
fields: "stats,created,type",
filter: pb.filter("system = {:system}", { system: systemId }),
}
;(async () => {
try {
unsubscribe = await pb.collection<NetworkProbeStatsRecord>("network_probe_stats").subscribe(
"*",
(event) => {
if (!chartTime || event.action !== "create") {
return
}
// if (typeof event.record.created === "string") {
// event.record.created = new Date(event.record.created).getTime()
// }
// return if not current chart time
// we could append to other chart times, but we would need to check the timestamps
// to make sure they fit in correctly, so for simplicity just ignore non-chart-time updates
// and fetch them via API when the user switches to that chart time
const chartTimeRecordType = chartTimeData[chartTime].type as ChartTimes
if (event.record.type !== chartTimeRecordType) {
// const lastCreated = getCacheValue(systemId, chartTime)?.at(-1)?.created ?? 0
// if (lastCreated) {
// // if the new record is close enough to the last cached record, append it to the cache so it's available immediately if the user switches to that chart time
// const { expectedInterval } = chartTimeData[chartTime]
// if (event.record.created - lastCreated < expectedInterval * 1.5) {
// console.log(
// `Caching out-of-chart-time probe stats record for chart time ${chartTime} (record type: ${event.record.type})`
// )
// const newStats = appendCacheValue(systemId, chartTime, [event.record])
// cache.set(`${systemId}${chartTime}`, newStats)
// }
// }
// console.log(`Received probe stats for non-current chart time (${event.record.type}), ignoring for now`)
return
}
// console.log("Appending new probe stats to chart:", event.record)
const newStats = appendCacheValue(systemId, chartTime, [event.record])
setProbeStats(newStats)
},
pbOptions
)
} catch (error) {
console.error("Failed to subscribe to probe stats:", error)
}
})()
return () => unsubscribe?.()
}, [systemId])
// fetch missing probe stats on load and when chart time changes
useEffect(() => {
if (!loadStats || !systemId || !chartTime || chartTime === "1m") {
return
}
const { expectedInterval } = chartTimeData[chartTime]
const requestId = ++statsRequestId.current
const cachedProbeStats = getCacheValue(systemId, chartTime)
// 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
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
console.log("Using cached probe stats, skipping fetch")
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
}
const newStats = appendCacheValue(systemId, chartTime, probeStats)
setProbeStats(newStats)
}
)
}, [chartTime])
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
if (!loadStats || !systemId || chartTime !== "1m") {
return
}
let unsubscribe: (() => void) | undefined
const cache_key = `${systemId}rt`
pb.realtime
.subscribe(
`rt_metrics`,
(data: { Probes: NetworkProbeStatsRecord["stats"] }) => {
let prev = getCacheValue(systemId, "rt")
const now = Date.now()
// if no previous data or the last data point is older than 1min,
// create a new data set starting with a point 1 second ago to seed the chart data
if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) {
prev = [{ created: now - 1000, stats: probesToStats(probes) }]
}
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
const newStats = appendData(prev, [stats], 1000, 120)
setProbeStats(() => newStats)
cache.set(cache_key, newStats)
},
{ query: { system: systemId } }
)
.then((us) => {
unsubscribe = us
})
return () => unsubscribe?.()
}, [chartTime, systemId])
return {
probes,
probeStats,
}
}
function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
const stats: NetworkProbeStatsRecord["stats"] = {}
for (const probe of probes) {
stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h]
}
return stats
}
async function fetchProbes(systemId?: string) {
try {
const res = await pb.collection<NetworkProbeRecord>("network_probes").getList(0, 2000, {
fields: NETWORK_PROBE_FIELDS,
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
return res.items
} catch (error) {
toast({
title: "Error",
description: (error as Error)?.message,
variant: "destructive",
})
return []
}
}
function applyProbeEvents(
probes: NetworkProbeRecord[],
events: Iterable<RecordSubscription<NetworkProbeRecord>>,
systemId?: string
) {
// Use a map to handle updates/deletes in constant time
const probeById = new Map(probes.map((probe) => [probe.id, probe]))
const createdProbes: NetworkProbeRecord[] = []
for (const { action, record } of events) {
const matchesSystemScope = !systemId || record.system === systemId
if (action === "delete" || !matchesSystemScope) {
probeById.delete(record.id)
continue
}
if (!probeById.has(record.id)) {
createdProbes.push(record)
}
probeById.set(record.id, record)
}
const nextProbes: NetworkProbeRecord[] = []
// Prepend brand new probes (matching previous behavior)
for (let index = createdProbes.length - 1; index >= 0; index -= 1) {
nextProbes.push(createdProbes[index])
}
// Rebuild the final list while preserving original order for existing probes
for (const probe of probes) {
const nextProbe = probeById.get(probe.id)
if (!nextProbe) {
continue
}
nextProbes.push(nextProbe)
probeById.delete(probe.id)
}
return nextProbes
}

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-17 09:26\n"
"PO-Revision-Date: 2026-04-05 18:27\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -57,7 +57,7 @@ msgstr "1 ora"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
@@ -74,7 +74,7 @@ msgstr "12 ore"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 min"
msgstr ""
#: src/lib/utils.ts
msgid "24 hours"
@@ -87,7 +87,7 @@ msgstr "30 giorni"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 min"
msgstr ""
#. Table column
#: src/components/routes/settings/quiet-hours.tsx
@@ -248,7 +248,7 @@ msgstr "Larghezza di banda"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr "Batt"
msgstr ""
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
@@ -336,7 +336,7 @@ msgstr "Attenzione - possibile perdita di dati"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -490,13 +490,13 @@ msgstr "Copia YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "Interne"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -624,7 +624,7 @@ msgstr "Utilizzo del disco di {extraFsName}"
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "Display"
msgstr ""
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
@@ -677,7 +677,7 @@ msgstr "Modifica {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr "Email"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,7 +772,7 @@ msgstr "Esporta la configurazione attuale dei tuoi sistemi."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -824,7 +824,7 @@ msgstr "Impronta digitale"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -858,7 +858,7 @@ msgstr "Globale"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr "GPU"
msgstr ""
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -883,7 +883,7 @@ msgstr "Stato"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Hearthbeat"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -901,7 +901,7 @@ msgstr "Comando Homebrew"
#: src/components/add-system.tsx
msgid "Host / IP"
msgstr "Host / IP"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
@@ -1043,7 +1043,7 @@ msgstr "Istruzioni di configurazione manuale"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "Max 1 min"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
@@ -1109,7 +1109,7 @@ msgstr "Unità rete"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "No"
msgstr "No"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1196,7 +1196,7 @@ msgstr "Pagine / Impostazioni"
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Password"
msgstr "Password"
msgstr ""
#: src/components/login/auth-form.tsx
msgid "Password must be at least 8 characters."
@@ -1384,7 +1384,7 @@ msgstr "Riprendi"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr "Root"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1615,11 +1615,11 @@ msgstr "Temperature dei sensori di sistema"
#: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
msgstr "Test Heartbeat"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Test notification sent"
@@ -1665,7 +1665,7 @@ msgstr "Attiva/disattiva tema"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1931,4 +1931,3 @@ msgstr "Sì"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Le impostazioni utente sono state aggiornate."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: sr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:14\n"
"PO-Revision-Date: 2026-04-05 18:27\n"
"Last-Translator: \n"
"Language-Team: Serbian (Cyrillic)\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
@@ -858,7 +858,7 @@ msgstr "Глобално"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr "ГПЈ"
msgstr ""
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -1384,7 +1384,7 @@ msgstr "Настави"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr "Root"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1697,7 +1697,7 @@ msgstr "Укупни подаци poslati за сваки интерфејс"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)"
msgstr "Укупно време проведено на читању/писању (може бити веће од 100%)"
msgstr ""
#. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx
@@ -1931,4 +1931,3 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Ваша корисничка подешавања су ажурирана."

View File

@@ -30,6 +30,7 @@ const LoginPage = lazy(() => import("@/components/login/login.tsx"))
const Home = lazy(() => import("@/components/routes/home.tsx"))
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
const Probes = lazy(() => import("@/components/routes/probes.tsx"))
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
@@ -79,6 +80,8 @@ const App = memo(() => {
return <Containers />
} else if (page.route === "smart") {
return <Smart />
} else if (page.route === "probes") {
return <Probes />
} else if (page.route === "settings") {
return <Settings />
}

View File

@@ -316,8 +316,6 @@ export interface ChartData {
systemStats: SystemStatsRecord[]
containerData: ChartDataContainer[]
orientation: "right" | "left"
ticks: number[]
domain: number[]
chartTime: ChartTimes
}
@@ -546,3 +544,40 @@ export interface UpdateInfo {
v: string // new version
url: string // url to new version
}
export interface NetworkProbeRecord {
id: string
system: string
name: string
target: string
protocol: "icmp" | "tcp" | "http"
port: number
res: number
resMin1h: number
resMax1h: number
resAvg1h: number
loss1h: number
interval: number
enabled: boolean
updated: string
}
/**
* 0: avg 1 minute response in ms
*
* 1: avg response over 1 hour in ms
*
* 2: min response over the last hour in ms
*
* 3: max response over the last hour in ms
*
* 4: packet loss in %
*/
type ProbeResult = number[]
export interface NetworkProbeStatsRecord {
id?: string
type?: string
stats: Record<string, ProbeResult>
created: number // unix timestamp (ms) for Recharts xAxis
}