mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-22 04:31:50 +02:00
Compare commits
33 Commits
main
...
temp-probe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6472af1ba4 | ||
|
|
e931165566 | ||
|
|
48fe407292 | ||
|
|
a95376b4a2 | ||
|
|
732983493a | ||
|
|
264b17f429 | ||
|
|
cef5ab10a5 | ||
|
|
3a881e1d5e | ||
|
|
209bb4ebb4 | ||
|
|
e71ffd4d2a | ||
|
|
ea19ef6334 | ||
|
|
40da2b4358 | ||
|
|
d0d5912d85 | ||
|
|
4162186ae0 | ||
|
|
578ba985e9 | ||
|
|
485830452e | ||
|
|
2fd00cd0b5 | ||
|
|
853a294157 | ||
|
|
aa9ab49654 | ||
|
|
9a5959b57e | ||
|
|
50f8548479 | ||
|
|
bc0581ea61 | ||
|
|
fab5e8a656 | ||
|
|
3a0896e57e | ||
|
|
7fdc403470 | ||
|
|
e833d44c43 | ||
|
|
77dd4bdaf5 | ||
|
|
ecba63c4bb | ||
|
|
f9feaf5343 | ||
|
|
ddf5e925c8 | ||
|
|
865e6db90f | ||
|
|
a42d899e64 | ||
|
|
3eaf12a7d5 |
@@ -48,6 +48,7 @@ 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.
|
||||||
@@ -121,6 +122,9 @@ 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()
|
||||||
|
|
||||||
@@ -178,6 +182,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.probeManager != nil {
|
||||||
|
data.Probes = a.probeManager.GetResults(cacheTimeMs)
|
||||||
|
slog.Debug("Probes", "data", data.Probes)
|
||||||
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
// 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())
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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"
|
||||||
@@ -51,6 +52,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -203,3 +205,19 @@ 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 configs []probe.Config
|
||||||
|
if err := cbor.Unmarshal(hctx.Request.Data, &configs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hctx.Agent.probeManager.SyncProbes(configs)
|
||||||
|
slog.Info("network probes synced", "count", len(configs))
|
||||||
|
return hctx.SendResponse("ok", hctx.RequestID)
|
||||||
|
}
|
||||||
|
|||||||
237
agent/probe.go
Normal file
237
agent/probe.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProbeManager manages network probe tasks.
|
||||||
|
type ProbeManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
probes map[string]*probeTask // key = probe.Config.Key()
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeTask struct {
|
||||||
|
config probe.Config
|
||||||
|
cancel chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
samples []probeSample
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeSample struct {
|
||||||
|
latencyMs float64 // -1 means loss
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeManager() *ProbeManager {
|
||||||
|
return &ProbeManager{
|
||||||
|
probes: make(map[string]*probeTask),
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
newKeys[cfg.Key()] = cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop removed probes
|
||||||
|
for key, task := range pm.probes {
|
||||||
|
if _, exists := newKeys[key]; !exists {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new probes (skip existing ones with same key)
|
||||||
|
for key, cfg := range newKeys {
|
||||||
|
if _, exists := pm.probes[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
task := &probeTask{
|
||||||
|
config: cfg,
|
||||||
|
cancel: make(chan struct{}),
|
||||||
|
samples: make([]probeSample, 0, 64),
|
||||||
|
}
|
||||||
|
pm.probes[key] = task
|
||||||
|
go pm.runProbe(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetResults returns aggregated results for all probes over the last supplied duration in ms.
|
||||||
|
func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
|
||||||
|
results := make(map[string]probe.Result, len(pm.probes))
|
||||||
|
cutoff := time.Now().Add(-time.Duration(durationMs) * time.Millisecond)
|
||||||
|
|
||||||
|
for key, task := range pm.probes {
|
||||||
|
task.mu.Lock()
|
||||||
|
var sum, minMs, maxMs float64
|
||||||
|
var count, lossCount int
|
||||||
|
minMs = math.MaxFloat64
|
||||||
|
|
||||||
|
for _, s := range task.samples {
|
||||||
|
if s.timestamp.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if s.latencyMs < 0 {
|
||||||
|
lossCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum += s.latencyMs
|
||||||
|
if s.latencyMs < minMs {
|
||||||
|
minMs = s.latencyMs
|
||||||
|
}
|
||||||
|
if s.latencyMs > maxMs {
|
||||||
|
maxMs = s.latencyMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.mu.Unlock()
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := count - lossCount
|
||||||
|
var avg float64
|
||||||
|
if successCount > 0 {
|
||||||
|
avg = math.Round(sum/float64(successCount)*100) / 100
|
||||||
|
}
|
||||||
|
if minMs == math.MaxFloat64 {
|
||||||
|
minMs = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
results[key] = probe.Result{
|
||||||
|
avg, // average latency in ms
|
||||||
|
math.Round(minMs*100) / 100, // min latency in ms
|
||||||
|
math.Round(maxMs*100) / 100, // max latency in ms
|
||||||
|
math.Round(float64(lossCount)/float64(count)*10000) / 100, // packet loss percentage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops all probe tasks.
|
||||||
|
func (pm *ProbeManager) Stop() {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
for key, task := range pm.probes {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runProbe executes a single probe task in a loop.
|
||||||
|
func (pm *ProbeManager) runProbe(task *probeTask) {
|
||||||
|
interval := time.Duration(task.config.Interval) * time.Second
|
||||||
|
if interval < time.Second {
|
||||||
|
interval = 10 * time.Second
|
||||||
|
}
|
||||||
|
ticker := time.Tick(interval)
|
||||||
|
|
||||||
|
// Run immediately on start
|
||||||
|
pm.executeProbe(task)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
return
|
||||||
|
case <-ticker:
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *ProbeManager) executeProbe(task *probeTask) {
|
||||||
|
var latencyMs float64
|
||||||
|
|
||||||
|
switch task.config.Protocol {
|
||||||
|
case "icmp":
|
||||||
|
latencyMs = probeICMP(task.config.Target)
|
||||||
|
case "tcp":
|
||||||
|
latencyMs = probeTCP(task.config.Target, task.config.Port)
|
||||||
|
case "http":
|
||||||
|
latencyMs = probeHTTP(pm.httpClient, task.config.Target)
|
||||||
|
default:
|
||||||
|
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sample := probeSample{
|
||||||
|
latencyMs: latencyMs,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
task.mu.Lock()
|
||||||
|
// Trim old samples beyond 120s to bound memory
|
||||||
|
cutoff := time.Now().Add(-120 * time.Second)
|
||||||
|
start := 0
|
||||||
|
for i := range task.samples {
|
||||||
|
if task.samples[i].timestamp.After(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)
|
||||||
|
task.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeTCP measures pure TCP handshake latency (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 latency. Returns -1 on failure.
|
||||||
|
func probeHTTP(client *http.Client, url string) float64 {
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
}
|
||||||
242
agent/probe_ping.go
Normal file
242
agent/probe_ping.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.org/x/net/ipv6"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
||||||
|
|
||||||
|
type icmpPacketConn interface {
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
||||||
|
// all native methods fail, the choice is cached so subsequent probes skip
|
||||||
|
// the trial-and-error overhead.
|
||||||
|
type icmpMethod int
|
||||||
|
|
||||||
|
const (
|
||||||
|
icmpUntried icmpMethod = iota // haven't tried yet
|
||||||
|
icmpRaw // privileged raw socket
|
||||||
|
icmpDatagram // unprivileged datagram socket
|
||||||
|
icmpExecFallback // shell out to system ping command
|
||||||
|
)
|
||||||
|
|
||||||
|
// icmpFamily holds the network parameters and cached detection result for one address family.
|
||||||
|
type icmpFamily struct {
|
||||||
|
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
||||||
|
dgramNetwork string // e.g. "udp4" or "udp6"
|
||||||
|
listenAddr string // "0.0.0.0" or "::"
|
||||||
|
echoType icmp.Type // outgoing echo request type
|
||||||
|
replyType icmp.Type // expected echo reply type
|
||||||
|
proto int // IANA protocol number for parsing replies
|
||||||
|
isIPv6 bool
|
||||||
|
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
icmpV4 = icmpFamily{
|
||||||
|
rawNetwork: "ip4:icmp",
|
||||||
|
dgramNetwork: "udp4",
|
||||||
|
listenAddr: "0.0.0.0",
|
||||||
|
echoType: ipv4.ICMPTypeEcho,
|
||||||
|
replyType: ipv4.ICMPTypeEchoReply,
|
||||||
|
proto: 1,
|
||||||
|
}
|
||||||
|
icmpV6 = icmpFamily{
|
||||||
|
rawNetwork: "ip6:ipv6-icmp",
|
||||||
|
dgramNetwork: "udp6",
|
||||||
|
listenAddr: "::",
|
||||||
|
echoType: ipv6.ICMPTypeEchoRequest,
|
||||||
|
replyType: ipv6.ICMPTypeEchoReply,
|
||||||
|
proto: 58,
|
||||||
|
isIPv6: true,
|
||||||
|
}
|
||||||
|
icmpModeMu sync.Mutex
|
||||||
|
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
||||||
|
return icmp.ListenPacket(network, listenAddr)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeICMP sends an ICMP echo request and measures round-trip latency.
|
||||||
|
// 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 latency in milliseconds, or -1 on failure.
|
||||||
|
func probeICMP(target string) float64 {
|
||||||
|
family, ip := resolveICMPTarget(target)
|
||||||
|
if family == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
icmpModeMu.Lock()
|
||||||
|
if family.mode == icmpUntried {
|
||||||
|
family.mode = detectICMPMode(family, icmpListen)
|
||||||
|
}
|
||||||
|
mode := family.mode
|
||||||
|
icmpModeMu.Unlock()
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case icmpRaw:
|
||||||
|
return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
|
||||||
|
case icmpDatagram:
|
||||||
|
return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
|
||||||
|
case icmpExecFallback:
|
||||||
|
return probeICMPExec(target, family.isIPv6)
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
||||||
|
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
||||||
|
func resolveICMPTarget(target string) (*icmpFamily, net.IP) {
|
||||||
|
if ip := net.ParseIP(target); ip != nil {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return &icmpV4, ip.To4()
|
||||||
|
}
|
||||||
|
return &icmpV6, ip
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := net.LookupIP(target)
|
||||||
|
if err != nil || len(ips) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
return &icmpV4, v4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &icmpV6, ips[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
||||||
|
label := "IPv4"
|
||||||
|
if family.isIPv6 {
|
||||||
|
label = "IPv6"
|
||||||
|
}
|
||||||
|
|
||||||
|
if conn, err := listen(family.rawNetwork, family.listenAddr); err == nil {
|
||||||
|
conn.Close()
|
||||||
|
slog.Info("ICMP probe using raw socket", "family", label)
|
||||||
|
return icmpRaw
|
||||||
|
} else {
|
||||||
|
slog.Debug("ICMP raw socket unavailable", "family", label, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if conn, err := listen(family.dgramNetwork, family.listenAddr); err == nil {
|
||||||
|
conn.Close()
|
||||||
|
slog.Info("ICMP probe using unprivileged datagram socket", "family", label)
|
||||||
|
return icmpDatagram
|
||||||
|
} else {
|
||||||
|
slog.Debug("ICMP datagram socket unavailable", "family", label, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("ICMP probe falling back to system ping command", "family", label)
|
||||||
|
return icmpExecFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
||||||
|
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
|
||||||
|
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Build ICMP echo request
|
||||||
|
msg := &icmp.Message{
|
||||||
|
Type: family.echoType,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: os.Getpid() & 0xffff,
|
||||||
|
Seq: 1,
|
||||||
|
Data: []byte("beszel-probe"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBytes, err := msg.Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set deadline before sending
|
||||||
|
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reply
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, err := conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.Type == family.replyType {
|
||||||
|
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
}
|
||||||
|
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeICMPExec falls back to the system ping command. Returns -1 on failure.
|
||||||
|
func probeICMPExec(target string, isIPv6 bool) float64 {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
||||||
|
}
|
||||||
|
default: // linux, darwin, freebsd
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// If ping fails but we got output, still try to parse
|
||||||
|
if len(output) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := pingTimeRegex.FindSubmatch(output)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use wall clock time if ping succeeded but parsing failed
|
||||||
|
if err == nil {
|
||||||
|
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
118
agent/probe_ping_test.go
Normal file
118
agent/probe_ping_test.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testICMPPacketConn struct{}
|
||||||
|
|
||||||
|
func (testICMPPacketConn) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestDetectICMPMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
family *icmpFamily
|
||||||
|
rawErr error
|
||||||
|
udpErr error
|
||||||
|
want icmpMethod
|
||||||
|
wantNetworks []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IPv4 prefers raw socket when available",
|
||||||
|
family: &icmpV4,
|
||||||
|
want: icmpRaw,
|
||||||
|
wantNetworks: []string{"ip4:icmp"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 uses datagram when raw unavailable",
|
||||||
|
family: &icmpV4,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
want: icmpDatagram,
|
||||||
|
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 falls back to exec when both unavailable",
|
||||||
|
family: &icmpV4,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
udpErr: errors.New("protocol not supported"),
|
||||||
|
want: icmpExecFallback,
|
||||||
|
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 prefers raw socket when available",
|
||||||
|
family: &icmpV6,
|
||||||
|
want: icmpRaw,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 uses datagram when raw unavailable",
|
||||||
|
family: &icmpV6,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
want: icmpDatagram,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 falls back to exec when both unavailable",
|
||||||
|
family: &icmpV6,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
udpErr: errors.New("protocol not supported"),
|
||||||
|
want: icmpExecFallback,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
calls := make([]string, 0, 2)
|
||||||
|
listen := func(network, listenAddr string) (icmpPacketConn, error) {
|
||||||
|
require.Equal(t, tt.family.listenAddr, listenAddr)
|
||||||
|
calls = append(calls, network)
|
||||||
|
switch network {
|
||||||
|
case tt.family.rawNetwork:
|
||||||
|
if tt.rawErr != nil {
|
||||||
|
return nil, tt.rawErr
|
||||||
|
}
|
||||||
|
case tt.family.dgramNetwork:
|
||||||
|
if tt.udpErr != nil {
|
||||||
|
return nil, tt.udpErr
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected network %q", network)
|
||||||
|
}
|
||||||
|
return testICMPPacketConn{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
|
||||||
|
assert.Equal(t, tt.wantNetworks, calls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveICMPTarget(t *testing.T) {
|
||||||
|
t.Run("IPv4 literal", func(t *testing.T) {
|
||||||
|
family, ip := resolveICMPTarget("127.0.0.1")
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.False(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IPv6 literal", func(t *testing.T) {
|
||||||
|
family, ip := resolveICMPTarget("::1")
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.True(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "::1", ip.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
||||||
|
family, ip := resolveICMPTarget("::ffff:127.0.0.1")
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.False(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -20,6 +20,7 @@ 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
|
||||||
@@ -56,7 +57,6 @@ 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
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ 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...
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
32
internal/entities/probe/probe.go
Normal file
32
internal/entities/probe/probe.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
// Config defines a network probe task sent from hub to agent.
|
||||||
|
type Config struct {
|
||||||
|
Target string `cbor:"0,keyasint" json:"target"`
|
||||||
|
Protocol string `cbor:"1,keyasint" json:"protocol"` // "icmp", "tcp", or "http"
|
||||||
|
Port uint16 `cbor:"2,keyasint,omitempty" json:"port,omitempty"`
|
||||||
|
Interval uint16 `cbor:"3,keyasint" json:"interval"` // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result holds aggregated probe results for a single target.
|
||||||
|
//
|
||||||
|
// 0: avg latency in ms
|
||||||
|
//
|
||||||
|
// 1: min latency in ms
|
||||||
|
//
|
||||||
|
// 2: max latency in ms
|
||||||
|
//
|
||||||
|
// 3: packet loss percentage (0-100)
|
||||||
|
type Result []float64
|
||||||
|
|
||||||
|
// Key returns the map key used for this probe config (e.g. "icmp:1.1.1.1", "tcp:host:443", "http:https://example.com").
|
||||||
|
func (c Config) Key() string {
|
||||||
|
switch c.Protocol {
|
||||||
|
case "tcp":
|
||||||
|
return c.Protocol + ":" + c.Target + ":" + strconv.FormatUint(uint64(c.Port), 10)
|
||||||
|
default:
|
||||||
|
return c.Protocol + ":" + c.Target
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -179,4 +180,5 @@ type CombinedData struct {
|
|||||||
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services", "network_probe_stats"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -92,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
view: &systemScopedReadRule,
|
view: &systemScopedReadRule,
|
||||||
create: &systemScopedWriteRule,
|
create: &systemScopedWriteRule,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ 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
|
||||||
@@ -109,6 +110,8 @@ 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")
|
||||||
|
|||||||
54
internal/hub/probes.go
Normal file
54
internal/hub/probes.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindNetworkProbesEvents(h *Hub) {
|
||||||
|
// on create, make sure the id is set to a stable hash
|
||||||
|
h.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
config := &probe.Config{
|
||||||
|
Target: e.Record.GetString("target"),
|
||||||
|
Protocol: e.Record.GetString("protocol"),
|
||||||
|
Port: uint16(e.Record.GetInt("port")),
|
||||||
|
Interval: uint16(e.Record.GetInt("interval")),
|
||||||
|
}
|
||||||
|
key := config.Key()
|
||||||
|
id := systems.MakeStableHashId(systemID, key)
|
||||||
|
e.Record.Set("id", id)
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// sync probe to agent on creation
|
||||||
|
h.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
h.syncProbesToAgent(systemID)
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
// sync probe to agent on delete
|
||||||
|
h.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
h.syncProbesToAgent(systemID)
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
// TODO: if enabled changes, sync to agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncProbesToAgent fetches enabled probes for a system and sends them to the agent.
|
||||||
|
func (h *Hub) syncProbesToAgent(systemID string) {
|
||||||
|
system, err := h.sm.GetSystem(systemID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := h.sm.GetProbeConfigsForSystem(systemID)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := system.SyncNetworkProbes(configs); err != nil {
|
||||||
|
h.Logger().Warn("failed to sync probes to agent", "system", systemID, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +19,7 @@ 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"
|
||||||
@@ -29,6 +31,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,6 +241,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.Probes != nil {
|
||||||
|
if err := updateNetworkProbesRecords(txApp, data.Probes, sys.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
// 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)
|
||||||
@@ -289,7 +298,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
|
||||||
@@ -306,6 +315,84 @@ 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 latency={:latency}, loss={:loss}, updated={:updated} WHERE id={:id}", collectionName)
|
||||||
|
updateQuery = db.NewQuery(sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert network probe stats records
|
||||||
|
switch realtimeActive {
|
||||||
|
case true:
|
||||||
|
collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats")
|
||||||
|
record := core.NewRecord(collection)
|
||||||
|
record.Set("system", systemId)
|
||||||
|
record.Set("stats", data)
|
||||||
|
record.Set("type", "1m")
|
||||||
|
err = app.SaveNoValidate(record)
|
||||||
|
default:
|
||||||
|
if dataJson, e := json.Marshal(data); e == nil {
|
||||||
|
sql := "INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})"
|
||||||
|
insertQuery := db.NewQuery(sql)
|
||||||
|
_, err = insertQuery.Bind(dbx.Params{
|
||||||
|
"system": systemId,
|
||||||
|
"stats": dataJson,
|
||||||
|
"type": "1m",
|
||||||
|
"created": nowString,
|
||||||
|
}).Execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update network_probes records
|
||||||
|
for key := range data {
|
||||||
|
probe := data[key]
|
||||||
|
id := MakeStableHashId(systemId, key)
|
||||||
|
switch realtimeActive {
|
||||||
|
case true:
|
||||||
|
var record *core.Record
|
||||||
|
record, err = app.FindRecordById(collectionName, id)
|
||||||
|
if err == nil {
|
||||||
|
record.Set("latency", probe[0])
|
||||||
|
record.Set("loss", probe[3])
|
||||||
|
err = app.SaveNoValidate(record)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_, err = updateQuery.Bind(dbx.Params{
|
||||||
|
"id": id,
|
||||||
|
"latency": probe[0],
|
||||||
|
"loss": probe[3],
|
||||||
|
"updated": nowString,
|
||||||
|
}).Execute()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", key, "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 {
|
||||||
@@ -540,7 +627,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))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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"
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ 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"
|
||||||
@@ -317,6 +319,17 @@ 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 on connect", "system", systemId, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +342,31 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
||||||
|
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
||||||
|
records, err := sm.hub.FindRecordsByFilter(
|
||||||
|
"network_probes",
|
||||||
|
"system = {:system} && enabled = true",
|
||||||
|
"",
|
||||||
|
0, 0,
|
||||||
|
dbx.Params{"system": systemID},
|
||||||
|
)
|
||||||
|
if err != nil || len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := make([]probe.Config, 0, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
configs = append(configs, probe.Config{
|
||||||
|
Target: r.GetString("target"),
|
||||||
|
Protocol: r.GetString("protocol"),
|
||||||
|
Port: uint16(r.GetInt("port")),
|
||||||
|
Interval: uint16(r.GetInt("interval")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
// 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("")
|
||||||
|
|||||||
57
internal/hub/systems/system_probes.go
Normal file
57
internal/hub/systems/system_probes.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncNetworkProbes sends probe configurations to the agent.
|
||||||
|
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var result string
|
||||||
|
return sys.request(ctx, common.SyncNetworkProbes, configs, &result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchNetworkProbeResults fetches probe results from the agent.
|
||||||
|
// func (sys *System) FetchNetworkProbeResults() (map[string]probe.Result, error) {
|
||||||
|
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
// defer cancel()
|
||||||
|
// var results map[string]probe.Result
|
||||||
|
// err := sys.request(ctx, common.GetNetworkProbeResults, nil, &results)
|
||||||
|
// return results, err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// hasEnabledProbes returns true if this system has any enabled network probes.
|
||||||
|
// func (sys *System) hasEnabledProbes() bool {
|
||||||
|
// count, err := sys.manager.hub.CountRecords("network_probes",
|
||||||
|
// dbx.NewExp("system = {:system} AND enabled = true", dbx.Params{"system": sys.Id}))
|
||||||
|
// return err == nil && count > 0
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fetchAndSaveProbeResults fetches probe results and saves them to the database.
|
||||||
|
// func (sys *System) fetchAndSaveProbeResults() {
|
||||||
|
// hub := sys.manager.hub
|
||||||
|
|
||||||
|
// results, err := sys.FetchNetworkProbeResults()
|
||||||
|
// if err != nil || len(results) == 0 {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// collection, err := hub.FindCachedCollectionByNameOrId("network_probe_stats")
|
||||||
|
// if err != nil {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// record := core.NewRecord(collection)
|
||||||
|
// record.Set("system", sys.Id)
|
||||||
|
// record.Set("stats", results)
|
||||||
|
// record.Set("type", "1m")
|
||||||
|
|
||||||
|
// if err := hub.SaveNoValidate(record); err != nil {
|
||||||
|
// hub.Logger().Warn("failed to save probe stats", "system", sys.Id, "err", err)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// Package utils provides utility functions for the hub.
|
// Package utils provides utility functions for the hub.
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||||
func GetEnv(key string) (value string, exists bool) {
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
@@ -10,3 +14,26 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
}
|
}
|
||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// realtimeActiveForCollection checks if there are active WebSocket subscriptions for the given collection.
|
||||||
|
func RealtimeActiveForCollection(app core.App, collectionName string, validateFn func(filterQuery string) bool) bool {
|
||||||
|
broker := app.SubscriptionsBroker()
|
||||||
|
if broker.TotalClients() == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, client := range broker.Clients() {
|
||||||
|
subs := client.Subscriptions(collectionName)
|
||||||
|
if len(subs) > 0 {
|
||||||
|
if validateFn == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for k := range subs {
|
||||||
|
filter := subs[k].Query["filter"]
|
||||||
|
if validateFn(filter) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1699,6 +1699,223 @@ 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
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
|
|||||||
62
internal/migrations/1776632983_updated_network_probes.go
Normal file
62
internal/migrations/1776632983_updated_network_probes.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("np_probes_001")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add field
|
||||||
|
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number926446584",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "latency",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// add field
|
||||||
|
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3726709001",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "loss",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}`)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("np_probes_001")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.Fields.RemoveById("number926446584")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.Fields.RemoveById("number3726709001")
|
||||||
|
|
||||||
|
return app.Save(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
245
internal/migrations/1_add_network_probes.go
Normal file
245
internal/migrations/1_add_network_probes.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
jsonData := `[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]`
|
||||||
|
|
||||||
|
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
// down: remove the network probe collections
|
||||||
|
if c, err := app.FindCollectionByNameOrId("network_probes"); err == nil {
|
||||||
|
if err := app.Delete(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, err := app.FindCollectionByNameOrId("network_probe_stats"); err == nil {
|
||||||
|
if err := app.Delete(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -70,7 +71,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 := [2]*core.Collection{}
|
collections := [3]*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
|
||||||
@@ -79,6 +80,10 @@ 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()
|
||||||
|
|
||||||
@@ -138,8 +143,9 @@ 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)
|
||||||
@@ -500,6 +506,63 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AverageProbeStats averages probe stats across multiple records.
|
||||||
|
// For each probe key: avg of avgs, min of mins, max of maxes, avg of losses.
|
||||||
|
func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Result {
|
||||||
|
type probeValues struct {
|
||||||
|
sums probe.Result
|
||||||
|
count float64
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}")
|
||||||
|
|
||||||
|
// accumulate sums for each probe key across records
|
||||||
|
sums := make(map[string]*probeValues)
|
||||||
|
var row StatsRecord
|
||||||
|
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))}
|
||||||
|
sums[key] = s
|
||||||
|
}
|
||||||
|
for i := range vals {
|
||||||
|
switch i {
|
||||||
|
case 1: // min fields
|
||||||
|
if s.count == 0 || vals[i] < s.sums[i] {
|
||||||
|
s.sums[i] = vals[i]
|
||||||
|
}
|
||||||
|
case 2: // max fields
|
||||||
|
if vals[i] > s.sums[i] {
|
||||||
|
s.sums[i] = vals[i]
|
||||||
|
}
|
||||||
|
default: // average fields
|
||||||
|
s.sums[i] += vals[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute final averages
|
||||||
|
result := make(map[string]probe.Result, len(sums))
|
||||||
|
for key, s := range sums {
|
||||||
|
if s.count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.sums[0] = twoDecimals(s.sums[0] / s.count) // avg latency
|
||||||
|
s.sums[3] = twoDecimals(s.sums[3] / s.count) // packet loss
|
||||||
|
result[key] = s.sums
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/* 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
|
||||||
|
|||||||
@@ -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 := [2]string{"system_stats", "container_stats"}
|
collections := [3]string{"system_stats", "container_stats", "network_probe_stats"}
|
||||||
|
|
||||||
// Record types and their retention periods
|
// Record types and their retention periods
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function AreaChartDefault({
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ 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)
|
||||||
@@ -62,6 +63,7 @@ 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 })
|
||||||
@@ -105,6 +107,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -145,7 +148,7 @@ export default function LineChartDefault({
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
|
|||||||
@@ -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.rfcEmail(t`Invalid email address.`))
|
const emailSchema = v.pipe(v.string(), v.email(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.`),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
|
NetworkIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -109,6 +110,10 @@ 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"
|
||||||
@@ -180,6 +185,21 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn, decimalString, hourWithSeconds } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
GlobeIcon,
|
||||||
|
TimerIcon,
|
||||||
|
ActivityIcon,
|
||||||
|
WifiOffIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
ArrowLeftRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ClockIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
} 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-34 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={TimerIcon} />,
|
||||||
|
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "latency",
|
||||||
|
accessorFn: (record) => record.latency,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const val = row.original.latency
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loss",
|
||||||
|
accessorFn: (record) => record.loss,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Loss`} Icon={WifiOffIcon} />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { loss, latency } = row.original
|
||||||
|
if (loss === undefined || (!latency && !loss)) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
let color = "bg-green-500"
|
||||||
|
if (loss) {
|
||||||
|
color = loss > 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)} />
|
||||||
|
{loss}%
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
invertSorting: true,
|
||||||
|
accessorFn: (record) => record.updated,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const timestamp = getValue() as number
|
||||||
|
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => null,
|
||||||
|
size: 40,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-10"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
deleteProbe(row.original.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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}
|
||||||
|
{/* <ArrowUpDownIcon className="size-4" /> */}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import {
|
||||||
|
type ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type Row,
|
||||||
|
type SortingState,
|
||||||
|
type Table as TableType,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||||
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
|
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
||||||
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { isReadOnlyUser } from "@/lib/api"
|
||||||
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
|
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
|
||||||
|
import type { NetworkProbeRecord } from "@/types"
|
||||||
|
import { AddProbeDialog } from "./probe-dialog"
|
||||||
|
|
||||||
|
export default function NetworkProbesTableNew({
|
||||||
|
systemId,
|
||||||
|
probes,
|
||||||
|
}: {
|
||||||
|
systemId?: string
|
||||||
|
probes: NetworkProbeRecord[]
|
||||||
|
}) {
|
||||||
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
|
`sort-np-${systemId ? 1 : 0}`,
|
||||||
|
[{ id: systemId ? "name" : "system", desc: false }],
|
||||||
|
sessionStorage
|
||||||
|
)
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
|
const { longestName, longestTarget } = useMemo(() => {
|
||||||
|
let longestName = 0
|
||||||
|
let longestTarget = 0
|
||||||
|
for (const p of probes) {
|
||||||
|
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
|
||||||
|
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
|
||||||
|
}
|
||||||
|
return { longestName, longestTarget }
|
||||||
|
}, [probes])
|
||||||
|
|
||||||
|
// Filter columns based on whether systemId is provided
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
let columns = getProbeColumns(longestName, longestTarget)
|
||||||
|
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
|
columns = isReadOnlyUser() ? columns.filter((col) => col.id !== "actions") : columns
|
||||||
|
return columns
|
||||||
|
}, [systemId, longestName, longestTarget])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: probes,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
defaultColumn: {
|
||||||
|
sortUndefined: "last",
|
||||||
|
size: 900,
|
||||||
|
minSize: 0,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
const probe = row.original
|
||||||
|
const systemName = $allSystemsById.get()[probe.system]?.name ?? ""
|
||||||
|
const searchString = `${probe.name}${probe.target}${probe.protocol}${systemName}`.toLocaleLowerCase()
|
||||||
|
return (filterValue as string)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.every((term) => searchString.includes(term))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = table.getRowModel().rows
|
||||||
|
const visibleColumns = table.getVisibleLeafColumns()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
|
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||||
|
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||||
|
<div className="px-2 sm:px-1">
|
||||||
|
<CardTitle className="mb-2">
|
||||||
|
<Trans>Network Probes</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||||
|
<Trans>ICMP/TCP/HTTP latency monitoring from agents</Trans>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:ms-auto flex items-center gap-2">
|
||||||
|
{probes.length > 0 && (
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="ms-auto px-4 w-full max-w-full md:w-64"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isReadOnlyUser() ? <AddProbeDialog systemId={systemId} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="rounded-md">
|
||||||
|
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NetworkProbesTable = memo(function NetworkProbeTable({
|
||||||
|
table,
|
||||||
|
rows,
|
||||||
|
colLength,
|
||||||
|
}: {
|
||||||
|
table: TableType<NetworkProbeRecord>
|
||||||
|
rows: Row<NetworkProbeRecord>[]
|
||||||
|
colLength: number
|
||||||
|
}) {
|
||||||
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
|
count: rows.length,
|
||||||
|
estimateSize: () => 54,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
overscan: 5,
|
||||||
|
})
|
||||||
|
const virtualRows = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||||
|
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||||
|
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||||
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
|
)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
{/* add header height to table size */}
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||||
|
<table className="text-sm w-full h-full text-nowrap">
|
||||||
|
<NetworkProbeTableHead table={table} />
|
||||||
|
<TableBody>
|
||||||
|
{rows.length ? (
|
||||||
|
virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
return <NetworkProbeTableRow key={row.id} row={row} virtualRow={virtualRow} />
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord> }) {
|
||||||
|
return (
|
||||||
|
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead className="px-2" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
}: {
|
||||||
|
row: Row<NetworkProbeRecord>
|
||||||
|
virtualRow: VirtualItem
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow data-state={row.getIsSelected() && "selected"} className="transition-opacity">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className="py-0"
|
||||||
|
style={{
|
||||||
|
width: `${cell.column.getSize()}px`,
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import { $systems } from "@/lib/stores"
|
||||||
|
|
||||||
|
export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [protocol, setProtocol] = useState<string>("icmp")
|
||||||
|
const [target, setTarget] = useState("")
|
||||||
|
const [port, setPort] = useState("")
|
||||||
|
const [probeInterval, setProbeInterval] = useState("30")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selectedSystemId, setSelectedSystemId] = useState("")
|
||||||
|
const systems = useStore($systems)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { t } = useLingui()
|
||||||
|
const targetName = target.replace(/^https?:\/\//, "")
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setProtocol("icmp")
|
||||||
|
setTarget("")
|
||||||
|
setPort("")
|
||||||
|
setProbeInterval("30")
|
||||||
|
setName("")
|
||||||
|
setSelectedSystemId("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await pb.collection("network_probes").create({
|
||||||
|
system: systemId ?? selectedSystemId,
|
||||||
|
name: name || targetName,
|
||||||
|
target,
|
||||||
|
protocol,
|
||||||
|
port: protocol === "tcp" ? Number(port) : 0,
|
||||||
|
interval: Number(probeInterval),
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
resetForm()
|
||||||
|
setOpen(false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<PlusIcon className="size-4 me-1" />
|
||||||
|
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Configure latency 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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`,
|
||||||
|
|||||||
25
internal/site/src/components/routes/probes.tsx
Normal file
25
internal/site/src/components/routes/probes.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { memo, useEffect } from "react"
|
||||||
|
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
|
||||||
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
|
import { useNetworkProbesData } from "@/lib/use-network-probes"
|
||||||
|
|
||||||
|
export default memo(() => {
|
||||||
|
const { t } = useLingui()
|
||||||
|
const { probes } = useNetworkProbesData({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${t`Network Probes`} / Beszel`
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ActiveAlerts />
|
||||||
|
<NetworkProbesTableNew probes={probes} />
|
||||||
|
</div>
|
||||||
|
<FooterRepoLink />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -24,7 +24,7 @@ interface ShoutrrrUrlCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NotificationSchema = v.object({
|
const NotificationSchema = v.object({
|
||||||
emails: v.array(v.pipe(v.string(), v.rfcEmail())),
|
emails: v.array(v.pipe(v.string(), v.email())),
|
||||||
webhooks: v.array(v.pipe(v.string(), v.url())),
|
webhooks: v.array(v.pipe(v.string(), v.url())),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 } from "./system/lazy-tables"
|
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTable } from "./system/lazy-tables"
|
||||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
import { ContainerIcon, CpuIcon, HardDriveIcon, NetworkIcon, 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", "disk"]
|
const tabs = ["core", "network", "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,6 +145,8 @@ 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} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,6 +159,10 @@ 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>
|
||||||
@@ -184,16 +190,26 @@ 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} />
|
||||||
<MemoryChart {...coreProps} />
|
|
||||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
<MemoryChart {...coreProps} />
|
||||||
|
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
<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") && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { timeTicks } from "d3-time"
|
|
||||||
import { getPbTimestamp, pb } from "@/lib/api"
|
import { getPbTimestamp, pb } from "@/lib/api"
|
||||||
import { chartTimeData } from "@/lib/utils"
|
import { chartTimeData } from "@/lib/utils"
|
||||||
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
|
import type { ChartData, ChartTimes, ContainerStatsRecord, NetworkProbeStatsRecord, SystemStatsRecord } from "@/types"
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -17,27 +16,6 @@ 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 }>(
|
||||||
@@ -66,12 +44,12 @@ export function appendData<T extends { created: string | number | null }>(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkProbeStatsRecord>(
|
||||||
collection: string,
|
collection: string,
|
||||||
systemId: string,
|
systemId: string,
|
||||||
chartTime: ChartTimes
|
chartTime: ChartTimes,
|
||||||
|
cachedStats?: { created: string | number | null }[]
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
|
|
||||||
const lastCached = cachedStats?.at(-1)?.created as number
|
const lastCached = cachedStats?.at(-1)?.created as number
|
||||||
return await pb.collection<T>(collection).getFullList({
|
return await pb.collection<T>(collection).getFullList({
|
||||||
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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"
|
||||||
|
import { probeKey } from "@/lib/use-network-probes"
|
||||||
|
|
||||||
|
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.latency - a.latency)
|
||||||
|
const count = sortedProbes.length
|
||||||
|
const points: DataPoint<NetworkProbeStatsRecord>[] = []
|
||||||
|
const visibleKeys: string[] = []
|
||||||
|
const filterTerms = filter
|
||||||
|
? filter
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.filter((term) => term.length > 0)
|
||||||
|
: []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const p = sortedProbes[i]
|
||||||
|
const key = probeKey(p)
|
||||||
|
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
|
||||||
|
if (filtered) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
visibleKeys.push(key)
|
||||||
|
points.push({
|
||||||
|
order: i,
|
||||||
|
label: p.name || p.target,
|
||||||
|
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[valueIndex] ?? "-",
|
||||||
|
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { dataPoints: points, visibleKeys }
|
||||||
|
}, [probes, filter, valueIndex])
|
||||||
|
|
||||||
|
const filteredProbeStats = useMemo(() => {
|
||||||
|
if (!visibleKeys.length) return probeStats
|
||||||
|
return probeStats.filter((record) => visibleKeys.some((key) => record.stats?.[key] != 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 LatencyChart({ 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`Latency`}
|
||||||
|
description={t`Average round-trip 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={3}
|
||||||
|
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)}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -120,8 +120,7 @@ export function TemperatureChart({
|
|||||||
label: key,
|
label: key,
|
||||||
dataKey: dataKeys[key],
|
dataKey: dataKeys[key],
|
||||||
color: colorMap[key],
|
color: colorMap[key],
|
||||||
strokeOpacity,
|
opacity: strokeOpacity,
|
||||||
activeDot: !filtered,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [sortedKeys, filter, dataKeys, colorMap])
|
}, [sortedKeys, filter, dataKeys, colorMap])
|
||||||
@@ -135,7 +134,7 @@ export function TemperatureChart({
|
|||||||
// label: `Test ${++i}`,
|
// label: `Test ${++i}`,
|
||||||
// dataKey: () => 0,
|
// dataKey: () => 0,
|
||||||
// color: "red",
|
// color: "red",
|
||||||
// strokeOpacity: 1,
|
// opacity: 1,
|
||||||
// })
|
// })
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
@@ -203,7 +202,6 @@ export function TemperatureChart({
|
|||||||
return `${decimalString(value)} ${unit}`
|
return `${decimalString(value)} ${unit}`
|
||||||
}}
|
}}
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
filter={filter}
|
|
||||||
></LineChartDefault>
|
></LineChartDefault>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
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 { LatencyChart, 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"))
|
||||||
|
|
||||||
@@ -34,3 +39,46 @@ 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">
|
||||||
|
<LatencyChart
|
||||||
|
probeStats={probeStats}
|
||||||
|
grid={grid}
|
||||||
|
probes={probes}
|
||||||
|
chartData={chartData}
|
||||||
|
empty={!probeStats.length}
|
||||||
|
/>
|
||||||
|
<LossChart
|
||||||
|
probeStats={probeStats}
|
||||||
|
grid={grid}
|
||||||
|
probes={probes}
|
||||||
|
chartData={chartData}
|
||||||
|
empty={!probeStats.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
|
import { appendData, cache, getStats, makeContainerData, makeContainerPoint } from "./chart-data"
|
||||||
|
|
||||||
export type SystemData = ReturnType<typeof useSystemData>
|
export type SystemData = ReturnType<typeof useSystemData>
|
||||||
|
|
||||||
@@ -151,16 +151,11 @@ export function useSystemData(id: string) {
|
|||||||
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
|
const 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])
|
||||||
@@ -200,8 +195,8 @@ export function useSystemData(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
|
||||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
|
||||||
]).then(([systemStats, containerStats]) => {
|
]).then(([systemStats, containerStats]) => {
|
||||||
// If another request has been made since this one, ignore the results
|
// If another request has been made since this one, ignore the results
|
||||||
if (requestId !== statsRequestId.current) {
|
if (requestId !== statsRequestId.current) {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ 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 { ChartData } from "@/types"
|
import type { ChartTimes } 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
|
||||||
@@ -400,12 +401,22 @@ 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: JSX.Element
|
let cachedAxis: {
|
||||||
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
time: number
|
||||||
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
|
el: JSX.Element
|
||||||
return cachedAxis
|
|
||||||
}
|
}
|
||||||
cachedAxis = (
|
|
||||||
|
const xAxis = (chartTime: ChartTimes, lastCreationTime: number) => {
|
||||||
|
if (Math.abs(lastCreationTime - cachedAxis?.time) < 1000) {
|
||||||
|
return cachedAxis.el
|
||||||
|
}
|
||||||
|
const now = new Date(lastCreationTime + 1000)
|
||||||
|
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||||
|
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||||
|
const domain = [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()]
|
||||||
|
cachedAxis = {
|
||||||
|
time: lastCreationTime,
|
||||||
|
el: (
|
||||||
<RechartsPrimitive.XAxis
|
<RechartsPrimitive.XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
domain={domain}
|
domain={domain}
|
||||||
@@ -418,8 +429,9 @@ const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
tickFormatter={chartTimeData[chartTime].format}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
return cachedAxis
|
}
|
||||||
|
return cachedAxis.el
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
319
internal/site/src/lib/use-network-probes.ts
Normal file
319
internal/site/src/lib/use-network-probes.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
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,latency,loss,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeKey(p: NetworkProbeRecord) {
|
||||||
|
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
||||||
|
return `${p.protocol}:${p.target}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
|
||||||
|
const stats: NetworkProbeStatsRecord["stats"] = {}
|
||||||
|
for (const probe of probes) {
|
||||||
|
const key = probeKey(probe)
|
||||||
|
stats[key] = [probe.latency, 0, 0, probe.loss]
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ 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"))
|
||||||
|
|
||||||
@@ -79,6 +80,8 @@ 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 />
|
||||||
}
|
}
|
||||||
|
|||||||
34
internal/site/src/types.d.ts
vendored
34
internal/site/src/types.d.ts
vendored
@@ -316,8 +316,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,3 +544,35 @@ 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
|
||||||
|
latency: number
|
||||||
|
loss: number
|
||||||
|
interval: number
|
||||||
|
enabled: boolean
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0: avg latency in ms
|
||||||
|
*
|
||||||
|
* 1: min latency in ms
|
||||||
|
*
|
||||||
|
* 2: max latency in ms
|
||||||
|
*
|
||||||
|
* 3: packet loss in %
|
||||||
|
*/
|
||||||
|
type ProbeResult = number[]
|
||||||
|
|
||||||
|
export interface NetworkProbeStatsRecord {
|
||||||
|
id?: string
|
||||||
|
type?: string
|
||||||
|
stats: Record<string, ProbeResult>
|
||||||
|
created: number // unix timestamp (ms) for Recharts xAxis
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user