mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 04:01:50 +02:00
Compare commits
1 Commits
temp-probe
...
l10n_main_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b2da9fd4 |
@@ -48,7 +48,6 @@ type Agent struct {
|
|||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
smartManager *SmartManager // Manages SMART data
|
||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
probeManager *ProbeManager // Manages network probes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -122,9 +121,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize handler registry
|
// initialize handler registry
|
||||||
agent.handlerRegistry = NewHandlerRegistry()
|
agent.handlerRegistry = NewHandlerRegistry()
|
||||||
|
|
||||||
// initialize probe manager
|
|
||||||
agent.probeManager = newProbeManager()
|
|
||||||
|
|
||||||
// initialize disk info
|
// initialize disk info
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
|
|
||||||
@@ -182,11 +178,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.probeManager != nil {
|
|
||||||
data.Probes = a.probeManager.GetResults()
|
|
||||||
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())
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
"github.com/henrygd/beszel/agent/health"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,37 +111,13 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
_ = health.Update()
|
_ = health.Update()
|
||||||
case <-sigCtx.Done():
|
case <-sigCtx.Done():
|
||||||
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
||||||
return c.stop()
|
_ = c.agent.StopServer()
|
||||||
|
c.closeWebSocket()
|
||||||
|
return health.CleanUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
|
|
||||||
//
|
|
||||||
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
|
|
||||||
//
|
|
||||||
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|
||||||
// ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
// c.cancel = cancel
|
|
||||||
//
|
|
||||||
// for {
|
|
||||||
// select {
|
|
||||||
// case <-ctx.Done():
|
|
||||||
// return c.stop()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (c *ConnectionManager) Stop() {
|
|
||||||
// c.cancel()
|
|
||||||
// }
|
|
||||||
func (c *ConnectionManager) stop() error {
|
|
||||||
_ = c.agent.StopServer()
|
|
||||||
c.agent.probeManager.Stop()
|
|
||||||
c.closeWebSocket()
|
|
||||||
return health.CleanUp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleEvent processes connection events and updates the connection state accordingly.
|
// handleEvent processes connection events and updates the connection state accordingly.
|
||||||
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||||
switch event {
|
switch event {
|
||||||
@@ -213,16 +185,9 @@ func (c *ConnectionManager) connect() {
|
|||||||
|
|
||||||
// Try WebSocket first, if it fails, start SSH server
|
// Try WebSocket first, if it fails, start SSH server
|
||||||
err := c.startWebSocketConnection()
|
err := c.startWebSocketConnection()
|
||||||
if err != nil {
|
if err != nil && c.State == Disconnected {
|
||||||
if shouldExitOnErr(err) {
|
c.startSSHServer()
|
||||||
time.Sleep(2 * time.Second) // prevent tight restart loop
|
c.startWsTicker()
|
||||||
_ = c.stop()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if c.State == Disconnected {
|
|
||||||
c.startSSHServer()
|
|
||||||
c.startWsTicker()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,14 +224,3 @@ func (c *ConnectionManager) closeWebSocket() {
|
|||||||
c.wsClient.Close()
|
c.wsClient.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
|
|
||||||
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
|
|
||||||
func shouldExitOnErr(err error) bool {
|
|
||||||
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
|
|
||||||
if opErr, ok := errors.AsType[*net.OpError](err); ok {
|
|
||||||
return strings.Contains(opErr.Err.Error(), "lookup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -299,65 +298,3 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
|
|||||||
cm.connect()
|
cm.connect()
|
||||||
}, "Connect should not panic without WebSocket client")
|
}, "Connect should not panic without WebSocket client")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldExitOnErr(t *testing.T) {
|
|
||||||
createDialErr := func(msg string) error {
|
|
||||||
return &net.OpError{
|
|
||||||
Op: "dial",
|
|
||||||
Net: "tcp",
|
|
||||||
Err: errors.New(msg),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
envValue string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no env var",
|
|
||||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
|
||||||
envValue: "",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var false",
|
|
||||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
|
||||||
envValue: "false",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, matching error",
|
|
||||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, matching error with extra context",
|
|
||||||
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, non-matching error",
|
|
||||||
err: errors.New("connection refused"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "env var true, dial but not lookup",
|
|
||||||
err: createDialErr("connection timeout"),
|
|
||||||
envValue: "true",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
|
|
||||||
result := shouldExitOnErr(tt.err)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -52,7 +51,6 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
registry.Register(common.SyncNetworkProbes, &SyncNetworkProbesHandler{})
|
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -205,19 +203,3 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|||||||
|
|
||||||
return hctx.SendResponse(details, hctx.RequestID)
|
return hctx.SendResponse(details, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// SyncNetworkProbesHandler handles probe configuration sync from hub
|
|
||||||
type SyncNetworkProbesHandler struct{}
|
|
||||||
|
|
||||||
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
var 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
237
agent/probe.go
@@ -1,237 +0,0 @@
|
|||||||
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 60s window.
|
|
||||||
func (pm *ProbeManager) GetResults() map[string]probe.Result {
|
|
||||||
pm.mu.RLock()
|
|
||||||
defer pm.mu.RUnlock()
|
|
||||||
|
|
||||||
results := make(map[string]probe.Result, len(pm.probes))
|
|
||||||
cutoff := time.Now().Add(-60 * time.Second)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/net/icmp"
|
|
||||||
"golang.org/x/net/ipv4"
|
|
||||||
"golang.org/x/net/ipv6"
|
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
|
||||||
|
|
||||||
type icmpPacketConn interface {
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
|
||||||
// all native methods fail, the choice is cached so subsequent probes skip
|
|
||||||
// the trial-and-error overhead.
|
|
||||||
type icmpMethod int
|
|
||||||
|
|
||||||
const (
|
|
||||||
icmpUntried icmpMethod = iota // haven't tried yet
|
|
||||||
icmpRaw // privileged raw socket
|
|
||||||
icmpDatagram // unprivileged datagram socket
|
|
||||||
icmpExecFallback // shell out to system ping command
|
|
||||||
)
|
|
||||||
|
|
||||||
// icmpFamily holds the network parameters and cached detection result for one address family.
|
|
||||||
type icmpFamily struct {
|
|
||||||
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
|
||||||
dgramNetwork string // e.g. "udp4" or "udp6"
|
|
||||||
listenAddr string // "0.0.0.0" or "::"
|
|
||||||
echoType icmp.Type // outgoing echo request type
|
|
||||||
replyType icmp.Type // expected echo reply type
|
|
||||||
proto int // IANA protocol number for parsing replies
|
|
||||||
isIPv6 bool
|
|
||||||
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
icmpV4 = icmpFamily{
|
|
||||||
rawNetwork: "ip4:icmp",
|
|
||||||
dgramNetwork: "udp4",
|
|
||||||
listenAddr: "0.0.0.0",
|
|
||||||
echoType: ipv4.ICMPTypeEcho,
|
|
||||||
replyType: ipv4.ICMPTypeEchoReply,
|
|
||||||
proto: 1,
|
|
||||||
}
|
|
||||||
icmpV6 = icmpFamily{
|
|
||||||
rawNetwork: "ip6:ipv6-icmp",
|
|
||||||
dgramNetwork: "udp6",
|
|
||||||
listenAddr: "::",
|
|
||||||
echoType: ipv6.ICMPTypeEchoRequest,
|
|
||||||
replyType: ipv6.ICMPTypeEchoReply,
|
|
||||||
proto: 58,
|
|
||||||
isIPv6: true,
|
|
||||||
}
|
|
||||||
icmpModeMu sync.Mutex
|
|
||||||
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
|
||||||
return icmp.ListenPacket(network, listenAddr)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// probeICMP sends an ICMP echo request and measures round-trip 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
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testICMPPacketConn struct{}
|
|
||||||
|
|
||||||
func (testICMPPacketConn) Close() error { return nil }
|
|
||||||
|
|
||||||
func TestDetectICMPMode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
family *icmpFamily
|
|
||||||
rawErr error
|
|
||||||
udpErr error
|
|
||||||
want icmpMethod
|
|
||||||
wantNetworks []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "IPv4 prefers raw socket when available",
|
|
||||||
family: &icmpV4,
|
|
||||||
want: icmpRaw,
|
|
||||||
wantNetworks: []string{"ip4:icmp"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv4 uses datagram when raw unavailable",
|
|
||||||
family: &icmpV4,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
want: icmpDatagram,
|
|
||||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv4 falls back to exec when both unavailable",
|
|
||||||
family: &icmpV4,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
udpErr: errors.New("protocol not supported"),
|
|
||||||
want: icmpExecFallback,
|
|
||||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 prefers raw socket when available",
|
|
||||||
family: &icmpV6,
|
|
||||||
want: icmpRaw,
|
|
||||||
wantNetworks: []string{"ip6:ipv6-icmp"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 uses datagram when raw unavailable",
|
|
||||||
family: &icmpV6,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
want: icmpDatagram,
|
|
||||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 falls back to exec when both unavailable",
|
|
||||||
family: &icmpV6,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
udpErr: errors.New("protocol not supported"),
|
|
||||||
want: icmpExecFallback,
|
|
||||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
calls := make([]string, 0, 2)
|
|
||||||
listen := func(network, listenAddr string) (icmpPacketConn, error) {
|
|
||||||
require.Equal(t, tt.family.listenAddr, listenAddr)
|
|
||||||
calls = append(calls, network)
|
|
||||||
switch network {
|
|
||||||
case tt.family.rawNetwork:
|
|
||||||
if tt.rawErr != nil {
|
|
||||||
return nil, tt.rawErr
|
|
||||||
}
|
|
||||||
case tt.family.dgramNetwork:
|
|
||||||
if tt.udpErr != nil {
|
|
||||||
return nil, tt.udpErr
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected network %q", network)
|
|
||||||
}
|
|
||||||
return testICMPPacketConn{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
|
|
||||||
assert.Equal(t, tt.wantNetworks, calls)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveICMPTarget(t *testing.T) {
|
|
||||||
t.Run("IPv4 literal", func(t *testing.T) {
|
|
||||||
family, ip := resolveICMPTarget("127.0.0.1")
|
|
||||||
require.NotNil(t, family)
|
|
||||||
assert.False(t, family.isIPv6)
|
|
||||||
assert.Equal(t, "127.0.0.1", ip.String())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("IPv6 literal", func(t *testing.T) {
|
|
||||||
family, ip := resolveICMPTarget("::1")
|
|
||||||
require.NotNil(t, family)
|
|
||||||
assert.True(t, family.isIPv6)
|
|
||||||
assert.Equal(t, "::1", ip.String())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
|
||||||
family, ip := resolveICMPTarget("::ffff:127.0.0.1")
|
|
||||||
require.NotNil(t, family)
|
|
||||||
assert.False(t, family.isIPv6)
|
|
||||||
assert.Equal(t, "127.0.0.1", ip.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
2
go.mod
2
go.mod
@@ -20,7 +20,6 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||||
golang.org/x/net v0.52.0
|
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
howett.net/plist v1.0.1
|
howett.net/plist v1.0.1
|
||||||
@@ -57,6 +56,7 @@ require (
|
|||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.38.0 // indirect
|
golang.org/x/image v0.38.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/term v0.41.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
|
|||||||
@@ -195,6 +195,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := a.Start(serverConfig); err != nil {
|
if err := a.Start(serverConfig); err != nil {
|
||||||
log.Fatal("Failed to start: ", err)
|
log.Fatal("Failed to start server: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ const (
|
|||||||
GetSmartData
|
GetSmartData
|
||||||
// Request detailed systemd service info from agent
|
// Request detailed systemd service info from agent
|
||||||
GetSystemdInfo
|
GetSystemdInfo
|
||||||
// Sync network probe configuration to agent
|
|
||||||
SyncNetworkProbes
|
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -175,10 +174,9 @@ type Details struct {
|
|||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
type CombinedData struct {
|
type CombinedData struct {
|
||||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||||
Info Info `json:"info" cbor:"1,keyasint"`
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||||
Probes map[string]probe.Result `cbor:"5,keyasint,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services", "network_probe_stats"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -92,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
view: &systemScopedReadRule,
|
view: &systemScopedReadRule,
|
||||||
create: &systemScopedWriteRule,
|
create: &systemScopedWriteRule,
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ func (h *Hub) StartHub() error {
|
|||||||
}
|
}
|
||||||
// register middlewares
|
// register middlewares
|
||||||
h.registerMiddlewares(e)
|
h.registerMiddlewares(e)
|
||||||
// bind events that aren't set up in different
|
|
||||||
// register api routes
|
// register api routes
|
||||||
if err := h.registerApiRoutes(e); err != nil {
|
if err := h.registerApiRoutes(e); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,8 +109,6 @@ func (h *Hub) StartHub() error {
|
|||||||
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||||
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||||
|
|
||||||
bindNetworkProbesEvents(h)
|
|
||||||
|
|
||||||
pb, ok := h.App.(*pocketbase.PocketBase)
|
pb, ok := h.App.(*pocketbase.PocketBase)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("not a pocketbase app")
|
return errors.New("not a pocketbase app")
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"log/slog"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,7 +18,6 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
@@ -31,7 +29,6 @@ import (
|
|||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,12 +238,6 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.Probes != nil {
|
|
||||||
if err := updateNetworkProbesRecords(txApp, data.Probes, sys.Id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
systemRecord.Set("status", up)
|
systemRecord.Set("status", up)
|
||||||
systemRecord.Set("info", data.Info)
|
systemRecord.Set("info", data.Info)
|
||||||
@@ -298,7 +289,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
for i, service := range data {
|
for i, service := range data {
|
||||||
suffix := fmt.Sprintf("%d", i)
|
suffix := fmt.Sprintf("%d", i)
|
||||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||||
params["id"+suffix] = MakeStableHashId(systemId, service.Name)
|
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
||||||
params["name"+suffix] = service.Name
|
params["name"+suffix] = service.Name
|
||||||
params["state"+suffix] = service.State
|
params["state"+suffix] = service.State
|
||||||
params["sub"+suffix] = service.Sub
|
params["sub"+suffix] = service.Sub
|
||||||
@@ -315,84 +306,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, systemId string) error {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
collectionName := "network_probes"
|
|
||||||
|
|
||||||
// If realtime updates are active, we save via PocketBase records to trigger realtime events.
|
|
||||||
// Otherwise we can do a more efficient direct update via SQL
|
|
||||||
realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool {
|
|
||||||
slog.Info("Checking realtime subscription filter for network probes", "filterQuery", filterQuery)
|
|
||||||
return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId)
|
|
||||||
})
|
|
||||||
|
|
||||||
var db dbx.Builder
|
|
||||||
var nowString string
|
|
||||||
var updateQuery *dbx.Query
|
|
||||||
if !realtimeActive {
|
|
||||||
db = app.DB()
|
|
||||||
nowString = time.Now().UTC().Format(types.DefaultDateLayout)
|
|
||||||
sql := fmt.Sprintf("UPDATE %s SET 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 {
|
||||||
@@ -627,7 +540,7 @@ func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error)
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakeStableHashId(strings ...string) string {
|
func makeStableHashId(strings ...string) string {
|
||||||
hash := fnv.New32a()
|
hash := fnv.New32a()
|
||||||
for _, str := range strings {
|
for _, str := range strings {
|
||||||
hash.Write([]byte(str))
|
hash.Write([]byte(str))
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ import (
|
|||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -319,17 +317,6 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
if err := sm.AddRecord(systemRecord, system); err != nil {
|
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync network probes to the newly connected agent
|
|
||||||
go func() {
|
|
||||||
configs := sm.GetProbeConfigsForSystem(systemId)
|
|
||||||
if len(configs) > 0 {
|
|
||||||
if err := system.SyncNetworkProbes(configs); err != nil {
|
|
||||||
sm.hub.Logger().Warn("failed to sync probes on connect", "system", systemId, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,31 +329,6 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
|
||||||
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
|
||||||
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("")
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SyncNetworkProbes sends probe configurations to the agent.
|
|
||||||
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
|
||||||
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,11 +1,7 @@
|
|||||||
// Package utils provides utility functions for the hub.
|
// Package utils provides utility functions for the hub.
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import "os"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||||
func GetEnv(key string) (value string, exists bool) {
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
@@ -14,26 +10,3 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
}
|
}
|
||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// realtimeActiveForCollection checks if there are active WebSocket subscriptions for the given collection.
|
|
||||||
func RealtimeActiveForCollection(app core.App, collectionName string, validateFn func(filterQuery string) bool) bool {
|
|
||||||
broker := app.SubscriptionsBroker()
|
|
||||||
if broker.TotalClients() == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, client := range broker.Clients() {
|
|
||||||
subs := client.Subscriptions(collectionName)
|
|
||||||
if len(subs) > 0 {
|
|
||||||
if validateFn == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for k := range subs {
|
|
||||||
filter := subs[k].Query["filter"]
|
|
||||||
if validateFn(filter) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1699,223 +1699,6 @@ func init() {
|
|||||||
"type": "base",
|
"type": "base",
|
||||||
"updateRule": null,
|
"updateRule": null,
|
||||||
"viewRule": null
|
"viewRule": null
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "np_probes_001",
|
|
||||||
"listRule": null,
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"name": "network_probes",
|
|
||||||
"type": "base",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 15,
|
|
||||||
"min": 15,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_system",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_name",
|
|
||||||
"max": 200,
|
|
||||||
"min": 0,
|
|
||||||
"name": "name",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_target",
|
|
||||||
"max": 500,
|
|
||||||
"min": 1,
|
|
||||||
"name": "target",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_protocol",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"name": "protocol",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "select",
|
|
||||||
"values": ["icmp", "tcp", "http"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_port",
|
|
||||||
"max": 65535,
|
|
||||||
"min": 0,
|
|
||||||
"name": "port",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_interval",
|
|
||||||
"max": 3600,
|
|
||||||
"min": 1,
|
|
||||||
"name": "interval",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_enabled",
|
|
||||||
"name": "enabled",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "bool"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate2990389176",
|
|
||||||
"name": "created",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": false,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate3332085495",
|
|
||||||
"name": "updated",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `enabled` + "`" + `\n)"
|
|
||||||
],
|
|
||||||
"system": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "np_stats_001",
|
|
||||||
"listRule": null,
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"name": "network_probe_stats",
|
|
||||||
"type": "base",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 15,
|
|
||||||
"min": 15,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "nps_system",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "nps_stats",
|
|
||||||
"maxSize": 2000000,
|
|
||||||
"name": "stats",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "nps_type",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"name": "type",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "select",
|
|
||||||
"values": ["1m", "10m", "20m", "120m", "480m"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate2990389176",
|
|
||||||
"name": "created",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": false,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate3332085495",
|
|
||||||
"name": "updated",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
|
||||||
],
|
|
||||||
"system": false
|
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -71,7 +70,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
// wrap the operations in a transaction
|
// wrap the operations in a transaction
|
||||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
var err error
|
var err error
|
||||||
collections := [3]*core.Collection{}
|
collections := [2]*core.Collection{}
|
||||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -80,10 +79,6 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
collections[2], err = txApp.FindCachedCollectionByNameOrId("network_probe_stats")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var systems RecordIds
|
var systems RecordIds
|
||||||
db := txApp.DB()
|
db := txApp.DB()
|
||||||
|
|
||||||
@@ -143,9 +138,8 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
case "system_stats":
|
case "system_stats":
|
||||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||||
case "container_stats":
|
case "container_stats":
|
||||||
|
|
||||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||||
case "network_probe_stats":
|
|
||||||
longerRecord.Set("stats", rm.AverageProbeStats(db, recordIds))
|
|
||||||
}
|
}
|
||||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||||
log.Println("failed to save longer record", "err", err)
|
log.Println("failed to save longer record", "err", err)
|
||||||
@@ -506,63 +500,6 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// AverageProbeStats averages probe stats across multiple records.
|
|
||||||
// For each probe key: avg of 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 := [3]string{"system_stats", "container_stats", "network_probe_stats"}
|
collections := [2]string{"system_stats", "container_stats"}
|
||||||
|
|
||||||
// Record types and their retention periods
|
// Record types and their retention periods
|
||||||
type RecordDeletionData struct {
|
type RecordDeletionData struct {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export default function LineChartDefault({
|
|||||||
filter,
|
filter,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
chartProps,
|
chartProps,
|
||||||
connectNulls,
|
|
||||||
}: {
|
}: {
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
@@ -63,7 +62,6 @@ export default function LineChartDefault({
|
|||||||
filter?: string
|
filter?: string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||||
connectNulls?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
@@ -106,8 +104,7 @@ export default function LineChartDefault({
|
|||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
// stackId={dataPoint.stackId}
|
// stackId={dataPoint.stackId}
|
||||||
order={dataPoint.order || i}
|
order={dataPoint.order || i}
|
||||||
activeDot={dataPoint.activeDot ?? true}
|
// activeDot={dataPoint.activeDot ?? true}
|
||||||
connectNulls={connectNulls}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
NetworkIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -110,10 +109,6 @@ export default function Navbar() {
|
|||||||
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||||
<span>S.M.A.R.T.</span>
|
<span>S.M.A.R.T.</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "probes"))} className="flex items-center">
|
|
||||||
<NetworkIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
|
||||||
<Trans>Network Probes</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
|
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
@@ -185,21 +180,6 @@ export default function Navbar() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Link
|
|
||||||
href={getPagePath($router, "probes")}
|
|
||||||
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
|
||||||
aria-label="Network Probes"
|
|
||||||
onMouseEnter={() => import("@/components/routes/probes")}
|
|
||||||
>
|
|
||||||
<NetworkIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
|
||||||
</Link>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<Trans>Network Probes</Trans>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
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 === undefined) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
|
||||||
}
|
|
||||||
let color = "bg-green-500"
|
|
||||||
if (!val || 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 val = row.original.loss
|
|
||||||
if (val === undefined) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
|
||||||
}
|
|
||||||
let color = "bg-green-500"
|
|
||||||
if (val > 0) {
|
|
||||||
color = val > 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)} />
|
|
||||||
{val}%
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
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, useEffect, 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, pb } 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"
|
|
||||||
|
|
||||||
const NETWORK_PROBE_FIELDS = "id,name,system,target,protocol,port,interval,latency,loss,enabled,updated"
|
|
||||||
|
|
||||||
export default function NetworkProbesTableNew({
|
|
||||||
systemId,
|
|
||||||
probes,
|
|
||||||
setProbes,
|
|
||||||
}: {
|
|
||||||
systemId?: string
|
|
||||||
probes: NetworkProbeRecord[]
|
|
||||||
setProbes: React.Dispatch<React.SetStateAction<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("")
|
|
||||||
|
|
||||||
// clear old data when systemId changes
|
|
||||||
useEffect(() => {
|
|
||||||
return setProbes([])
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function fetchData(systemId?: string) {
|
|
||||||
pb.collection<NetworkProbeRecord>("network_probes")
|
|
||||||
.getList(0, 2000, {
|
|
||||||
fields: NETWORK_PROBE_FIELDS,
|
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
|
||||||
})
|
|
||||||
.then((res) => setProbes(res.items))
|
|
||||||
}
|
|
||||||
|
|
||||||
// initial load
|
|
||||||
fetchData(systemId)
|
|
||||||
|
|
||||||
// if no systemId, pull after every system update
|
|
||||||
// if (!systemId) {
|
|
||||||
// return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
|
||||||
// // exclude initial load of systems
|
|
||||||
// if (Date.now() - loadTime > 500) {
|
|
||||||
// fetchData(systemId)
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if systemId, fetch after the system is updated
|
|
||||||
// return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
|
||||||
// fetchData(systemId)
|
|
||||||
// })
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
// Subscribe to updates
|
|
||||||
useEffect(() => {
|
|
||||||
let unsubscribe: (() => void) | undefined
|
|
||||||
const pbOptions = systemId
|
|
||||||
? { fields: NETWORK_PROBE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
|
||||||
: { fields: NETWORK_PROBE_FIELDS }
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
unsubscribe = await pb.collection<NetworkProbeRecord>("network_probes").subscribe(
|
|
||||||
"*",
|
|
||||||
(event) => {
|
|
||||||
const record = event.record
|
|
||||||
setProbes((currentProbes) => {
|
|
||||||
const probes = currentProbes ?? []
|
|
||||||
const matchesSystemScope = !systemId || record.system === systemId
|
|
||||||
|
|
||||||
if (event.action === "delete") {
|
|
||||||
return probes.filter((device) => device.id !== record.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchesSystemScope) {
|
|
||||||
// Record moved out of scope; ensure it disappears locally.
|
|
||||||
return probes.filter((device) => device.id !== record.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIndex = probes.findIndex((device) => device.id === record.id)
|
|
||||||
if (existingIndex === -1) {
|
|
||||||
return [record, ...probes]
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = [...probes]
|
|
||||||
next[existingIndex] = record
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
},
|
|
||||||
pbOptions
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to subscribe to SMART device updates:", error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe?.()
|
|
||||||
}
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
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 this agent</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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
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("60")
|
|
||||||
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("60")
|
|
||||||
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>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Configure ICMP, TCP, or HTTP 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,7 +4,6 @@ const routes = {
|
|||||||
home: "/",
|
home: "/",
|
||||||
containers: "/containers",
|
containers: "/containers",
|
||||||
smart: "/smart",
|
smart: "/smart",
|
||||||
probes: "/probes",
|
|
||||||
system: `/system/:id`,
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { memo, useEffect, useState } 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 type { NetworkProbeRecord } from "@/types"
|
|
||||||
|
|
||||||
export default memo(() => {
|
|
||||||
const { t } = useLingui()
|
|
||||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${t`Network Probes`} / Beszel`
|
|
||||||
}, [t])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<ActiveAlerts />
|
|
||||||
<NetworkProbesTableNew probes={probes} setProbes={setProbes} />
|
|
||||||
</div>
|
|
||||||
<FooterRepoLink />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -11,7 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
|||||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTableNew } from "./system/lazy-tables"
|
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
||||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||||
import { GpuIcon } from "../ui/icons"
|
import { GpuIcon } from "../ui/icons"
|
||||||
@@ -28,7 +28,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
system,
|
system,
|
||||||
systemStats,
|
systemStats,
|
||||||
containerData,
|
containerData,
|
||||||
probeStats,
|
|
||||||
chartData,
|
chartData,
|
||||||
containerChartConfigs,
|
containerChartConfigs,
|
||||||
details,
|
details,
|
||||||
@@ -146,10 +145,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
|
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
|
||||||
|
|
||||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||||
|
|
||||||
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
|
|
||||||
|
|
||||||
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,8 +192,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||||
</div>
|
</div>
|
||||||
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
|
|
||||||
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
import { getPbTimestamp, pb } from "@/lib/api"
|
import { getPbTimestamp, pb } from "@/lib/api"
|
||||||
import { chartTimeData } from "@/lib/utils"
|
import { chartTimeData } from "@/lib/utils"
|
||||||
import type { ChartData, ChartTimes, ContainerStatsRecord, NetworkProbeStatsRecord, SystemStatsRecord } from "@/types"
|
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -66,12 +66,12 @@ export function appendData<T extends { created: string | number | null }>(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkProbeStatsRecord>(
|
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||||
collection: string,
|
collection: string,
|
||||||
systemId: string,
|
systemId: string,
|
||||||
chartTime: ChartTimes,
|
chartTime: ChartTimes
|
||||||
cachedStats?: { created: string | number | null }[]
|
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
|
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
|
||||||
const lastCached = cachedStats?.at(-1)?.created as number
|
const lastCached = cachedStats?.at(-1)?.created as number
|
||||||
return await pb.collection<T>(collection).getFullList({
|
return await pb.collection<T>(collection).getFullList({
|
||||||
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import LineChartDefault, { DataPoint } from "@/components/charts/line-chart"
|
|
||||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
|
||||||
import { toFixedFloat, decimalString } from "@/lib/utils"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { ChartCard, FilterBar } from "../chart-card"
|
|
||||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
|
||||||
import { useMemo } from "react"
|
|
||||||
import { atom } from "nanostores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
|
|
||||||
function probeKey(p: NetworkProbeRecord) {
|
|
||||||
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
|
||||||
return `${p.protocol}:${p.target}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const $filter = atom("")
|
|
||||||
|
|
||||||
export function LatencyChart({
|
|
||||||
probeStats,
|
|
||||||
grid,
|
|
||||||
probes,
|
|
||||||
chartData,
|
|
||||||
empty,
|
|
||||||
}: {
|
|
||||||
probeStats: NetworkProbeStatsRecord[]
|
|
||||||
grid?: boolean
|
|
||||||
probes: NetworkProbeRecord[]
|
|
||||||
chartData: ChartData
|
|
||||||
empty: boolean
|
|
||||||
}) {
|
|
||||||
const { t } = useLingui()
|
|
||||||
const filter = useStore($filter)
|
|
||||||
|
|
||||||
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
|
|
||||||
const count = probes.length
|
|
||||||
return probes
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((p, i) => {
|
|
||||||
const key = probeKey(p)
|
|
||||||
const filterTerms = filter
|
|
||||||
? filter
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.filter((term) => term.length > 0)
|
|
||||||
: []
|
|
||||||
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
|
|
||||||
const strokeOpacity = filtered ? 0.1 : 1
|
|
||||||
return {
|
|
||||||
label: p.name || p.target,
|
|
||||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null,
|
|
||||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
|
||||||
strokeOpacity,
|
|
||||||
activeDot: !filtered,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [probes, filter])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartCard
|
|
||||||
legend
|
|
||||||
cornerEl={<FilterBar store={$filter} />}
|
|
||||||
empty={empty}
|
|
||||||
title={t`Latency`}
|
|
||||||
description={t`Average round-trip time (ms)`}
|
|
||||||
grid={grid}
|
|
||||||
>
|
|
||||||
<LineChartDefault
|
|
||||||
chartData={chartData}
|
|
||||||
customData={probeStats}
|
|
||||||
dataPoints={dataPoints}
|
|
||||||
domain={pinnedAxisDomain()}
|
|
||||||
connectNulls
|
|
||||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
|
||||||
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
|
|
||||||
legend
|
|
||||||
filter={filter}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import { lazy, useEffect, useRef, useState } from "react"
|
import { lazy } from "react"
|
||||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
|
||||||
import { LatencyChart } from "./charts/probes-charts"
|
|
||||||
import { SystemData } from "./use-system-data"
|
|
||||||
import { $chartTime } from "@/lib/stores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import system from "../system"
|
|
||||||
import { getStats, appendData } from "./chart-data"
|
|
||||||
|
|
||||||
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
||||||
|
|
||||||
@@ -41,100 +34,3 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetworkProbesTableNew = lazy(() => import("@/components/network-probes-table/network-probes-table"))
|
|
||||||
|
|
||||||
const cache = new Map<string, any>()
|
|
||||||
|
|
||||||
export function LazyNetworkProbesTableNew({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
|
|
||||||
const { grid, chartData } = systemData ?? {}
|
|
||||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
|
||||||
const { isIntersecting, ref } = useIntersectionObserver()
|
|
||||||
|
|
||||||
const statsRequestId = useRef(0)
|
|
||||||
|
|
||||||
// get stats when system "changes." (Not just system to system,
|
|
||||||
// also when new info comes in via systemManager realtime connection, indicating an update)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!systemId || !chartTime || chartTime === "1m") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { expectedInterval } = chartTimeData[chartTime]
|
|
||||||
const ss_cache_key = `${systemId}${chartTime}`
|
|
||||||
const requestId = ++statsRequestId.current
|
|
||||||
|
|
||||||
const cachedProbeStats = cache.get(ss_cache_key) as NetworkProbeStatsRecord[] | undefined
|
|
||||||
|
|
||||||
// Render from cache immediately if available
|
|
||||||
// if (cachedProbeStats?.length) {
|
|
||||||
// setProbeStats(cachedProbeStats)
|
|
||||||
|
|
||||||
// // Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
|
||||||
// const lastCreated = cachedProbeStats.at(-1)?.created as number | undefined
|
|
||||||
// if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
getStats<NetworkProbeStatsRecord>("network_probe_stats", systemId, chartTime, cachedProbeStats).then(
|
|
||||||
(probeStats) => {
|
|
||||||
// If another request has been made since this one, ignore the results
|
|
||||||
if (requestId !== statsRequestId.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// make new system stats
|
|
||||||
let probeStatsData = (cache.get(ss_cache_key) || []) as NetworkProbeStatsRecord[]
|
|
||||||
if (probeStats.length) {
|
|
||||||
probeStatsData = appendData(probeStatsData, probeStats, expectedInterval, 100)
|
|
||||||
cache.set(ss_cache_key, probeStatsData)
|
|
||||||
}
|
|
||||||
setProbeStats(probeStatsData)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}, [system, chartTime, probes])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
|
||||||
{isIntersecting && (
|
|
||||||
<>
|
|
||||||
<NetworkProbesTableNew systemId={systemId} probes={probes} setProbes={setProbes} />
|
|
||||||
{!!chartData && (
|
|
||||||
<LatencyChart
|
|
||||||
probeStats={probeStats}
|
|
||||||
grid={grid}
|
|
||||||
probes={probes}
|
|
||||||
chartData={chartData}
|
|
||||||
empty={!probeStats.length}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const NetworkProbesTable = lazy(() => import("@/components/routes/system/network-probes"))
|
|
||||||
|
|
||||||
export function LazyNetworkProbesTable({
|
|
||||||
system,
|
|
||||||
chartData,
|
|
||||||
grid,
|
|
||||||
probeStats,
|
|
||||||
}: {
|
|
||||||
system: any
|
|
||||||
chartData: any
|
|
||||||
grid: any
|
|
||||||
probeStats: any
|
|
||||||
}) {
|
|
||||||
const { isIntersecting, ref } = useIntersectionObserver()
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
|
||||||
{isIntersecting && (
|
|
||||||
<NetworkProbesTable system={system} chartData={chartData} grid={grid} realtimeProbeStats={probeStats} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { cn, decimalString } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
GlobeIcon,
|
|
||||||
TagIcon,
|
|
||||||
TimerIcon,
|
|
||||||
ActivityIcon,
|
|
||||||
WifiOffIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
ArrowLeftRightIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
} 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"
|
|
||||||
|
|
||||||
export interface ProbeRow extends NetworkProbeRecord {
|
|
||||||
key: string
|
|
||||||
latency?: number
|
|
||||||
loss?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProbeColumns(
|
|
||||||
deleteProbe: (id: string) => void,
|
|
||||||
longestName = 0,
|
|
||||||
longestTarget = 0
|
|
||||||
): ColumnDef<ProbeRow>[] {
|
|
||||||
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={TagIcon} />,
|
|
||||||
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: "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 === undefined) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", val > 100 ? "bg-yellow-500" : "bg-green-500")} />
|
|
||||||
{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 val = row.original.loss
|
|
||||||
if (val === undefined) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", val > 0 ? "bg-yellow-500" : "bg-green-500")} />
|
|
||||||
{val}%
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => null,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeaderButton({ column, name, Icon }: { column: Column<ProbeRow>; 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { $chartTime } from "@/lib/stores"
|
|
||||||
import { chartTimeData, cn, toFixedFloat, decimalString, getVisualStringWidth } from "@/lib/utils"
|
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
|
||||||
import { appendData } from "./chart-data"
|
|
||||||
// import { AddProbeDialog } from "./probe-dialog"
|
|
||||||
import { ChartCard } from "./chart-card"
|
|
||||||
import LineChartDefault, { type DataPoint } from "@/components/charts/line-chart"
|
|
||||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
|
||||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord, SystemRecord } from "@/types"
|
|
||||||
import {
|
|
||||||
type Row,
|
|
||||||
type SortingState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { getProbeColumns, type ProbeRow } from "./network-probes-columns"
|
|
||||||
|
|
||||||
function probeKey(p: NetworkProbeRecord) {
|
|
||||||
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
|
||||||
return `${p.protocol}:${p.target}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NetworkProbes({
|
|
||||||
system,
|
|
||||||
chartData,
|
|
||||||
grid,
|
|
||||||
realtimeProbeStats,
|
|
||||||
}: {
|
|
||||||
system: SystemRecord
|
|
||||||
chartData: ChartData
|
|
||||||
grid: boolean
|
|
||||||
realtimeProbeStats?: NetworkProbeStatsRecord[]
|
|
||||||
}) {
|
|
||||||
const systemId = system.id
|
|
||||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
|
||||||
const [stats, setStats] = useState<NetworkProbeStatsRecord[]>([])
|
|
||||||
const [latestResults, setLatestResults] = useState<Record<string, { avg: number; loss: number }>>({})
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
const { toast } = useToast()
|
|
||||||
const { t } = useLingui()
|
|
||||||
|
|
||||||
const fetchProbes = useCallback(() => {
|
|
||||||
pb.collection<NetworkProbeRecord>("network_probes")
|
|
||||||
.getList(0, 2000, {
|
|
||||||
fields: "id,name,target,protocol,port,interval,enabled,updated",
|
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
|
||||||
})
|
|
||||||
.then((res) => setProbes(res.items))
|
|
||||||
.catch(() => setProbes([]))
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchProbes()
|
|
||||||
}, [fetchProbes])
|
|
||||||
|
|
||||||
// Build set of current probe keys to filter out deleted probes from stats
|
|
||||||
const activeProbeKeys = useMemo(() => new Set(probes.map(probeKey)), [probes])
|
|
||||||
|
|
||||||
// Use realtime probe stats when in 1m mode
|
|
||||||
useEffect(() => {
|
|
||||||
if (chartTime !== "1m" || !realtimeProbeStats) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Filter stats to only include currently active probes, preserving gap markers
|
|
||||||
const data: NetworkProbeStatsRecord[] = realtimeProbeStats.map((r) => {
|
|
||||||
if (!r.stats) {
|
|
||||||
return r // preserve gap markers from appendData
|
|
||||||
}
|
|
||||||
const filtered: NetworkProbeStatsRecord["stats"] = {}
|
|
||||||
for (const [key, val] of Object.entries(r.stats)) {
|
|
||||||
if (activeProbeKeys.has(key)) {
|
|
||||||
filtered[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { stats: filtered, created: r.created }
|
|
||||||
})
|
|
||||||
setStats(data)
|
|
||||||
// Use last non-gap entry for latest results
|
|
||||||
for (let i = data.length - 1; i >= 0; i--) {
|
|
||||||
if (data[i].stats) {
|
|
||||||
const latest: Record<string, { avg: number; loss: number }> = {}
|
|
||||||
for (const [key, val] of Object.entries(data[i].stats)) {
|
|
||||||
latest[key] = { avg: val?.[0], loss: val?.[3] }
|
|
||||||
}
|
|
||||||
setLatestResults(latest)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [chartTime, realtimeProbeStats, activeProbeKeys])
|
|
||||||
|
|
||||||
// Fetch probe stats based on chart time (skip in realtime mode)
|
|
||||||
useEffect(() => {
|
|
||||||
if (probes.length === 0) {
|
|
||||||
setStats([])
|
|
||||||
setLatestResults({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (chartTime === "1m") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const controller = new AbortController()
|
|
||||||
const { type: statsType = "1m", expectedInterval } = chartTimeData[chartTime] ?? {}
|
|
||||||
|
|
||||||
console.log("Fetching probe stats", { systemId, statsType, expectedInterval })
|
|
||||||
|
|
||||||
pb.collection<NetworkProbeStatsRecord>("network_probe_stats")
|
|
||||||
.getList(0, 2000, {
|
|
||||||
fields: "stats,created",
|
|
||||||
filter: pb.filter("system={:system} && type={:type} && created <= {:created}", {
|
|
||||||
system: systemId,
|
|
||||||
type: statsType,
|
|
||||||
created: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
||||||
}),
|
|
||||||
sort: "-created",
|
|
||||||
})
|
|
||||||
.then((raw) => {
|
|
||||||
console.log("Fetched probe stats", { raw })
|
|
||||||
// Filter stats to only include currently active probes
|
|
||||||
const mapped: NetworkProbeStatsRecord[] = raw.items.map((r) => {
|
|
||||||
const filtered: NetworkProbeStatsRecord["stats"] = {}
|
|
||||||
for (const [key, val] of Object.entries(r.stats)) {
|
|
||||||
if (activeProbeKeys.has(key)) {
|
|
||||||
filtered[key] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { stats: filtered, created: new Date(r.created).getTime() }
|
|
||||||
})
|
|
||||||
// Apply gap detection — inserts null markers where data is missing
|
|
||||||
const data = appendData([] as NetworkProbeStatsRecord[], mapped, expectedInterval)
|
|
||||||
setStats(data)
|
|
||||||
if (mapped.length > 0) {
|
|
||||||
const last = mapped[mapped.length - 1].stats
|
|
||||||
const latest: Record<string, { avg: number; loss: number }> = {}
|
|
||||||
for (const [key, val] of Object.entries(last)) {
|
|
||||||
latest[key] = { avg: val?.[0], loss: val?.[3] }
|
|
||||||
}
|
|
||||||
setLatestResults(latest)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Error fetching probe stats", e)
|
|
||||||
setStats([])
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => controller.abort()
|
|
||||||
}, [system, chartTime, probes, activeProbeKeys])
|
|
||||||
|
|
||||||
const deleteProbe = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
try {
|
|
||||||
await pb.collection("network_probes").delete(id)
|
|
||||||
// fetchProbes()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[systemId, t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
|
|
||||||
const count = probes.length
|
|
||||||
return probes.map((p, i) => {
|
|
||||||
const key = probeKey(p)
|
|
||||||
return {
|
|
||||||
label: p.name || p.target,
|
|
||||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null,
|
|
||||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [probes])
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() => getProbeColumns(deleteProbe, longestName, longestTarget),
|
|
||||||
[deleteProbe, longestName, longestTarget]
|
|
||||||
)
|
|
||||||
|
|
||||||
const tableData: ProbeRow[] = useMemo(
|
|
||||||
() =>
|
|
||||||
probes.map((p) => {
|
|
||||||
const key = probeKey(p)
|
|
||||||
const result = latestResults[key]
|
|
||||||
return { ...p, key, latency: result?.avg, loss: result?.loss }
|
|
||||||
}),
|
|
||||||
[probes, latestResults]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: tableData,
|
|
||||||
columns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
defaultColumn: {
|
|
||||||
sortUndefined: "last",
|
|
||||||
size: 100,
|
|
||||||
minSize: 0,
|
|
||||||
},
|
|
||||||
state: { sorting },
|
|
||||||
})
|
|
||||||
|
|
||||||
const rows = table.getRowModel().rows
|
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
|
||||||
|
|
||||||
// if (probes.length === 0 && stats.length === 0) {
|
|
||||||
// return (
|
|
||||||
// <Card className="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>
|
|
||||||
// <CardDescription>
|
|
||||||
// <Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
|
|
||||||
// </CardDescription>
|
|
||||||
// </div>
|
|
||||||
// {/* <div className="relative ms-auto w-full max-w-full md:w-64"> */}
|
|
||||||
// <AddProbeDialog systemId={systemId} onCreated={fetchProbes} />
|
|
||||||
// {/* </div> */}
|
|
||||||
// </div>
|
|
||||||
// </CardHeader>
|
|
||||||
// </Card>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// console.log("Rendering NetworkProbes", { probes, stats })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<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 md:justify-between">
|
|
||||||
<div className="px-2 sm:px-1">
|
|
||||||
<CardTitle>
|
|
||||||
<Trans>Network Probes</Trans>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1.5">
|
|
||||||
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{/* <AddProbeDialog systemId={systemId} onCreated={fetchProbes} /> */}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<ProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{stats.length > 0 && (
|
|
||||||
<ChartCard title={t`Latency`} description={t`Average round-trip time (ms)`} grid={grid}>
|
|
||||||
<LineChartDefault
|
|
||||||
chartData={chartData}
|
|
||||||
customData={stats}
|
|
||||||
dataPoints={dataPoints}
|
|
||||||
domain={pinnedAxisDomain()}
|
|
||||||
connectNulls
|
|
||||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
|
||||||
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
|
|
||||||
legend
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProbesTable = memo(function ProbesTable({
|
|
||||||
table,
|
|
||||||
rows,
|
|
||||||
colLength,
|
|
||||||
}: {
|
|
||||||
table: ReturnType<typeof useReactTable<ProbeRow>>
|
|
||||||
rows: Row<ProbeRow>[]
|
|
||||||
colLength: number
|
|
||||||
}) {
|
|
||||||
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)] w-full relative overflow-auto rounded-md border",
|
|
||||||
(!rows.length || rows.length > 2) && "min-h-50"
|
|
||||||
)}
|
|
||||||
ref={scrollRef}
|
|
||||||
>
|
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
|
||||||
<table className="w-full text-sm text-nowrap">
|
|
||||||
<ProbesTableHead table={table} />
|
|
||||||
<TableBody>
|
|
||||||
{rows.length ? (
|
|
||||||
virtualRows.map((virtualRow) => {
|
|
||||||
const row = rows[virtualRow.index]
|
|
||||||
return <ProbesTableRow 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 ProbesTableHead({ table }: { table: ReturnType<typeof useReactTable<ProbeRow>> }) {
|
|
||||||
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) => (
|
|
||||||
<TableHead className="px-2" key={header.id}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProbesTableRow = memo(function ProbesTableRow({
|
|
||||||
row,
|
|
||||||
virtualRow,
|
|
||||||
}: {
|
|
||||||
row: Row<ProbeRow>
|
|
||||||
virtualRow: VirtualItem
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id} className="py-0" style={{ height: virtualRow.size }}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -19,7 +19,6 @@ import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/uti
|
|||||||
import type {
|
import type {
|
||||||
ChartData,
|
ChartData,
|
||||||
ContainerStatsRecord,
|
ContainerStatsRecord,
|
||||||
NetworkProbeStatsRecord,
|
|
||||||
SystemDetailsRecord,
|
SystemDetailsRecord,
|
||||||
SystemInfo,
|
SystemInfo,
|
||||||
SystemRecord,
|
SystemRecord,
|
||||||
@@ -49,7 +48,6 @@ export function useSystemData(id: string) {
|
|||||||
const [system, setSystem] = useState({} as SystemRecord)
|
const [system, setSystem] = useState({} as SystemRecord)
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||||
const [probeStats, setProbeStats] = useState([] as NetworkProbeStatsRecord[])
|
|
||||||
const persistChartTime = useRef(false)
|
const persistChartTime = useRef(false)
|
||||||
const statsRequestId = useRef(0)
|
const statsRequestId = useRef(0)
|
||||||
const [chartLoading, setChartLoading] = useState(true)
|
const [chartLoading, setChartLoading] = useState(true)
|
||||||
@@ -121,34 +119,24 @@ export function useSystemData(id: string) {
|
|||||||
pb.realtime
|
pb.realtime
|
||||||
.subscribe(
|
.subscribe(
|
||||||
`rt_metrics`,
|
`rt_metrics`,
|
||||||
(data: {
|
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
|
||||||
container: ContainerStatsRecord[]
|
|
||||||
info: SystemInfo
|
|
||||||
stats: SystemStats
|
|
||||||
probes?: NetworkProbeStatsRecord["stats"]
|
|
||||||
}) => {
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
|
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
|
||||||
const containerPoint =
|
const containerPoint =
|
||||||
data.container?.length > 0
|
data.container?.length > 0
|
||||||
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
|
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
|
||||||
: null
|
: null
|
||||||
const probePoint: NetworkProbeStatsRecord | null = data.probes ? { stats: data.probes, created: now } : null
|
|
||||||
// on first message, make sure we clear out data from other time periods
|
// on first message, make sure we clear out data from other time periods
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
isFirst = false
|
isFirst = false
|
||||||
setSystemStats([statsPoint])
|
setSystemStats([statsPoint])
|
||||||
setContainerData(containerPoint ? [containerPoint] : [])
|
setContainerData(containerPoint ? [containerPoint] : [])
|
||||||
setProbeStats(probePoint ? [probePoint] : [])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
|
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
|
||||||
if (containerPoint) {
|
if (containerPoint) {
|
||||||
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
|
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
|
||||||
}
|
}
|
||||||
if (probePoint) {
|
|
||||||
setProbeStats((prev) => appendData(prev, [probePoint], 1000, 60))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ query: { system: system.id } }
|
{ query: { system: system.id } }
|
||||||
)
|
)
|
||||||
@@ -212,8 +200,8 @@ export function useSystemData(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
|
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
||||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
|
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
||||||
]).then(([systemStats, containerStats]) => {
|
]).then(([systemStats, containerStats]) => {
|
||||||
// If another request has been made since this one, ignore the results
|
// If another request has been made since this one, ignore the results
|
||||||
if (requestId !== statsRequestId.current) {
|
if (requestId !== statsRequestId.current) {
|
||||||
@@ -334,7 +322,6 @@ export function useSystemData(id: string) {
|
|||||||
system,
|
system,
|
||||||
systemStats,
|
systemStats,
|
||||||
containerData,
|
containerData,
|
||||||
probeStats,
|
|
||||||
chartData,
|
chartData,
|
||||||
containerChartConfigs,
|
containerChartConfigs,
|
||||||
details,
|
details,
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
|
|||||||
|
|
||||||
let cachedAxis: JSX.Element
|
let cachedAxis: JSX.Element
|
||||||
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
||||||
if (cachedAxis && ticks === cachedAxis.props.ticks) {
|
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
|
||||||
return cachedAxis
|
return cachedAxis
|
||||||
}
|
}
|
||||||
cachedAxis = (
|
cachedAxis = (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: it\n"
|
"Language: it\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
"PO-Revision-Date: 2026-04-17 09:26\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Italian\n"
|
"Language-Team: Italian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -57,7 +57,7 @@ msgstr "1 ora"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr ""
|
msgstr "1 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 minute"
|
msgid "1 minute"
|
||||||
@@ -74,7 +74,7 @@ msgstr "12 ore"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "15 min"
|
msgid "15 min"
|
||||||
msgstr ""
|
msgstr "15 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
@@ -87,7 +87,7 @@ msgstr "30 giorni"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "5 min"
|
msgid "5 min"
|
||||||
msgstr ""
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
@@ -248,7 +248,7 @@ msgstr "Larghezza di banda"
|
|||||||
#. Battery label in systems table header
|
#. Battery label in systems table header
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Bat"
|
msgid "Bat"
|
||||||
msgstr ""
|
msgstr "Batt"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/sensor-charts.tsx
|
#: src/components/routes/system/charts/sensor-charts.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -336,7 +336,7 @@ msgstr "Attenzione - possibile perdita di dati"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Celsius (°C)"
|
msgid "Celsius (°C)"
|
||||||
msgstr ""
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change display units for metrics."
|
msgid "Change display units for metrics."
|
||||||
@@ -490,13 +490,13 @@ msgstr "Copia YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Interne"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr ""
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Cores"
|
msgid "CPU Cores"
|
||||||
@@ -624,7 +624,7 @@ msgstr "Utilizzo del disco di {extraFsName}"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Display"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -677,7 +677,7 @@ msgstr "Modifica {foo}"
|
|||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
#: src/components/login/otp-forms.tsx
|
#: src/components/login/otp-forms.tsx
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr ""
|
msgstr "Email"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Email notifications"
|
msgid "Email notifications"
|
||||||
@@ -772,7 +772,7 @@ msgstr "Esporta la configurazione attuale dei tuoi sistemi."
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr ""
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Failed"
|
msgid "Failed"
|
||||||
@@ -824,7 +824,7 @@ msgstr "Impronta digitale"
|
|||||||
|
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Firmware"
|
msgid "Firmware"
|
||||||
msgstr ""
|
msgstr "Firmware"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -858,7 +858,7 @@ msgstr "Globale"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "GPU"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -883,7 +883,7 @@ msgstr "Stato"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Hearthbeat"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -901,7 +901,7 @@ msgstr "Comando Homebrew"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Host / IP"
|
msgid "Host / IP"
|
||||||
msgstr ""
|
msgstr "Host / IP"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "HTTP Method"
|
msgid "HTTP Method"
|
||||||
@@ -1043,7 +1043,7 @@ msgstr "Istruzioni di configurazione manuale"
|
|||||||
#. Chart select field. Please try to keep this short.
|
#. Chart select field. Please try to keep this short.
|
||||||
#: src/components/routes/system/chart-card.tsx
|
#: src/components/routes/system/chart-card.tsx
|
||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr ""
|
msgstr "Max 1 min"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
@@ -1109,7 +1109,7 @@ msgstr "Unità rete"
|
|||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No"
|
msgid "No"
|
||||||
msgstr ""
|
msgstr "No"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1196,7 +1196,7 @@ msgstr "Pagine / Impostazioni"
|
|||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr "Password"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Password must be at least 8 characters."
|
msgid "Password must be at least 8 characters."
|
||||||
@@ -1384,7 +1384,7 @@ msgstr "Riprendi"
|
|||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgctxt "Root disk label"
|
msgctxt "Root disk label"
|
||||||
msgid "Root"
|
msgid "Root"
|
||||||
msgstr ""
|
msgstr "Root"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
@@ -1615,11 +1615,11 @@ msgstr "Temperature dei sensori di sistema"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test <0>URL</0>"
|
msgid "Test <0>URL</0>"
|
||||||
msgstr ""
|
msgstr "Test <0>URL</0>"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Test heartbeat"
|
msgid "Test heartbeat"
|
||||||
msgstr ""
|
msgstr "Test Heartbeat"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test notification sent"
|
msgid "Test notification sent"
|
||||||
@@ -1665,7 +1665,7 @@ msgstr "Attiva/disattiva tema"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr ""
|
msgstr "Token"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Sì"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Le impostazioni utente sono state aggiornate."
|
msgstr "Le impostazioni utente sono state aggiornate."
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
|||||||
const Home = lazy(() => import("@/components/routes/home.tsx"))
|
const Home = lazy(() => import("@/components/routes/home.tsx"))
|
||||||
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
|
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
|
||||||
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
|
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
|
||||||
const Probes = lazy(() => import("@/components/routes/probes.tsx"))
|
|
||||||
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
||||||
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
||||||
|
|
||||||
@@ -80,8 +79,6 @@ const App = memo(() => {
|
|||||||
return <Containers />
|
return <Containers />
|
||||||
} else if (page.route === "smart") {
|
} else if (page.route === "smart") {
|
||||||
return <Smart />
|
return <Smart />
|
||||||
} else if (page.route === "probes") {
|
|
||||||
return <Probes />
|
|
||||||
} else if (page.route === "settings") {
|
} else if (page.route === "settings") {
|
||||||
return <Settings />
|
return <Settings />
|
||||||
}
|
}
|
||||||
|
|||||||
30
internal/site/src/types.d.ts
vendored
30
internal/site/src/types.d.ts
vendored
@@ -546,33 +546,3 @@ export interface UpdateInfo {
|
|||||||
v: string // new version
|
v: string // new version
|
||||||
url: string // url to new version
|
url: string // url to new version
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkProbeRecord {
|
|
||||||
id: string
|
|
||||||
system: string
|
|
||||||
name: string
|
|
||||||
target: string
|
|
||||||
protocol: "icmp" | "tcp" | "http"
|
|
||||||
port: number
|
|
||||||
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 {
|
|
||||||
stats: Record<string, ProbeResult>
|
|
||||||
created: number // unix timestamp (ms) for Recharts xAxis
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user