Compare commits

..

2 Commits

Author SHA1 Message Date
henrygd
c1c1cd1bcb ui: fix temperature chart filtering 2026-04-20 20:45:16 -04:00
Yvan Wang
cd9ea51039 fix(hub): use rfcEmail validator to allow IDN/Punycode email addresses (#1935)
valibot's email() action uses a domain-label regex that disallows the
-- sequence, so Punycode ACE labels like xn--mnchen-3ya.de (the ASCII
form of münchen.de) are incorrectly rejected.

Switching to rfcEmail() applies the RFC 5321 domain-label pattern,
which allows hyphens within labels and therefore accepts both standard
and internationalized domain names.
2026-04-18 12:23:14 -04:00
46 changed files with 95 additions and 3684 deletions

View File

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

View File

@@ -141,7 +141,6 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
// } // }
func (c *ConnectionManager) stop() error { func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer() _ = c.agent.StopServer()
c.agent.probeManager.Stop()
c.closeWebSocket() c.closeWebSocket()
return health.CleanUp() return health.CleanUp()
} }

View File

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

View File

@@ -1,494 +0,0 @@
package agent
import (
"errors"
"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},
}
}
func newProbeTask(config probe.Config) *probeTask {
return &probeTask{
config: config,
cancel: make(chan struct{}),
samples: make([]probeSample, 0, 64),
}
}
// 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 and restart tasks whose config changed.
for key, cfg := range newKeys {
task, exists := pm.probes[key]
if exists && task.config == cfg {
continue
}
if exists {
close(task.cancel)
}
task = newProbeTask(cfg)
pm.probes[key] = task
go pm.runProbe(task, true)
}
}
// ApplySync applies a full or incremental probe sync request.
func (pm *ProbeManager) ApplySync(req probe.SyncRequest) (probe.SyncResponse, error) {
switch req.Action {
case probe.SyncActionReplace:
pm.SyncProbes(req.Configs)
return probe.SyncResponse{}, nil
case probe.SyncActionUpsert:
result, err := pm.UpsertProbe(req.Config, req.RunNow)
if err != nil {
return probe.SyncResponse{}, err
}
if result == nil {
return probe.SyncResponse{}, nil
}
return probe.SyncResponse{Result: *result}, nil
case probe.SyncActionDelete:
if req.Config.ID == "" {
return probe.SyncResponse{}, errors.New("missing probe ID for delete action")
}
pm.DeleteProbe(req.Config.ID)
return probe.SyncResponse{}, nil
default:
return probe.SyncResponse{}, fmt.Errorf("unknown probe sync action: %d", req.Action)
}
}
// UpsertProbe creates or replaces a single probe task.
func (pm *ProbeManager) UpsertProbe(config probe.Config, runNow bool) (*probe.Result, error) {
if config.ID == "" {
return nil, errors.New("missing probe ID")
}
pm.mu.Lock()
task, exists := pm.probes[config.ID]
startTask := false
if exists && task.config == config {
pm.mu.Unlock()
if !runNow {
return nil, nil
}
return pm.runProbeNow(task), nil
}
if exists {
close(task.cancel)
}
task = newProbeTask(config)
pm.probes[config.ID] = task
startTask = true
pm.mu.Unlock()
if runNow {
result := pm.runProbeNow(task)
if startTask {
go pm.runProbe(task, false)
}
return result, nil
}
if startTask {
go pm.runProbe(task, true)
}
return nil, nil
}
// DeleteProbe stops and removes a single probe task.
func (pm *ProbeManager) DeleteProbe(id string) {
if id == "" {
return
}
pm.mu.Lock()
defer pm.mu.Unlock()
if task, exists := pm.probes[id]; exists {
close(task.cancel)
delete(pm.probes, id)
}
}
// 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()
result, ok := task.resultLocked(duration, now)
task.mu.Unlock()
if !ok {
continue
}
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, runImmediately bool) {
interval := time.Duration(task.config.Interval) * time.Second
if interval < time.Second {
interval = 10 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
if runImmediately {
pm.executeProbe(task)
}
for {
select {
case <-task.cancel:
return
case <-ticker.C:
pm.executeProbe(task)
}
}
}
func (pm *ProbeManager) runProbeNow(task *probeTask) *probe.Result {
pm.executeProbe(task)
task.mu.Lock()
defer task.mu.Unlock()
result, ok := task.resultLocked(time.Minute, time.Now())
if !ok {
return nil
}
return &result
}
// 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)
}
func (task *probeTask) resultLocked(duration time.Duration, now time.Time) (probe.Result, bool) {
agg := task.aggregateLocked(duration, now)
hourAgg := task.aggregateLocked(time.Hour, now)
if !agg.hasData() {
return nil, false
}
result := agg.result()
hourAvg := hourAgg.avgResponse()
hourLoss := hourAgg.lossPercentage()
if hourAgg.successCount > 0 {
return probe.Result{
result[0],
hourAvg,
math.Round(hourAgg.minMs*100) / 100,
math.Round(hourAgg.maxMs*100) / 100,
hourLoss,
}, true
}
return probe.Result{result[0], hourAvg, 0, 0, hourLoss}, true
}
// 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 {
if client == nil {
client = http.DefaultClient
}
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
}

View File

@@ -1,242 +0,0 @@
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
}

View File

@@ -1,118 +0,0 @@
//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())
})
}

View File

@@ -1,293 +0,0 @@
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: "ignored", Protocol: "noop", Interval: 10}
invalidCfg := probe.Config{Target: "ignored", Protocol: "noop", 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: "ignored", Protocol: "noop", Interval: 10}
removeCfg := probe.Config{ID: "probe-2", Target: "ignored", Protocol: "noop", 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 TestProbeManagerSyncProbesRestartsChangedConfig(t *testing.T) {
originalCfg := probe.Config{ID: "probe-1", Target: "ignored-a", Protocol: "noop", Interval: 10}
updatedCfg := probe.Config{ID: "probe-1", Target: "ignored-b", Protocol: "noop", Interval: 10}
originalTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
pm := &ProbeManager{
probes: map[string]*probeTask{
originalCfg.ID: originalTask,
},
}
pm.SyncProbes([]probe.Config{updatedCfg})
defer pm.Stop()
restartedTask := pm.probes[updatedCfg.ID]
assert.NotSame(t, originalTask, restartedTask)
assert.Equal(t, updatedCfg, restartedTask.config)
select {
case <-originalTask.cancel:
default:
t.Fatal("expected changed probe task to be cancelled")
}
}
func TestProbeManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
pm := &ProbeManager{
probes: make(map[string]*probeTask),
httpClient: server.Client(),
}
resp, err := pm.ApplySync(probe.SyncRequest{
Action: probe.SyncActionUpsert,
Config: probe.Config{ID: "probe-1", Target: server.URL, Protocol: "http", Interval: 10},
RunNow: true,
})
defer pm.Stop()
require.NoError(t, err)
require.Len(t, resp.Result, 5)
assert.GreaterOrEqual(t, resp.Result[0], 0.0)
assert.Equal(t, 0.0, resp.Result[4])
task := pm.probes["probe-1"]
require.NotNil(t, task)
task.mu.Lock()
defer task.mu.Unlock()
require.Len(t, task.samples, 1)
}
func TestProbeManagerApplySyncDeleteRemovesTask(t *testing.T) {
config := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
task := &probeTask{config: config, cancel: make(chan struct{})}
pm := &ProbeManager{
probes: map[string]*probeTask{config.ID: task},
}
_, err := pm.ApplySync(probe.SyncRequest{
Action: probe.SyncActionDelete,
Config: probe.Config{ID: config.ID},
})
require.NoError(t, err)
_, exists := pm.probes[config.ID]
assert.False(t, exists)
select {
case <-task.cancel:
default:
t.Fatal("expected deleted probe task to be cancelled")
}
}
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,7 +20,6 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.52.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1 howett.net/plist v1.0.1
@@ -57,6 +56,7 @@ require (
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.38.0 // 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/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.41.0 // indirect golang.org/x/term v0.41.0 // indirect

View File

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

View File

@@ -1,56 +0,0 @@
package probe
type SyncAction uint8
const (
// SyncActionReplace indicates a full sync where the provided configs should replace all existing probes for the system.
SyncActionReplace SyncAction = iota
// SyncActionUpsert indicates an incremental sync where the provided config should be added or updated.
SyncActionUpsert
// SyncActionDelete indicates an incremental sync where the provided config should be removed.
SyncActionDelete
)
// 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
}
// SyncRequest defines an incremental or full probe sync request sent to the agent.
type SyncRequest struct {
Action SyncAction `cbor:"0,keyasint"`
Config Config `cbor:"1,keyasint,omitempty"`
Configs []Config `cbor:"2,keyasint,omitempty"`
RunNow bool `cbor:"3,keyasint,omitempty"`
}
// SyncResponse returns the immediate result for an upsert when requested.
type SyncResponse struct {
Result Result `cbor:"0,keyasint,omitempty"`
}
// 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
// Get returns the value at the specified index or 0 if the index is out of range.
func (r Result) Get(index int) float64 {
if index < len(r) {
return r[index]
}
return 0
}

View File

@@ -7,7 +7,6 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/entities/systemd"
) )
@@ -175,10 +174,9 @@ type Details struct {
// Final data structure to return to the hub // Final data structure to return to the hub
type CombinedData struct { type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"` Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"` Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"` Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"` SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,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 return err
} }
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services", "network_probe_stats"}, collectionRules{ if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{
list: &systemScopedReadRule, list: &systemScopedReadRule,
}); err != nil { }); err != nil {
return err return err
@@ -92,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
return err return err
} }
if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{ if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
list: &systemScopedReadRule, list: &systemScopedReadRule,
view: &systemScopedReadRule, view: &systemScopedReadRule,
create: &systemScopedWriteRule, create: &systemScopedWriteRule,

View File

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

View File

@@ -1,120 +0,0 @@
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)
}
// bindNetworkProbesEvents keeps probe records and agent probe state in sync.
func bindNetworkProbesEvents(hub *Hub) {
// on create, make sure the id is set to a stable hash
hub.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
systemID := e.Record.GetString("system")
config := probeConfigFromRecord(e.Record)
id := generateProbeID(systemID, *config)
e.Record.Set("id", id)
return e.Next()
})
// sync probe to agent on creation and persist the first result immediately when available
hub.OnRecordCreateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
if !e.Record.GetBool("enabled") {
return nil
}
result, err := hub.upsertNetworkProbe(e.Record, true)
if err != nil {
hub.Logger().Warn("failed to sync probe to agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
return nil
}
if result == nil {
return nil
}
setProbeResultFields(e.Record, *result)
if err := e.App.SaveNoValidate(e.Record); err != nil {
hub.Logger().Warn("failed to save initial probe result", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
}
return nil
})
hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
if e.Record.GetBool("enabled") {
_, err = hub.upsertNetworkProbe(e.Record, false)
} else {
err = hub.deleteNetworkProbe(e.Record)
}
if err != nil {
hub.Logger().Warn("failed to sync updated probe to agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
}
return nil
})
// sync probe to agent on delete
hub.OnRecordDeleteRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
if err := hub.deleteNetworkProbe(e.Record); err != nil {
hub.Logger().Warn("failed to delete probe on agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
}
return nil
})
}
// probeConfigFromRecord builds a probe config from a network_probes record.
func probeConfigFromRecord(record *core.Record) *probe.Config {
return &probe.Config{
ID: record.Id,
Target: record.GetString("target"),
Protocol: record.GetString("protocol"),
Port: uint16(record.GetInt("port")),
Interval: uint16(record.GetInt("interval")),
}
}
// setProbeResultFields stores the latest probe result values on the record.
func setProbeResultFields(record *core.Record, result probe.Result) {
record.Set("res", result.Get(0))
record.Set("resAvg1h", result.Get(1))
record.Set("resMin1h", result.Get(2))
record.Set("resMax1h", result.Get(3))
record.Set("loss1h", result.Get(4))
}
// upsertNetworkProbe applies the record's probe config to the target system.
func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) (*probe.Result, error) {
systemID := record.GetString("system")
system, err := h.sm.GetSystem(systemID)
if err != nil {
return nil, err
}
return system.UpsertNetworkProbe(*probeConfigFromRecord(record), runNow)
}
// deleteNetworkProbe removes the record's probe from the target system.
func (h *Hub) deleteNetworkProbe(record *core.Record) error {
systemID := record.GetString("system")
system, err := h.sm.GetSystem(systemID)
if err != nil {
return err
}
return system.DeleteNetworkProbe(record.Id)
}

View File

@@ -1,79 +0,0 @@
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,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"log/slog"
"math/rand" "math/rand"
"net" "net"
"strings" "strings"
@@ -19,7 +18,6 @@ import (
"github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/entities/systemd"
@@ -31,7 +29,6 @@ import (
"github.com/lxzan/gws" "github.com/lxzan/gws"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -241,12 +238,6 @@ 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) // 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("status", up)
systemRecord.Set("info", data.Info) systemRecord.Set("info", data.Info)
@@ -298,7 +289,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
for i, service := range data { for i, service := range data {
suffix := fmt.Sprintf("%d", i) 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)) 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["name"+suffix] = service.Name
params["state"+suffix] = service.State params["state"+suffix] = service.State
params["sub"+suffix] = service.Sub params["sub"+suffix] = service.Sub
@@ -315,88 +306,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
return err 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", values.Get(0))
record.Set("resAvg1h", values.Get(1))
record.Set("resMin1h", values.Get(2))
record.Set("resMax1h", values.Get(3))
record.Set("loss1h", values.Get(4))
err = app.SaveNoValidate(record)
}
default:
_, err = updateQuery.Bind(dbx.Params{
"id": id,
"res": values.Get(0),
"resAvg1h": values.Get(1),
"resMin1h": values.Get(2),
"resMax1h": values.Get(3),
"loss1h": values.Get(4),
"updated": nowString,
}).Execute()
}
if err != nil {
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", id, "err", err)
}
}
return nil
}
// createContainerRecords creates container records // createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error { func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 { if len(data) == 0 {
@@ -631,7 +540,7 @@ func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error)
return result, err return result, err
} }
func MakeStableHashId(strings ...string) string { func makeStableHashId(strings ...string) string {
hash := fnv.New32a() hash := fnv.New32a()
for _, str := range strings { for _, str := range strings {
hash.Write([]byte(str)) hash.Write([]byte(str))

View File

@@ -7,7 +7,6 @@ import (
"github.com/henrygd/beszel/internal/hub/ws" "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/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap" "github.com/henrygd/beszel/internal/hub/expirymap"
@@ -16,7 +15,6 @@ import (
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/blang/semver" "github.com/blang/semver"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store" "github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
@@ -319,17 +317,6 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
if err := sm.AddRecord(systemRecord, system); err != nil { if err := sm.AddRecord(systemRecord, system); err != nil {
return err 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 return nil
} }
@@ -342,16 +329,6 @@ 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 // createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
func (sm *SystemManager) createSSHClientConfig() error { func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey("") privateKey, err := sm.hub.GetSSHKey("")

View File

@@ -1,48 +0,0 @@
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 {
_, err := sys.syncNetworkProbes(probe.SyncRequest{Action: probe.SyncActionReplace, Configs: configs})
return err
}
// UpsertNetworkProbe sends a single probe configuration change to the agent.
func (sys *System) UpsertNetworkProbe(config probe.Config, runNow bool) (*probe.Result, error) {
resp, err := sys.syncNetworkProbes(probe.SyncRequest{
Action: probe.SyncActionUpsert,
Config: config,
RunNow: runNow,
})
if err != nil {
return nil, err
}
if len(resp.Result) == 0 {
return nil, nil
}
result := resp.Result
return &result, nil
}
// DeleteNetworkProbe removes a single probe task from the agent.
func (sys *System) DeleteNetworkProbe(id string) error {
_, err := sys.syncNetworkProbes(probe.SyncRequest{
Action: probe.SyncActionDelete,
Config: probe.Config{ID: id},
})
return err
}
func (sys *System) syncNetworkProbes(req probe.SyncRequest) (probe.SyncResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result probe.SyncResponse
return result, sys.request(ctx, common.SyncNetworkProbes, req, &result)
}

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 { func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
hub := sys.manager.hub hub := sys.manager.hub
recordID := MakeStableHashId(sys.Id, deviceKey) recordID := makeStableHashId(sys.Id, deviceKey)
record, err := hub.FindRecordById(collection, recordID) record, err := hub.FindRecordById(collection, recordID)
if err != nil { if err != nil {

View File

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

View File

@@ -1,11 +1,7 @@
// Package utils provides utility functions for the hub. // Package utils provides utility functions for the hub.
package utils package utils
import ( import "os"
"os"
"github.com/pocketbase/pocketbase/core"
)
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key. // GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) { func GetEnv(key string) (value string, exists bool) {
@@ -14,26 +10,3 @@ func GetEnv(key string) (value string, exists bool) {
} }
return os.LookupEnv(key) return os.LookupEnv(key)
} }
// realtimeActiveForCollection checks if there are active WebSocket subscriptions for the given collection.
func RealtimeActiveForCollection(app core.App, collectionName string, validateFn func(filterQuery string) bool) bool {
broker := app.SubscriptionsBroker()
if broker.TotalClients() == 0 {
return false
}
for _, client := range broker.Clients() {
subs := client.Subscriptions(collectionName)
if len(subs) > 0 {
if validateFn == nil {
return true
}
for k := range subs {
filter := subs[k].Query["filter"]
if validateFn(filter) {
return true
}
}
}
}
return false
}

View File

@@ -1699,223 +1699,6 @@ func init() {
"type": "base", "type": "base",
"updateRule": null, "updateRule": null,
"viewRule": 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

@@ -1,58 +0,0 @@
//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,7 +8,6 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/probe"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -71,7 +70,7 @@ func (rm *RecordManager) CreateLongerRecords() {
// wrap the operations in a transaction // wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error { rm.app.RunInTransaction(func(txApp core.App) error {
var err error var err error
collections := [3]*core.Collection{} collections := [2]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats") collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil { if err != nil {
return err return err
@@ -80,10 +79,6 @@ func (rm *RecordManager) CreateLongerRecords() {
if err != nil { if err != nil {
return err return err
} }
collections[2], err = txApp.FindCachedCollectionByNameOrId("network_probe_stats")
if err != nil {
return err
}
var systems RecordIds var systems RecordIds
db := txApp.DB() db := txApp.DB()
@@ -143,9 +138,8 @@ func (rm *RecordManager) CreateLongerRecords() {
case "system_stats": case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds)) longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats": case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds)) 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 { if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err) log.Println("failed to save longer record", "err", err)
@@ -506,80 +500,6 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
return result 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 */ /* Round float to two decimals */
func twoDecimals(value float64) float64 { func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100 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 // Deletes system_stats records older than what is displayed in the UI
func deleteOldSystemStats(app core.App) error { func deleteOldSystemStats(app core.App) error {
// Collections to process // Collections to process
collections := [3]string{"system_stats", "container_stats", "network_probe_stats"} collections := [2]string{"system_stats", "container_stats"}
// Record types and their retention periods // Record types and their retention periods
type RecordDeletionData struct { type RecordDeletionData struct {

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import { toast } from "../ui/use-toast"
import { OtpInputForm } from "./otp-forms" import { OtpInputForm } from "./otp-forms"
const honeypot = v.literal("") const honeypot = v.literal("")
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`)) const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`))
const passwordSchema = v.pipe( const passwordSchema = v.pipe(
v.string(), v.string(),
v.minLength(8, t`Password must be at least 8 characters.`), v.minLength(8, t`Password must be at least 8 characters.`),

View File

@@ -8,7 +8,6 @@ import {
LogOutIcon, LogOutIcon,
LogsIcon, LogsIcon,
MenuIcon, MenuIcon,
NetworkIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
ServerIcon, ServerIcon,
@@ -110,10 +109,6 @@ export default function Navbar() {
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} /> <HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<span>S.M.A.R.T.</span> <span>S.M.A.R.T.</span>
</DropdownMenuItem> </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 <DropdownMenuItem
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))} onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
className="flex items-center" className="flex items-center"
@@ -185,21 +180,6 @@ export default function Navbar() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>S.M.A.R.T.</TooltipContent> <TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip> </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 /> <LangToggle />
<ModeToggle /> <ModeToggle />
<Tooltip> <Tooltip>

View File

@@ -1,238 +0,0 @@
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,
enableHiding: 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

@@ -1,354 +0,0 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type Row,
type RowSelectionState,
type SortingState,
type Table as TableType,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
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 { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/components/ui/use-toast"
import { isReadOnlyUser } from "@/lib/api"
import { pb } from "@/lib/api"
import { $allSystemsById } from "@/lib/stores"
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
import { Trash2Icon } from "lucide-react"
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 [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [globalFilter, setGlobalFilter] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
const { toast } = useToast()
const canManageProbes = !isReadOnlyUser()
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 = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
if (!canManageProbes) {
return columns
}
const selectionColumn: ColumnDef<NetworkProbeRecord> = {
id: "select",
header: ({ table }) => (
<Checkbox
className="ms-2"
checked={table.getIsAllRowsSelected() || (table.getIsSomeRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label={t`Select all`}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t`Select row`}
/>
),
enableSorting: false,
enableHiding: false,
size: 44,
}
return [selectionColumn, ...columns]
}, [systemId, longestName, longestTarget, canManageProbes])
const handleBulkDelete = async () => {
setDeleteOpen(false)
const selectedIds = Object.keys(rowSelection)
if (!selectedIds.length) {
return
}
try {
let batch = pb.createBatch()
let inBatch = 0
for (const id of selectedIds) {
batch.collection("network_probes").delete(id)
inBatch++
if (inBatch >= 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
if (inBatch) {
await batch.send()
}
table.resetRowSelection()
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to delete probes.`,
})
}
}
const table = useReactTable({
data: probes,
columns,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
defaultColumn: {
sortUndefined: "last",
size: 900,
minSize: 0,
},
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
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">
{canManageProbes && table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-1 items-center gap-4 z-50 backdrop-blur-md shrink-0 md:static md:p-0 md:w-auto md:gap-3">
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="h-9 shrink-0">
<Trash2Icon className="size-4 shrink-0" />
<span className="ms-1">
<Trans>Delete</Trans>
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>This will permanently delete all selected records from the database.</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleBulkDelete}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{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-50"
/>
)}
{canManageProbes ? <AddProbeDialog systemId={systemId} /> : null}
</div>
</div>
</CardHeader>
<div className="rounded-md">
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} rowSelection={rowSelection} />
</div>
</Card>
)
}
const NetworkProbesTable = memo(function NetworkProbeTable({
table,
rows,
colLength,
rowSelection: _rowSelection,
}: {
table: TableType<NetworkProbeRecord>
rows: Row<NetworkProbeRecord>[]
colLength: number
rowSelection: RowSelectionState
}) {
// 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}
isSelected={row.getIsSelected()}
/>
)
})
) : (
<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,
isSelected,
}: {
row: Row<NetworkProbeRecord>
virtualRow: VirtualItem
isSelected: boolean
}) {
return (
<TableRow data-state={isSelected && "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

@@ -1,413 +0,0 @@
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,
} from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
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 { Textarea } from "@/components/ui/textarea"
import { ChevronDownIcon, ListIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores"
import * as v from "valibot"
type ProbeProtocol = "icmp" | "tcp" | "http"
type ProbeValues = {
system: string
target: string
protocol: ProbeProtocol
port: number
interval: string
name?: string
}
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()),
})
function buildProbePayload(values: ProbeValues) {
const normalizedPort = (values.protocol === "tcp" || values.protocol === "http") && !values.port ? 443 : values.port
const payload = v.parse(Schema, {
system: values.system,
target: values.target,
protocol: values.protocol,
port: normalizedPort,
interval: values.interval,
enabled: true,
})
const trimmedName = values.name?.trim()
const targetName = values.target.replace(/^https?:\/\//i, "")
if (trimmedName) {
payload.name = trimmedName
} else if (targetName !== values.target) {
payload.name = targetName
}
return payload
}
function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",")
const target = rawTarget.trim()
if (!target) {
throw new Error(`Line ${lineNumber}: target is required`)
}
const inferredProtocol: ProbeProtocol = /^https?:\/\//i.test(target) ? "http" : "icmp"
const protocolValue = rawProtocol.trim().toLowerCase() || inferredProtocol
if (protocolValue !== "icmp" && protocolValue !== "tcp" && protocolValue !== "http") {
throw new Error(`Line ${lineNumber}: protocol must be icmp, tcp, or http`)
}
const portValue = rawPort.trim()
if (protocolValue === "tcp") {
const port = portValue ? Number(portValue) : 443
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Line ${lineNumber}: TCP entries require a port between 1 and 65535`)
}
return buildProbePayload({
system,
target,
protocol: "tcp",
port,
interval: rawInterval.trim() || "30",
name: rawName.join(",").trim() || undefined,
})
}
return buildProbePayload({
system,
target,
protocol: protocolValue,
port: 0,
interval: rawInterval.trim() || "30",
name: rawName.join(",").trim() || undefined,
})
}
export function AddProbeDialog({ systemId }: { systemId?: string }) {
const [open, setOpen] = useState(false)
const [bulkOpen, setBulkOpen] = 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 [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState("")
const [bulkSelectedSystemId, setBulkSelectedSystemId] = 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 resetBulkForm = () => {
setBulkInput("")
setBulkSelectedSystemId("")
}
const openBulkAdd = () => {
if (!systemId && selectedSystemId) {
setBulkSelectedSystemId(selectedSystemId)
}
setOpen(false)
setBulkOpen(true)
}
const openAdd = () => {
setBulkOpen(false)
setOpen(true)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const payload = buildProbePayload({
system: systemId ?? selectedSystemId,
target,
protocol: protocol as ProbeProtocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: probeInterval,
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)
}
}
async function handleBulkSubmit(e: React.FormEvent) {
e.preventDefault()
setBulkLoading(true)
let closedForSubmit = false
try {
const system = systemId ?? bulkSelectedSystemId
const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim())
if (!rawLines.length) {
throw new Error("Enter at least one probe.")
}
const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system))
setBulkOpen(false)
closedForSubmit = true
let batch = pb.createBatch()
let inBatch = 0
for (const payload of payloads) {
batch.collection("network_probes").create(payload)
inBatch++
if (inBatch > 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
if (inBatch) {
await batch.send()
}
resetBulkForm()
toast({ title: t`Probes created`, description: `${payloads.length} probe(s) added.` })
} catch (err: unknown) {
if (closedForSubmit) {
setBulkOpen(true)
}
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
} finally {
setBulkLoading(false)
}
}
return (
<>
<div className="flex gap-0 rounded-lg">
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow">
{/* <PlusIcon className="size-4 me-1" /> */}
<Trans>Add {{ foo: t`Probe` }}</Trans>
</Button>
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="px-2 rounded-s-none border-s-0" aria-label={t`More probe actions`}>
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={openBulkAdd}>
<ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<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>
<Sheet open={bulkOpen} onOpenChange={setBulkOpen}>
<SheetContent className="w-full sm:max-w-xl gap-0">
<SheetHeader className="border-b">
<SheetTitle>
<Trans>Bulk Add {{ foo: t`Network Probes` }}</Trans>
</SheetTitle>
<SheetDescription>
<Trans>
Paste one probe per line. See{" "}
<a href={"#bulk-add-probes-docs"} className="underline underline-offset-2">
the documentation
</a>
.
</Trans>
</SheetDescription>
</SheetHeader>
<form onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
<div className="flex-1 space-y-4 overflow-auto p-4">
{!systemId && (
<div className="grid gap-2">
<Label>
<Trans>System</Trans>
</Label>
<Select value={bulkSelectedSystemId} onValueChange={setBulkSelectedSystemId} 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 htmlFor="bulk-probes">
<Trans>Entries</Trans>
</Label>
<Textarea
id="bulk-probes"
value={bulkInput}
onChange={(e) => setBulkInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
handleBulkSubmit(e)
}
}}
className="h-120 font-mono text-sm bg-muted/40"
style={{ maxHeight: `calc(100vh - 20rem)` }}
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Homepage"].join("\n")}
required
/>
<p className="text-xs text-muted-foreground">
target[,protocol[,port[,interval[,name]]]] TCP and HTTP default to port 443.
</p>
</div>
</div>
<SheetFooter className="border-t">
<Button type="submit" disabled={bulkLoading || (!systemId && !bulkSelectedSystemId)}>
<Trans>Add {{ foo: t`Network Probes` }}</Trans>
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
</>
)
}

View File

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

View File

@@ -1,25 +0,0 @@
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

@@ -24,7 +24,7 @@ interface ShoutrrrUrlCardProps {
} }
const NotificationSchema = v.object({ const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.email())), emails: v.array(v.pipe(v.string(), v.rfcEmail())),
webhooks: v.array(v.pipe(v.string(), v.url())), webhooks: v.array(v.pipe(v.string(), v.url())),
}) })

View File

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

View File

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

View File

@@ -1,152 +0,0 @@
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

@@ -120,7 +120,8 @@ export function TemperatureChart({
label: key, label: key,
dataKey: dataKeys[key], dataKey: dataKeys[key],
color: colorMap[key], color: colorMap[key],
opacity: strokeOpacity, strokeOpacity,
activeDot: !filtered,
} }
}) })
}, [sortedKeys, filter, dataKeys, colorMap]) }, [sortedKeys, filter, dataKeys, colorMap])
@@ -134,7 +135,7 @@ export function TemperatureChart({
// label: `Test ${++i}`, // label: `Test ${++i}`,
// dataKey: () => 0, // dataKey: () => 0,
// color: "red", // color: "red",
// opacity: 1, // strokeOpacity: 1,
// }) // })
// } // }
// } // }
@@ -202,6 +203,7 @@ export function TemperatureChart({
return `${decimalString(value)} ${unit}` return `${decimalString(value)} ${unit}`
}} }}
dataPoints={dataPoints} dataPoints={dataPoints}
filter={filter}
></LineChartDefault> ></LineChartDefault>
</ChartCard> </ChartCard>
</div> </div>

View File

@@ -1,11 +1,6 @@
import { lazy } from "react" import { lazy } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer" import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils" 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")) const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
@@ -39,46 +34,3 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
</div> </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, SystemStatsRecord,
} from "@/types" } from "@/types"
import { $router, navigate } from "../../router" import { $router, navigate } from "../../router"
import { appendData, cache, getStats, makeContainerData, makeContainerPoint } from "./chart-data" import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
export type SystemData = ReturnType<typeof useSystemData> export type SystemData = ReturnType<typeof useSystemData>
@@ -151,11 +151,16 @@ export function useSystemData(id: string) {
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v]) const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
const chartData: ChartData = useMemo(() => { const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
)
return { return {
systemStats, systemStats,
containerData, containerData,
chartTime, chartTime,
orientation: direction === "rtl" ? "right" : "left", orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
agentVersion, agentVersion,
} }
}, [systemStats, containerData, direction]) }, [systemStats, containerData, direction])
@@ -195,8 +200,8 @@ export function useSystemData(id: string) {
} }
Promise.allSettled([ Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats), getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData), getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
]).then(([systemStats, containerStats]) => { ]).then(([systemStats, containerStats]) => {
// If another request has been made since this one, ignore the results // If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) { if (requestId !== statsRequestId.current) {

View File

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

View File

@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted/40", "border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted!",
className className
)} )}
{...props} {...props}

View File

@@ -1,314 +0,0 @@
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

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

View File

@@ -316,6 +316,8 @@ export interface ChartData {
systemStats: SystemStatsRecord[] systemStats: SystemStatsRecord[]
containerData: ChartDataContainer[] containerData: ChartDataContainer[]
orientation: "right" | "left" orientation: "right" | "left"
ticks: number[]
domain: number[]
chartTime: ChartTimes chartTime: ChartTimes
} }
@@ -544,40 +546,3 @@ export interface UpdateInfo {
v: string // new version v: string // new version
url: string // url to 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
}