mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-25 05:51:49 +02:00
Compare commits
2 Commits
dev-probes
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c1cd1bcb | ||
|
|
cd9ea51039 |
@@ -48,7 +48,6 @@ type Agent struct {
|
|||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
smartManager *SmartManager // Manages SMART data
|
||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
probeManager *ProbeManager // Manages network probes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -122,9 +121,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize handler registry
|
// initialize handler registry
|
||||||
agent.handlerRegistry = NewHandlerRegistry()
|
agent.handlerRegistry = NewHandlerRegistry()
|
||||||
|
|
||||||
// initialize probe manager
|
|
||||||
agent.probeManager = newProbeManager()
|
|
||||||
|
|
||||||
// initialize disk info
|
// initialize disk info
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
|
|
||||||
@@ -182,11 +178,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.probeManager != nil {
|
|
||||||
data.Probes = a.probeManager.GetResults(cacheTimeMs)
|
|
||||||
slog.Debug("Probes", "data", data.Probes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
// }
|
// }
|
||||||
func (c *ConnectionManager) stop() error {
|
func (c *ConnectionManager) stop() error {
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.agent.probeManager.Stop()
|
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
return health.CleanUp()
|
return health.CleanUp()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -52,7 +51,6 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
registry.Register(common.SyncNetworkProbes, &SyncNetworkProbesHandler{})
|
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -205,22 +203,3 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|||||||
|
|
||||||
return hctx.SendResponse(details, hctx.RequestID)
|
return hctx.SendResponse(details, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// SyncNetworkProbesHandler handles probe configuration sync from hub
|
|
||||||
type SyncNetworkProbesHandler struct{}
|
|
||||||
|
|
||||||
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
var req probe.SyncRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resp, err := hctx.Agent.probeManager.ApplySync(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Info("network probes synced", "action", req.Action)
|
|
||||||
return hctx.SendResponse(resp, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|||||||
494
agent/probe.go
494
agent/probe.go
@@ -1,494 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Probe functionality overview:
|
|
||||||
// Probes run at user-defined intervals (e.g., every 10s).
|
|
||||||
// To keep memory usage low and constant, data is stored in two layers:
|
|
||||||
// 1. Raw samples: The most recent individual results (kept for probeRawRetention).
|
|
||||||
// 2. Minute buckets: A fixed-size ring buffer of 61 buckets, each representing one
|
|
||||||
// wall-clock minute. Samples collected within the same minute are aggregated
|
|
||||||
// (sum, min, max, count) into a single bucket.
|
|
||||||
//
|
|
||||||
// Short-term requests (<= 2m) use raw samples for perfect accuracy.
|
|
||||||
// Long-term requests (up to 1h) use the minute buckets to avoid storing thousands
|
|
||||||
// of individual data points.
|
|
||||||
|
|
||||||
const (
|
|
||||||
// probeRawRetention is the duration to keep individual samples for high-precision short-term requests
|
|
||||||
probeRawRetention = 80 * time.Second
|
|
||||||
// probeMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
|
|
||||||
probeMinuteBucketLen int32 = 61
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProbeManager manages network probe tasks.
|
|
||||||
type ProbeManager struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
probes map[string]*probeTask // key = probe.Config.Key()
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeTask owns retention buffers and cancellation for a single probe config.
|
|
||||||
type probeTask struct {
|
|
||||||
config probe.Config
|
|
||||||
cancel chan struct{}
|
|
||||||
mu sync.Mutex
|
|
||||||
samples []probeSample
|
|
||||||
buckets [probeMinuteBucketLen]probeBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeSample stores one probe attempt and its collection time.
|
|
||||||
type probeSample struct {
|
|
||||||
responseMs float64 // -1 means loss
|
|
||||||
timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeBucket stores one minute of aggregated probe data.
|
|
||||||
type probeBucket struct {
|
|
||||||
minute int32
|
|
||||||
filled bool
|
|
||||||
stats probeAggregate
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeAggregate accumulates successful response stats and total sample counts.
|
|
||||||
type probeAggregate struct {
|
|
||||||
sumMs float64
|
|
||||||
minMs float64
|
|
||||||
maxMs float64
|
|
||||||
totalCount int
|
|
||||||
successCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newProbeManager() *ProbeManager {
|
|
||||||
return &ProbeManager{
|
|
||||||
probes: make(map[string]*probeTask),
|
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newProbeTask(config probe.Config) *probeTask {
|
|
||||||
return &probeTask{
|
|
||||||
config: config,
|
|
||||||
cancel: make(chan struct{}),
|
|
||||||
samples: make([]probeSample, 0, 64),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newProbeAggregate initializes an aggregate with an unset minimum value.
|
|
||||||
func newProbeAggregate() probeAggregate {
|
|
||||||
return probeAggregate{minMs: math.MaxFloat64}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addResponse folds a single probe sample into the aggregate.
|
|
||||||
func (agg *probeAggregate) addResponse(responseMs float64) {
|
|
||||||
agg.totalCount++
|
|
||||||
if responseMs < 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
agg.successCount++
|
|
||||||
agg.sumMs += responseMs
|
|
||||||
if responseMs < agg.minMs {
|
|
||||||
agg.minMs = responseMs
|
|
||||||
}
|
|
||||||
if responseMs > agg.maxMs {
|
|
||||||
agg.maxMs = responseMs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addAggregate merges another aggregate into this one.
|
|
||||||
func (agg *probeAggregate) addAggregate(other probeAggregate) {
|
|
||||||
if other.totalCount == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
agg.totalCount += other.totalCount
|
|
||||||
agg.successCount += other.successCount
|
|
||||||
agg.sumMs += other.sumMs
|
|
||||||
if other.successCount == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if agg.minMs == math.MaxFloat64 || other.minMs < agg.minMs {
|
|
||||||
agg.minMs = other.minMs
|
|
||||||
}
|
|
||||||
if other.maxMs > agg.maxMs {
|
|
||||||
agg.maxMs = other.maxMs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasData reports whether the aggregate contains any samples.
|
|
||||||
func (agg probeAggregate) hasData() bool {
|
|
||||||
return agg.totalCount > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// result converts the aggregate into the probe result slice format.
|
|
||||||
func (agg probeAggregate) result() probe.Result {
|
|
||||||
avg := agg.avgResponse()
|
|
||||||
minMs := 0.0
|
|
||||||
if agg.successCount > 0 {
|
|
||||||
minMs = math.Round(agg.minMs*100) / 100
|
|
||||||
}
|
|
||||||
return probe.Result{
|
|
||||||
avg,
|
|
||||||
minMs,
|
|
||||||
math.Round(agg.maxMs*100) / 100,
|
|
||||||
agg.lossPercentage(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// avgResponse returns the rounded average of successful samples.
|
|
||||||
func (agg probeAggregate) avgResponse() float64 {
|
|
||||||
if agg.successCount == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return math.Round(agg.sumMs/float64(agg.successCount)*100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// lossPercentage returns the rounded failure rate for the aggregate.
|
|
||||||
func (agg probeAggregate) lossPercentage() float64 {
|
|
||||||
if agg.totalCount == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return math.Round(float64(agg.totalCount-agg.successCount)/float64(agg.totalCount)*10000) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncProbes replaces all probe tasks with the given configs.
|
|
||||||
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
|
||||||
pm.mu.Lock()
|
|
||||||
defer pm.mu.Unlock()
|
|
||||||
|
|
||||||
// Build set of new keys
|
|
||||||
newKeys := make(map[string]probe.Config, len(configs))
|
|
||||||
for _, cfg := range configs {
|
|
||||||
if cfg.ID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newKeys[cfg.ID] = cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop removed probes
|
|
||||||
for key, task := range pm.probes {
|
|
||||||
if _, exists := newKeys[key]; !exists {
|
|
||||||
close(task.cancel)
|
|
||||||
delete(pm.probes, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new probes and restart tasks whose config changed.
|
|
||||||
for key, cfg := range newKeys {
|
|
||||||
task, exists := pm.probes[key]
|
|
||||||
if exists && task.config == cfg {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
close(task.cancel)
|
|
||||||
}
|
|
||||||
task = newProbeTask(cfg)
|
|
||||||
pm.probes[key] = task
|
|
||||||
go pm.runProbe(task, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplySync applies a full or incremental probe sync request.
|
|
||||||
func (pm *ProbeManager) ApplySync(req probe.SyncRequest) (probe.SyncResponse, error) {
|
|
||||||
switch req.Action {
|
|
||||||
case probe.SyncActionReplace:
|
|
||||||
pm.SyncProbes(req.Configs)
|
|
||||||
return probe.SyncResponse{}, nil
|
|
||||||
case probe.SyncActionUpsert:
|
|
||||||
result, err := pm.UpsertProbe(req.Config, req.RunNow)
|
|
||||||
if err != nil {
|
|
||||||
return probe.SyncResponse{}, err
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
return probe.SyncResponse{}, nil
|
|
||||||
}
|
|
||||||
return probe.SyncResponse{Result: *result}, nil
|
|
||||||
case probe.SyncActionDelete:
|
|
||||||
if req.Config.ID == "" {
|
|
||||||
return probe.SyncResponse{}, errors.New("missing probe ID for delete action")
|
|
||||||
}
|
|
||||||
pm.DeleteProbe(req.Config.ID)
|
|
||||||
return probe.SyncResponse{}, nil
|
|
||||||
default:
|
|
||||||
return probe.SyncResponse{}, fmt.Errorf("unknown probe sync action: %d", req.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpsertProbe creates or replaces a single probe task.
|
|
||||||
func (pm *ProbeManager) UpsertProbe(config probe.Config, runNow bool) (*probe.Result, error) {
|
|
||||||
if config.ID == "" {
|
|
||||||
return nil, errors.New("missing probe ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.mu.Lock()
|
|
||||||
task, exists := pm.probes[config.ID]
|
|
||||||
startTask := false
|
|
||||||
if exists && task.config == config {
|
|
||||||
pm.mu.Unlock()
|
|
||||||
if !runNow {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return pm.runProbeNow(task), nil
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
close(task.cancel)
|
|
||||||
}
|
|
||||||
task = newProbeTask(config)
|
|
||||||
pm.probes[config.ID] = task
|
|
||||||
startTask = true
|
|
||||||
pm.mu.Unlock()
|
|
||||||
|
|
||||||
if runNow {
|
|
||||||
result := pm.runProbeNow(task)
|
|
||||||
if startTask {
|
|
||||||
go pm.runProbe(task, false)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
if startTask {
|
|
||||||
go pm.runProbe(task, true)
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteProbe stops and removes a single probe task.
|
|
||||||
func (pm *ProbeManager) DeleteProbe(id string) {
|
|
||||||
if id == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pm.mu.Lock()
|
|
||||||
defer pm.mu.Unlock()
|
|
||||||
if task, exists := pm.probes[id]; exists {
|
|
||||||
close(task.cancel)
|
|
||||||
delete(pm.probes, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResults returns aggregated results for all probes over the last supplied duration in ms.
|
|
||||||
func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
|
|
||||||
pm.mu.RLock()
|
|
||||||
defer pm.mu.RUnlock()
|
|
||||||
|
|
||||||
results := make(map[string]probe.Result, len(pm.probes))
|
|
||||||
now := time.Now()
|
|
||||||
duration := time.Duration(durationMs) * time.Millisecond
|
|
||||||
|
|
||||||
for _, task := range pm.probes {
|
|
||||||
task.mu.Lock()
|
|
||||||
result, ok := task.resultLocked(duration, now)
|
|
||||||
task.mu.Unlock()
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results[task.config.ID] = result
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops all probe tasks.
|
|
||||||
func (pm *ProbeManager) Stop() {
|
|
||||||
pm.mu.Lock()
|
|
||||||
defer pm.mu.Unlock()
|
|
||||||
for key, task := range pm.probes {
|
|
||||||
close(task.cancel)
|
|
||||||
delete(pm.probes, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runProbe executes a single probe task in a loop.
|
|
||||||
func (pm *ProbeManager) runProbe(task *probeTask, runImmediately bool) {
|
|
||||||
interval := time.Duration(task.config.Interval) * time.Second
|
|
||||||
if interval < time.Second {
|
|
||||||
interval = 10 * time.Second
|
|
||||||
}
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
if runImmediately {
|
|
||||||
pm.executeProbe(task)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-task.cancel:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
pm.executeProbe(task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *ProbeManager) runProbeNow(task *probeTask) *probe.Result {
|
|
||||||
pm.executeProbe(task)
|
|
||||||
task.mu.Lock()
|
|
||||||
defer task.mu.Unlock()
|
|
||||||
result, ok := task.resultLocked(time.Minute, time.Now())
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &result
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggregateLocked collects probe data for the requested time window.
|
|
||||||
func (task *probeTask) aggregateLocked(duration time.Duration, now time.Time) probeAggregate {
|
|
||||||
cutoff := now.Add(-duration)
|
|
||||||
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
|
|
||||||
if duration <= probeRawRetention {
|
|
||||||
return aggregateSamplesSince(task.samples, cutoff)
|
|
||||||
}
|
|
||||||
return aggregateBucketsSince(task.buckets[:], cutoff, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (task *probeTask) resultLocked(duration time.Duration, now time.Time) (probe.Result, bool) {
|
|
||||||
agg := task.aggregateLocked(duration, now)
|
|
||||||
hourAgg := task.aggregateLocked(time.Hour, now)
|
|
||||||
if !agg.hasData() {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
result := agg.result()
|
|
||||||
hourAvg := hourAgg.avgResponse()
|
|
||||||
hourLoss := hourAgg.lossPercentage()
|
|
||||||
if hourAgg.successCount > 0 {
|
|
||||||
return probe.Result{
|
|
||||||
result[0],
|
|
||||||
hourAvg,
|
|
||||||
math.Round(hourAgg.minMs*100) / 100,
|
|
||||||
math.Round(hourAgg.maxMs*100) / 100,
|
|
||||||
hourLoss,
|
|
||||||
}, true
|
|
||||||
}
|
|
||||||
return probe.Result{result[0], hourAvg, 0, 0, hourLoss}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
|
|
||||||
func aggregateSamplesSince(samples []probeSample, cutoff time.Time) probeAggregate {
|
|
||||||
agg := newProbeAggregate()
|
|
||||||
for _, sample := range samples {
|
|
||||||
if sample.timestamp.Before(cutoff) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agg.addResponse(sample.responseMs)
|
|
||||||
}
|
|
||||||
return agg
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggregateBucketsSince aggregates minute buckets overlapping the requested window.
|
|
||||||
func aggregateBucketsSince(buckets []probeBucket, cutoff, now time.Time) probeAggregate {
|
|
||||||
agg := newProbeAggregate()
|
|
||||||
startMinute := int32(cutoff.Unix() / 60)
|
|
||||||
endMinute := int32(now.Unix() / 60)
|
|
||||||
for _, bucket := range buckets {
|
|
||||||
if !bucket.filled || bucket.minute < startMinute || bucket.minute > endMinute {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agg.addAggregate(bucket.stats)
|
|
||||||
}
|
|
||||||
return agg
|
|
||||||
}
|
|
||||||
|
|
||||||
// addSampleLocked stores a fresh sample in both raw and per-minute retention buffers.
|
|
||||||
func (task *probeTask) addSampleLocked(sample probeSample) {
|
|
||||||
cutoff := sample.timestamp.Add(-probeRawRetention)
|
|
||||||
start := 0
|
|
||||||
for i := range task.samples {
|
|
||||||
if !task.samples[i].timestamp.Before(cutoff) {
|
|
||||||
start = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if i == len(task.samples)-1 {
|
|
||||||
start = len(task.samples)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if start > 0 {
|
|
||||||
size := copy(task.samples, task.samples[start:])
|
|
||||||
task.samples = task.samples[:size]
|
|
||||||
}
|
|
||||||
task.samples = append(task.samples, sample)
|
|
||||||
|
|
||||||
minute := int32(sample.timestamp.Unix() / 60)
|
|
||||||
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per probe.
|
|
||||||
bucket := &task.buckets[minute%probeMinuteBucketLen]
|
|
||||||
if !bucket.filled || bucket.minute != minute {
|
|
||||||
bucket.minute = minute
|
|
||||||
bucket.filled = true
|
|
||||||
bucket.stats = newProbeAggregate()
|
|
||||||
}
|
|
||||||
bucket.stats.addResponse(sample.responseMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeProbe runs the configured probe and records the sample.
|
|
||||||
func (pm *ProbeManager) executeProbe(task *probeTask) {
|
|
||||||
var responseMs float64
|
|
||||||
|
|
||||||
switch task.config.Protocol {
|
|
||||||
case "icmp":
|
|
||||||
responseMs = probeICMP(task.config.Target)
|
|
||||||
case "tcp":
|
|
||||||
responseMs = probeTCP(task.config.Target, task.config.Port)
|
|
||||||
case "http":
|
|
||||||
responseMs = probeHTTP(pm.httpClient, task.config.Target)
|
|
||||||
default:
|
|
||||||
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sample := probeSample{
|
|
||||||
responseMs: responseMs,
|
|
||||||
timestamp: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
task.mu.Lock()
|
|
||||||
task.addSampleLocked(sample)
|
|
||||||
task.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeTCP measures pure TCP handshake response (excluding DNS resolution).
|
|
||||||
// Returns -1 on failure.
|
|
||||||
func probeTCP(target string, port uint16) float64 {
|
|
||||||
// Resolve DNS first, outside the timing window
|
|
||||||
ips, err := net.LookupHost(target)
|
|
||||||
if err != nil || len(ips) == 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
|
|
||||||
|
|
||||||
// Measure only the TCP handshake
|
|
||||||
start := time.Now()
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
conn.Close()
|
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeHTTP measures HTTP GET request response. Returns -1 on failure.
|
|
||||||
func probeHTTP(client *http.Client, url string) float64 {
|
|
||||||
if client == nil {
|
|
||||||
client = http.DefaultClient
|
|
||||||
}
|
|
||||||
start := time.Now()
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/net/icmp"
|
|
||||||
"golang.org/x/net/ipv4"
|
|
||||||
"golang.org/x/net/ipv6"
|
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
|
||||||
|
|
||||||
type icmpPacketConn interface {
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
|
||||||
// all native methods fail, the choice is cached so subsequent probes skip
|
|
||||||
// the trial-and-error overhead.
|
|
||||||
type icmpMethod int
|
|
||||||
|
|
||||||
const (
|
|
||||||
icmpUntried icmpMethod = iota // haven't tried yet
|
|
||||||
icmpRaw // privileged raw socket
|
|
||||||
icmpDatagram // unprivileged datagram socket
|
|
||||||
icmpExecFallback // shell out to system ping command
|
|
||||||
)
|
|
||||||
|
|
||||||
// icmpFamily holds the network parameters and cached detection result for one address family.
|
|
||||||
type icmpFamily struct {
|
|
||||||
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
|
||||||
dgramNetwork string // e.g. "udp4" or "udp6"
|
|
||||||
listenAddr string // "0.0.0.0" or "::"
|
|
||||||
echoType icmp.Type // outgoing echo request type
|
|
||||||
replyType icmp.Type // expected echo reply type
|
|
||||||
proto int // IANA protocol number for parsing replies
|
|
||||||
isIPv6 bool
|
|
||||||
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
icmpV4 = icmpFamily{
|
|
||||||
rawNetwork: "ip4:icmp",
|
|
||||||
dgramNetwork: "udp4",
|
|
||||||
listenAddr: "0.0.0.0",
|
|
||||||
echoType: ipv4.ICMPTypeEcho,
|
|
||||||
replyType: ipv4.ICMPTypeEchoReply,
|
|
||||||
proto: 1,
|
|
||||||
}
|
|
||||||
icmpV6 = icmpFamily{
|
|
||||||
rawNetwork: "ip6:ipv6-icmp",
|
|
||||||
dgramNetwork: "udp6",
|
|
||||||
listenAddr: "::",
|
|
||||||
echoType: ipv6.ICMPTypeEchoRequest,
|
|
||||||
replyType: ipv6.ICMPTypeEchoReply,
|
|
||||||
proto: 58,
|
|
||||||
isIPv6: true,
|
|
||||||
}
|
|
||||||
icmpModeMu sync.Mutex
|
|
||||||
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
|
||||||
return icmp.ListenPacket(network, listenAddr)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// probeICMP sends an ICMP echo request and measures round-trip response.
|
|
||||||
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
|
||||||
// unprivileged datagram, or exec fallback) is detected once per address
|
|
||||||
// family and cached for subsequent probes.
|
|
||||||
// Returns response in milliseconds, or -1 on failure.
|
|
||||||
func probeICMP(target string) float64 {
|
|
||||||
family, ip := resolveICMPTarget(target)
|
|
||||||
if family == nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
icmpModeMu.Lock()
|
|
||||||
if family.mode == icmpUntried {
|
|
||||||
family.mode = detectICMPMode(family, icmpListen)
|
|
||||||
}
|
|
||||||
mode := family.mode
|
|
||||||
icmpModeMu.Unlock()
|
|
||||||
|
|
||||||
switch mode {
|
|
||||||
case icmpRaw:
|
|
||||||
return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
|
|
||||||
case icmpDatagram:
|
|
||||||
return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
|
|
||||||
case icmpExecFallback:
|
|
||||||
return probeICMPExec(target, family.isIPv6)
|
|
||||||
default:
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
|
||||||
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
|
||||||
func resolveICMPTarget(target string) (*icmpFamily, net.IP) {
|
|
||||||
if ip := net.ParseIP(target); ip != nil {
|
|
||||||
if ip.To4() != nil {
|
|
||||||
return &icmpV4, ip.To4()
|
|
||||||
}
|
|
||||||
return &icmpV6, ip
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := net.LookupIP(target)
|
|
||||||
if err != nil || len(ips) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
for _, ip := range ips {
|
|
||||||
if v4 := ip.To4(); v4 != nil {
|
|
||||||
return &icmpV4, v4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &icmpV6, ips[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
|
||||||
label := "IPv4"
|
|
||||||
if family.isIPv6 {
|
|
||||||
label = "IPv6"
|
|
||||||
}
|
|
||||||
|
|
||||||
if conn, err := listen(family.rawNetwork, family.listenAddr); err == nil {
|
|
||||||
conn.Close()
|
|
||||||
slog.Info("ICMP probe using raw socket", "family", label)
|
|
||||||
return icmpRaw
|
|
||||||
} else {
|
|
||||||
slog.Debug("ICMP raw socket unavailable", "family", label, "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if conn, err := listen(family.dgramNetwork, family.listenAddr); err == nil {
|
|
||||||
conn.Close()
|
|
||||||
slog.Info("ICMP probe using unprivileged datagram socket", "family", label)
|
|
||||||
return icmpDatagram
|
|
||||||
} else {
|
|
||||||
slog.Debug("ICMP datagram socket unavailable", "family", label, "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("ICMP probe falling back to system ping command", "family", label)
|
|
||||||
return icmpExecFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
|
||||||
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
|
|
||||||
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
// Build ICMP echo request
|
|
||||||
msg := &icmp.Message{
|
|
||||||
Type: family.echoType,
|
|
||||||
Code: 0,
|
|
||||||
Body: &icmp.Echo{
|
|
||||||
ID: os.Getpid() & 0xffff,
|
|
||||||
Seq: 1,
|
|
||||||
Data: []byte("beszel-probe"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
msgBytes, err := msg.Marshal(nil)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set deadline before sending
|
|
||||||
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read reply
|
|
||||||
buf := make([]byte, 1500)
|
|
||||||
for {
|
|
||||||
n, _, err := conn.ReadFrom(buf)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if reply.Type == family.replyType {
|
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
|
||||||
}
|
|
||||||
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeICMPExec falls back to the system ping command. Returns -1 on failure.
|
|
||||||
func probeICMPExec(target string, isIPv6 bool) float64 {
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "windows":
|
|
||||||
if isIPv6 {
|
|
||||||
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
|
|
||||||
} else {
|
|
||||||
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
|
||||||
}
|
|
||||||
default: // linux, darwin, freebsd
|
|
||||||
if isIPv6 {
|
|
||||||
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
|
||||||
} else {
|
|
||||||
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
// If ping fails but we got output, still try to parse
|
|
||||||
if len(output) == 0 {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matches := pingTimeRegex.FindSubmatch(output)
|
|
||||||
if len(matches) >= 2 {
|
|
||||||
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: use wall clock time if ping succeeded but parsing failed
|
|
||||||
if err == nil {
|
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestProbeTaskAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
|
||||||
task := &probeTask{}
|
|
||||||
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-90 * time.Second)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-10 * time.Second)})
|
|
||||||
|
|
||||||
agg := task.aggregateLocked(time.Minute, now)
|
|
||||||
require.True(t, agg.hasData())
|
|
||||||
assert.Equal(t, 2, agg.totalCount)
|
|
||||||
assert.Equal(t, 1, agg.successCount)
|
|
||||||
assert.Equal(t, 20.0, agg.result()[0])
|
|
||||||
assert.Equal(t, 20.0, agg.result()[1])
|
|
||||||
assert.Equal(t, 20.0, agg.result()[2])
|
|
||||||
assert.Equal(t, 50.0, agg.result()[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeTaskAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
|
|
||||||
task := &probeTask{}
|
|
||||||
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-11 * time.Minute)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-9 * time.Minute)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 40, timestamp: now.Add(-5 * time.Minute)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-90 * time.Second)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 30, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
|
|
||||||
agg := task.aggregateLocked(10*time.Minute, now)
|
|
||||||
require.True(t, agg.hasData())
|
|
||||||
assert.Equal(t, 4, agg.totalCount)
|
|
||||||
assert.Equal(t, 3, agg.successCount)
|
|
||||||
assert.Equal(t, 30.0, agg.result()[0])
|
|
||||||
assert.Equal(t, 20.0, agg.result()[1])
|
|
||||||
assert.Equal(t, 40.0, agg.result()[2])
|
|
||||||
assert.Equal(t, 25.0, agg.result()[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
|
||||||
task := &probeTask{}
|
|
||||||
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-10 * time.Minute)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now})
|
|
||||||
|
|
||||||
require.Len(t, task.samples, 1)
|
|
||||||
assert.Equal(t, 20.0, task.samples[0].responseMs)
|
|
||||||
|
|
||||||
agg := task.aggregateLocked(10*time.Minute, now)
|
|
||||||
require.True(t, agg.hasData())
|
|
||||||
assert.Equal(t, 2, agg.totalCount)
|
|
||||||
assert.Equal(t, 2, agg.successCount)
|
|
||||||
assert.Equal(t, 15.0, agg.result()[0])
|
|
||||||
assert.Equal(t, 10.0, agg.result()[1])
|
|
||||||
assert.Equal(t, 20.0, agg.result()[2])
|
|
||||||
assert.Equal(t, 0.0, agg.result()[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-30 * time.Minute)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-9 * time.Minute)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 40, timestamp: now.Add(-5 * time.Minute)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-90 * time.Second)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: 30, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
|
|
||||||
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
|
|
||||||
|
|
||||||
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
|
||||||
result, ok := results["probe-1"]
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Len(t, result, 5)
|
|
||||||
assert.Equal(t, 30.0, result[0])
|
|
||||||
assert.Equal(t, 25.0, result[1])
|
|
||||||
assert.Equal(t, 10.0, result[2])
|
|
||||||
assert.Equal(t, 40.0, result[3])
|
|
||||||
assert.Equal(t, 20.0, result[4])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
|
||||||
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-10 * time.Second)})
|
|
||||||
|
|
||||||
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
|
|
||||||
|
|
||||||
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
|
||||||
result, ok := results["probe-1"]
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Len(t, result, 5)
|
|
||||||
assert.Equal(t, 0.0, result[0])
|
|
||||||
assert.Equal(t, 0.0, result[1])
|
|
||||||
assert.Equal(t, 0.0, result[2])
|
|
||||||
assert.Equal(t, 0.0, result[3])
|
|
||||||
assert.Equal(t, 100.0, result[4])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeConfigResultKeyUsesSyncedID(t *testing.T) {
|
|
||||||
cfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
|
||||||
assert.Equal(t, "probe-1", cfg.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeManagerSyncProbesSkipsConfigsWithoutStableID(t *testing.T) {
|
|
||||||
validCfg := probe.Config{ID: "probe-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
invalidCfg := probe.Config{Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
|
|
||||||
pm := newProbeManager()
|
|
||||||
pm.SyncProbes([]probe.Config{validCfg, invalidCfg})
|
|
||||||
defer pm.Stop()
|
|
||||||
|
|
||||||
_, validExists := pm.probes[validCfg.ID]
|
|
||||||
_, invalidExists := pm.probes[invalidCfg.ID]
|
|
||||||
assert.True(t, validExists)
|
|
||||||
assert.False(t, invalidExists)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeManagerSyncProbesStopsRemovedTasksButKeepsExisting(t *testing.T) {
|
|
||||||
keepCfg := probe.Config{ID: "probe-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
removeCfg := probe.Config{ID: "probe-2", Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
|
|
||||||
keptTask := &probeTask{config: keepCfg, cancel: make(chan struct{})}
|
|
||||||
removedTask := &probeTask{config: removeCfg, cancel: make(chan struct{})}
|
|
||||||
pm := &ProbeManager{
|
|
||||||
probes: map[string]*probeTask{
|
|
||||||
keepCfg.ID: keptTask,
|
|
||||||
removeCfg.ID: removedTask,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.SyncProbes([]probe.Config{keepCfg})
|
|
||||||
|
|
||||||
assert.Same(t, keptTask, pm.probes[keepCfg.ID])
|
|
||||||
_, exists := pm.probes[removeCfg.ID]
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-removedTask.cancel:
|
|
||||||
default:
|
|
||||||
t.Fatal("expected removed probe task to be cancelled")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-keptTask.cancel:
|
|
||||||
t.Fatal("expected existing probe task to remain active")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeManagerSyncProbesRestartsChangedConfig(t *testing.T) {
|
|
||||||
originalCfg := probe.Config{ID: "probe-1", Target: "ignored-a", Protocol: "noop", Interval: 10}
|
|
||||||
updatedCfg := probe.Config{ID: "probe-1", Target: "ignored-b", Protocol: "noop", Interval: 10}
|
|
||||||
originalTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
|
|
||||||
pm := &ProbeManager{
|
|
||||||
probes: map[string]*probeTask{
|
|
||||||
originalCfg.ID: originalTask,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.SyncProbes([]probe.Config{updatedCfg})
|
|
||||||
defer pm.Stop()
|
|
||||||
|
|
||||||
restartedTask := pm.probes[updatedCfg.ID]
|
|
||||||
assert.NotSame(t, originalTask, restartedTask)
|
|
||||||
assert.Equal(t, updatedCfg, restartedTask.config)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-originalTask.cancel:
|
|
||||||
default:
|
|
||||||
t.Fatal("expected changed probe task to be cancelled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
pm := &ProbeManager{
|
|
||||||
probes: make(map[string]*probeTask),
|
|
||||||
httpClient: server.Client(),
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := pm.ApplySync(probe.SyncRequest{
|
|
||||||
Action: probe.SyncActionUpsert,
|
|
||||||
Config: probe.Config{ID: "probe-1", Target: server.URL, Protocol: "http", Interval: 10},
|
|
||||||
RunNow: true,
|
|
||||||
})
|
|
||||||
defer pm.Stop()
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, resp.Result, 5)
|
|
||||||
assert.GreaterOrEqual(t, resp.Result[0], 0.0)
|
|
||||||
assert.Equal(t, 0.0, resp.Result[4])
|
|
||||||
|
|
||||||
task := pm.probes["probe-1"]
|
|
||||||
require.NotNil(t, task)
|
|
||||||
task.mu.Lock()
|
|
||||||
defer task.mu.Unlock()
|
|
||||||
require.Len(t, task.samples, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeManagerApplySyncDeleteRemovesTask(t *testing.T) {
|
|
||||||
config := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
|
||||||
task := &probeTask{config: config, cancel: make(chan struct{})}
|
|
||||||
pm := &ProbeManager{
|
|
||||||
probes: map[string]*probeTask{config.ID: task},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := pm.ApplySync(probe.SyncRequest{
|
|
||||||
Action: probe.SyncActionDelete,
|
|
||||||
Config: probe.Config{ID: config.ID},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, exists := pm.probes[config.ID]
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-task.cancel:
|
|
||||||
default:
|
|
||||||
t.Fatal("expected deleted probe task to be cancelled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeHTTP(t *testing.T) {
|
|
||||||
t.Run("success", func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
responseMs := probeHTTP(server.Client(), server.URL)
|
|
||||||
assert.GreaterOrEqual(t, responseMs, 0.0)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("server error", func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, -1.0, probeHTTP(server.Client(), server.URL))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProbeTCP(t *testing.T) {
|
|
||||||
t.Run("success", func(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer listener.Close()
|
|
||||||
|
|
||||||
accepted := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(accepted)
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err == nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
|
||||||
responseMs := probeTCP("127.0.0.1", port)
|
|
||||||
assert.GreaterOrEqual(t, responseMs, 0.0)
|
|
||||||
<-accepted
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("connection failure", func(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
|
||||||
require.NoError(t, listener.Close())
|
|
||||||
|
|
||||||
assert.Equal(t, -1.0, probeTCP("127.0.0.1", port))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
2
go.mod
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
|
||||||
|
|||||||
@@ -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,56 +0,0 @@
|
|||||||
package probe
|
|
||||||
|
|
||||||
type SyncAction uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
// SyncActionReplace indicates a full sync where the provided configs should replace all existing probes for the system.
|
|
||||||
SyncActionReplace SyncAction = iota
|
|
||||||
// SyncActionUpsert indicates an incremental sync where the provided config should be added or updated.
|
|
||||||
SyncActionUpsert
|
|
||||||
// SyncActionDelete indicates an incremental sync where the provided config should be removed.
|
|
||||||
SyncActionDelete
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config defines a network probe task sent from hub to agent.
|
|
||||||
type Config struct {
|
|
||||||
// ID is the stable network_probes record ID generated by the hub.
|
|
||||||
ID string `cbor:"0,keyasint"`
|
|
||||||
Target string `cbor:"1,keyasint"`
|
|
||||||
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", or "http"
|
|
||||||
Port uint16 `cbor:"3,keyasint,omitempty"`
|
|
||||||
Interval uint16 `cbor:"4,keyasint"` // seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncRequest defines an incremental or full probe sync request sent to the agent.
|
|
||||||
type SyncRequest struct {
|
|
||||||
Action SyncAction `cbor:"0,keyasint"`
|
|
||||||
Config Config `cbor:"1,keyasint,omitempty"`
|
|
||||||
Configs []Config `cbor:"2,keyasint,omitempty"`
|
|
||||||
RunNow bool `cbor:"3,keyasint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncResponse returns the immediate result for an upsert when requested.
|
|
||||||
type SyncResponse struct {
|
|
||||||
Result Result `cbor:"0,keyasint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result holds aggregated probe results for a single target.
|
|
||||||
//
|
|
||||||
// 0: avg response in ms
|
|
||||||
//
|
|
||||||
// 1: average response over the last hour in ms
|
|
||||||
//
|
|
||||||
// 2: min response over the last hour in ms
|
|
||||||
//
|
|
||||||
// 3: max response over the last hour in ms
|
|
||||||
//
|
|
||||||
// 4: packet loss percentage over the last hour (0-100)
|
|
||||||
type Result []float64
|
|
||||||
|
|
||||||
// Get returns the value at the specified index or 0 if the index is out of range.
|
|
||||||
func (r Result) Get(index int) float64 {
|
|
||||||
if index < len(r) {
|
|
||||||
return r[index]
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -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,120 +0,0 @@
|
|||||||
package hub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// generateProbeID creates a stable hash ID for a probe based on its configuration and the system it belongs to.
|
|
||||||
func generateProbeID(systemId string, config probe.Config) string {
|
|
||||||
intervalStr := strconv.FormatUint(uint64(config.Interval), 10)
|
|
||||||
portStr := strconv.FormatUint(uint64(config.Port), 10)
|
|
||||||
return systems.MakeStableHashId(systemId, config.Protocol, config.Target, portStr, intervalStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// bindNetworkProbesEvents keeps probe records and agent probe state in sync.
|
|
||||||
func bindNetworkProbesEvents(hub *Hub) {
|
|
||||||
// on create, make sure the id is set to a stable hash
|
|
||||||
hub.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
|
||||||
systemID := e.Record.GetString("system")
|
|
||||||
config := probeConfigFromRecord(e.Record)
|
|
||||||
id := generateProbeID(systemID, *config)
|
|
||||||
e.Record.Set("id", id)
|
|
||||||
return e.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// sync probe to agent on creation and persist the first result immediately when available
|
|
||||||
hub.OnRecordCreateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
|
|
||||||
err := e.Next()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !e.Record.GetBool("enabled") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result, err := hub.upsertNetworkProbe(e.Record, true)
|
|
||||||
if err != nil {
|
|
||||||
hub.Logger().Warn("failed to sync probe to agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
setProbeResultFields(e.Record, *result)
|
|
||||||
if err := e.App.SaveNoValidate(e.Record); err != nil {
|
|
||||||
hub.Logger().Warn("failed to save initial probe result", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
|
|
||||||
err := e.Next()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if e.Record.GetBool("enabled") {
|
|
||||||
_, err = hub.upsertNetworkProbe(e.Record, false)
|
|
||||||
} else {
|
|
||||||
err = hub.deleteNetworkProbe(e.Record)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
hub.Logger().Warn("failed to sync updated probe to agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// sync probe to agent on delete
|
|
||||||
hub.OnRecordDeleteRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
|
|
||||||
err := e.Next()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := hub.deleteNetworkProbe(e.Record); err != nil {
|
|
||||||
hub.Logger().Warn("failed to delete probe on agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeConfigFromRecord builds a probe config from a network_probes record.
|
|
||||||
func probeConfigFromRecord(record *core.Record) *probe.Config {
|
|
||||||
return &probe.Config{
|
|
||||||
ID: record.Id,
|
|
||||||
Target: record.GetString("target"),
|
|
||||||
Protocol: record.GetString("protocol"),
|
|
||||||
Port: uint16(record.GetInt("port")),
|
|
||||||
Interval: uint16(record.GetInt("interval")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setProbeResultFields stores the latest probe result values on the record.
|
|
||||||
func setProbeResultFields(record *core.Record, result probe.Result) {
|
|
||||||
record.Set("res", result.Get(0))
|
|
||||||
record.Set("resAvg1h", result.Get(1))
|
|
||||||
record.Set("resMin1h", result.Get(2))
|
|
||||||
record.Set("resMax1h", result.Get(3))
|
|
||||||
record.Set("loss1h", result.Get(4))
|
|
||||||
}
|
|
||||||
|
|
||||||
// upsertNetworkProbe applies the record's probe config to the target system.
|
|
||||||
func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) (*probe.Result, error) {
|
|
||||||
systemID := record.GetString("system")
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return system.UpsertNetworkProbe(*probeConfigFromRecord(record), runNow)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteNetworkProbe removes the record's probe from the target system.
|
|
||||||
func (h *Hub) deleteNetworkProbe(record *core.Record) error {
|
|
||||||
systemID := record.GetString("system")
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return system.DeleteNetworkProbe(record.Id)
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package hub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateProbeID(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
systemID string
|
|
||||||
config probe.Config
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "HTTP probe on example.com",
|
|
||||||
systemID: "sys123",
|
|
||||||
config: probe.Config{
|
|
||||||
Protocol: "http",
|
|
||||||
Target: "example.com",
|
|
||||||
Port: 80,
|
|
||||||
Interval: 60,
|
|
||||||
},
|
|
||||||
expected: "d5f27931",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HTTP probe on example.com with different system ID",
|
|
||||||
systemID: "sys1234",
|
|
||||||
config: probe.Config{
|
|
||||||
Protocol: "http",
|
|
||||||
Target: "example.com",
|
|
||||||
Port: 80,
|
|
||||||
Interval: 60,
|
|
||||||
},
|
|
||||||
expected: "6f8b17f1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Same probe, different interval",
|
|
||||||
systemID: "sys1234",
|
|
||||||
config: probe.Config{
|
|
||||||
Protocol: "http",
|
|
||||||
Target: "example.com",
|
|
||||||
Port: 80,
|
|
||||||
Interval: 120,
|
|
||||||
},
|
|
||||||
expected: "6d4baf8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ICMP probe on 1.1.1.1",
|
|
||||||
systemID: "sys456",
|
|
||||||
config: probe.Config{
|
|
||||||
Protocol: "icmp",
|
|
||||||
Target: "1.1.1.1",
|
|
||||||
Port: 0,
|
|
||||||
Interval: 10,
|
|
||||||
},
|
|
||||||
expected: "80b5836b",
|
|
||||||
}, {
|
|
||||||
name: "ICMP probe on 1.1.1.1 with different system ID",
|
|
||||||
systemID: "sys4567",
|
|
||||||
config: probe.Config{
|
|
||||||
Protocol: "icmp",
|
|
||||||
Target: "1.1.1.1",
|
|
||||||
Port: 0,
|
|
||||||
Interval: 10,
|
|
||||||
},
|
|
||||||
expected: "a6652680",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := generateProbeID(tt.systemID, tt.config)
|
|
||||||
assert.Equal(t, tt.expected, got, "generateProbeID() = %v, want %v", got, tt.expected)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"log/slog"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,7 +18,6 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
@@ -31,7 +29,6 @@ import (
|
|||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,12 +238,6 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.Probes != nil {
|
|
||||||
if err := updateNetworkProbesRecords(txApp, data.Probes, sys.Id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
systemRecord.Set("status", up)
|
systemRecord.Set("status", up)
|
||||||
systemRecord.Set("info", data.Info)
|
systemRecord.Set("info", data.Info)
|
||||||
@@ -298,7 +289,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
for i, service := range data {
|
for i, service := range data {
|
||||||
suffix := fmt.Sprintf("%d", i)
|
suffix := fmt.Sprintf("%d", i)
|
||||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||||
params["id"+suffix] = MakeStableHashId(systemId, service.Name)
|
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
||||||
params["name"+suffix] = service.Name
|
params["name"+suffix] = service.Name
|
||||||
params["state"+suffix] = service.State
|
params["state"+suffix] = service.State
|
||||||
params["sub"+suffix] = service.Sub
|
params["sub"+suffix] = service.Sub
|
||||||
@@ -315,88 +306,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, systemId string) error {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
collectionName := "network_probes"
|
|
||||||
|
|
||||||
// If realtime updates are active, we save via PocketBase records to trigger realtime events.
|
|
||||||
// Otherwise we can do a more efficient direct update via SQL
|
|
||||||
realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool {
|
|
||||||
slog.Info("Checking realtime subscription filter for network probes", "filterQuery", filterQuery)
|
|
||||||
return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId)
|
|
||||||
})
|
|
||||||
|
|
||||||
var db dbx.Builder
|
|
||||||
var nowString string
|
|
||||||
var updateQuery *dbx.Query
|
|
||||||
if !realtimeActive {
|
|
||||||
db = app.DB()
|
|
||||||
nowString = time.Now().UTC().Format(types.DefaultDateLayout)
|
|
||||||
sql := fmt.Sprintf("UPDATE %s SET resAvg={:res}, resMin1h={:resMin1h}, resMax1h={:resMax1h}, resAvg1h={:resAvg1h}, loss1h={:loss1h}, updated={:updated} WHERE id={:id}", collectionName)
|
|
||||||
updateQuery = db.NewQuery(sql)
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert network probe stats records
|
|
||||||
switch realtimeActive {
|
|
||||||
case true:
|
|
||||||
collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats")
|
|
||||||
record := core.NewRecord(collection)
|
|
||||||
record.Set("system", systemId)
|
|
||||||
record.Set("stats", data)
|
|
||||||
record.Set("type", "1m")
|
|
||||||
err = app.SaveNoValidate(record)
|
|
||||||
default:
|
|
||||||
if dataJSON, marshalErr := json.Marshal(data); marshalErr == nil {
|
|
||||||
sql := "INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})"
|
|
||||||
insertQuery := db.NewQuery(sql)
|
|
||||||
_, err = insertQuery.Bind(dbx.Params{
|
|
||||||
"system": systemId,
|
|
||||||
"stats": dataJSON,
|
|
||||||
"type": "1m",
|
|
||||||
"created": nowString,
|
|
||||||
}).Execute()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// update network_probes records
|
|
||||||
for id, values := range data {
|
|
||||||
switch realtimeActive {
|
|
||||||
case true:
|
|
||||||
var record *core.Record
|
|
||||||
record, err = app.FindRecordById(collectionName, id)
|
|
||||||
if err == nil {
|
|
||||||
record.Set("res", values.Get(0))
|
|
||||||
record.Set("resAvg1h", values.Get(1))
|
|
||||||
record.Set("resMin1h", values.Get(2))
|
|
||||||
record.Set("resMax1h", values.Get(3))
|
|
||||||
record.Set("loss1h", values.Get(4))
|
|
||||||
err = app.SaveNoValidate(record)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
_, err = updateQuery.Bind(dbx.Params{
|
|
||||||
"id": id,
|
|
||||||
"res": values.Get(0),
|
|
||||||
"resAvg1h": values.Get(1),
|
|
||||||
"resMin1h": values.Get(2),
|
|
||||||
"resMax1h": values.Get(3),
|
|
||||||
"loss1h": values.Get(4),
|
|
||||||
"updated": nowString,
|
|
||||||
}).Execute()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", id, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createContainerRecords creates container records
|
// createContainerRecords creates container records
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
@@ -631,7 +540,7 @@ func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error)
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakeStableHashId(strings ...string) string {
|
func makeStableHashId(strings ...string) string {
|
||||||
hash := fnv.New32a()
|
hash := fnv.New32a()
|
||||||
for _, str := range strings {
|
for _, str := range strings {
|
||||||
hash.Write([]byte(str))
|
hash.Write([]byte(str))
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ import (
|
|||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -319,17 +317,6 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
if err := sm.AddRecord(systemRecord, system); err != nil {
|
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync network probes to the newly connected agent
|
|
||||||
go func() {
|
|
||||||
configs := sm.GetProbeConfigsForSystem(systemId)
|
|
||||||
if len(configs) > 0 {
|
|
||||||
if err := system.SyncNetworkProbes(configs); err != nil {
|
|
||||||
sm.hub.Logger().Warn("failed to sync probes to agent", "system", systemId, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,16 +329,6 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
|
||||||
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
|
||||||
var configs []probe.Config
|
|
||||||
_ = sm.hub.DB().
|
|
||||||
NewQuery("SELECT id, target, protocol, port, interval FROM network_probes WHERE system = {:system} AND enabled = true").
|
|
||||||
Bind(dbx.Params{"system": systemID}).
|
|
||||||
All(&configs)
|
|
||||||
return configs
|
|
||||||
}
|
|
||||||
|
|
||||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||||
func (sm *SystemManager) createSSHClientConfig() error {
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
privateKey, err := sm.hub.GetSSHKey("")
|
privateKey, err := sm.hub.GetSSHKey("")
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SyncNetworkProbes sends probe configurations to the agent.
|
|
||||||
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
|
||||||
_, err := sys.syncNetworkProbes(probe.SyncRequest{Action: probe.SyncActionReplace, Configs: configs})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpsertNetworkProbe sends a single probe configuration change to the agent.
|
|
||||||
func (sys *System) UpsertNetworkProbe(config probe.Config, runNow bool) (*probe.Result, error) {
|
|
||||||
resp, err := sys.syncNetworkProbes(probe.SyncRequest{
|
|
||||||
Action: probe.SyncActionUpsert,
|
|
||||||
Config: config,
|
|
||||||
RunNow: runNow,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(resp.Result) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
result := resp.Result
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteNetworkProbe removes a single probe task from the agent.
|
|
||||||
func (sys *System) DeleteNetworkProbe(id string) error {
|
|
||||||
_, err := sys.syncNetworkProbes(probe.SyncRequest{
|
|
||||||
Action: probe.SyncActionDelete,
|
|
||||||
Config: probe.Config{ID: id},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sys *System) syncNetworkProbes(req probe.SyncRequest) (probe.SyncResponse, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
var result probe.SyncResponse
|
|
||||||
return result, sys.request(ctx, common.SyncNetworkProbes, req, &result)
|
|
||||||
}
|
|
||||||
@@ -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,58 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package records_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/records"
|
|
||||||
"github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAverageProbeStats(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
rm := records.NewRecordManager(hub)
|
|
||||||
user, err := tests.CreateUser(hub, "probe-avg@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "probe-avg-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
recordA, err := tests.CreateRecord(hub, "network_probe_stats", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": `{"icmp:1.1.1.1":[10,80,8,14,1]}`,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
recordB, err := tests.CreateRecord(hub, "network_probe_stats", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": `{"icmp:1.1.1.1":[40,100,9,50,5]}`,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result := rm.AverageProbeStats(hub.DB(), records.RecordIds{
|
|
||||||
{Id: recordA.Id},
|
|
||||||
{Id: recordB.Id},
|
|
||||||
})
|
|
||||||
|
|
||||||
stats, ok := result["icmp:1.1.1.1"]
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Len(t, stats, 5)
|
|
||||||
assert.Equal(t, 25.0, stats[0])
|
|
||||||
assert.Equal(t, 90.0, stats[1])
|
|
||||||
assert.Equal(t, 8.0, stats[2])
|
|
||||||
assert.Equal(t, 50.0, stats[3])
|
|
||||||
assert.Equal(t, 3.0, stats[4])
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -71,7 +70,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
// wrap the operations in a transaction
|
// wrap the operations in a transaction
|
||||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
var err error
|
var err error
|
||||||
collections := [3]*core.Collection{}
|
collections := [2]*core.Collection{}
|
||||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -80,10 +79,6 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
collections[2], err = txApp.FindCachedCollectionByNameOrId("network_probe_stats")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var systems RecordIds
|
var systems RecordIds
|
||||||
db := txApp.DB()
|
db := txApp.DB()
|
||||||
|
|
||||||
@@ -143,9 +138,8 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
case "system_stats":
|
case "system_stats":
|
||||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||||
case "container_stats":
|
case "container_stats":
|
||||||
|
|
||||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||||
case "network_probe_stats":
|
|
||||||
longerRecord.Set("stats", rm.AverageProbeStats(db, recordIds))
|
|
||||||
}
|
}
|
||||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||||
log.Println("failed to save longer record", "err", err)
|
log.Println("failed to save longer record", "err", err)
|
||||||
@@ -506,80 +500,6 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// AverageProbeStats averages probe stats across multiple records.
|
|
||||||
// For each probe key: avg of average fields, min of mins, and max of maxes.
|
|
||||||
func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Result {
|
|
||||||
type probeValues struct {
|
|
||||||
sums probe.Result
|
|
||||||
counts []int
|
|
||||||
}
|
|
||||||
|
|
||||||
query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}")
|
|
||||||
|
|
||||||
// accumulate sums for each probe key across records
|
|
||||||
sums := make(map[string]*probeValues)
|
|
||||||
var row StatsRecord
|
|
||||||
for _, rec := range records {
|
|
||||||
row.Stats = row.Stats[:0]
|
|
||||||
query.Bind(dbx.Params{"id": rec.Id}).One(&row)
|
|
||||||
var rawStats map[string]probe.Result
|
|
||||||
if err := json.Unmarshal(row.Stats, &rawStats); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for key, vals := range rawStats {
|
|
||||||
s, ok := sums[key]
|
|
||||||
if !ok {
|
|
||||||
s = &probeValues{sums: make(probe.Result, len(vals)), counts: make([]int, len(vals))}
|
|
||||||
sums[key] = s
|
|
||||||
}
|
|
||||||
if len(vals) > len(s.sums) {
|
|
||||||
expandedSums := make(probe.Result, len(vals))
|
|
||||||
copy(expandedSums, s.sums)
|
|
||||||
s.sums = expandedSums
|
|
||||||
|
|
||||||
expandedCounts := make([]int, len(vals))
|
|
||||||
copy(expandedCounts, s.counts)
|
|
||||||
s.counts = expandedCounts
|
|
||||||
}
|
|
||||||
for i := range vals {
|
|
||||||
switch i {
|
|
||||||
case 2: // min fields
|
|
||||||
if s.counts[i] == 0 || vals[i] < s.sums[i] {
|
|
||||||
s.sums[i] = vals[i]
|
|
||||||
}
|
|
||||||
case 3: // max fields
|
|
||||||
if s.counts[i] == 0 || vals[i] > s.sums[i] {
|
|
||||||
s.sums[i] = vals[i]
|
|
||||||
}
|
|
||||||
default: // average fields
|
|
||||||
s.sums[i] += vals[i]
|
|
||||||
}
|
|
||||||
s.counts[i]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// compute final averages
|
|
||||||
result := make(map[string]probe.Result, len(sums))
|
|
||||||
for key, s := range sums {
|
|
||||||
if len(s.counts) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for i := range s.sums {
|
|
||||||
switch i {
|
|
||||||
case 2, 3: // min and max fields should not be averaged
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
if s.counts[i] > 0 {
|
|
||||||
s.sums[i] = twoDecimals(s.sums[i] / float64(s.counts[i]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result[key] = s.sums
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function AreaChartDefault({
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export default function LineChartDefault({
|
|||||||
filter,
|
filter,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
chartProps,
|
chartProps,
|
||||||
connectNulls,
|
|
||||||
}: {
|
}: {
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
@@ -63,7 +62,6 @@ export default function LineChartDefault({
|
|||||||
filter?: string
|
filter?: string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||||
connectNulls?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
@@ -107,7 +105,6 @@ export default function LineChartDefault({
|
|||||||
// stackId={dataPoint.stackId}
|
// stackId={dataPoint.stackId}
|
||||||
order={dataPoint.order || i}
|
order={dataPoint.order || i}
|
||||||
activeDot={dataPoint.activeDot ?? true}
|
activeDot={dataPoint.activeDot ?? true}
|
||||||
connectNulls={connectNulls}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -148,7 +145,7 @@ export default function LineChartDefault({
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { toast } from "../ui/use-toast"
|
|||||||
import { OtpInputForm } from "./otp-forms"
|
import { OtpInputForm } from "./otp-forms"
|
||||||
|
|
||||||
const honeypot = v.literal("")
|
const honeypot = v.literal("")
|
||||||
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`))
|
||||||
const passwordSchema = v.pipe(
|
const passwordSchema = v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
v.minLength(8, t`Password must be at least 8 characters.`),
|
v.minLength(8, t`Password must be at least 8 characters.`),
|
||||||
|
|||||||
@@ -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,238 +0,0 @@
|
|||||||
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { cn, decimalString, hourWithSeconds } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
GlobeIcon,
|
|
||||||
TimerIcon,
|
|
||||||
WifiOffIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
ArrowLeftRightIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
ServerIcon,
|
|
||||||
ClockIcon,
|
|
||||||
NetworkIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import type { NetworkProbeRecord } from "@/types"
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { toast } from "../ui/use-toast"
|
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
|
|
||||||
const protocolColors: Record<string, string> = {
|
|
||||||
icmp: "bg-blue-500/15 text-blue-400",
|
|
||||||
tcp: "bg-purple-500/15 text-purple-400",
|
|
||||||
http: "bg-green-500/15 text-green-400",
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteProbe(id: string) {
|
|
||||||
try {
|
|
||||||
await pb.collection("network_probes").delete(id)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<NetworkProbeRecord>[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
|
||||||
accessorFn: (record) => record.name || record.target,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
|
||||||
{getValue() as string}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "system",
|
|
||||||
accessorFn: (record) => record.system,
|
|
||||||
sortingFn: (a, b) => {
|
|
||||||
const allSystems = $allSystemsById.get()
|
|
||||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
|
||||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
|
||||||
return systemNameA.localeCompare(systemNameB)
|
|
||||||
},
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const allSystems = useStore($allSystemsById)
|
|
||||||
return <span className="ms-1.5 xl:w-20 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "target",
|
|
||||||
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
|
|
||||||
accessorFn: (record) => record.target,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<div className="ms-1.5 tabular-nums block truncate max-w-44" style={{ width: `${longestTarget / 1.05}ch` }}>
|
|
||||||
{getValue() as string}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "protocol",
|
|
||||||
accessorFn: (record) => record.protocol,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const protocol = getValue() as string
|
|
||||||
return (
|
|
||||||
<span className={cn("ms-1.5 px-2 py-0.5 rounded text-xs font-medium uppercase", protocolColors[protocol])}>
|
|
||||||
{protocol}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "interval",
|
|
||||||
accessorFn: (record) => record.interval,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
|
|
||||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "res",
|
|
||||||
accessorFn: (record) => record.res,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
|
|
||||||
cell: responseTimeCell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "res1h",
|
|
||||||
accessorFn: (record) => record.resAvg1h,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
|
|
||||||
cell: responseTimeCell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "max1h",
|
|
||||||
accessorFn: (record) => record.resMax1h,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
|
|
||||||
cell: responseTimeCell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "min1h",
|
|
||||||
accessorFn: (record) => record.resMin1h,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
|
|
||||||
cell: responseTimeCell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "loss",
|
|
||||||
accessorFn: (record) => record.loss1h,
|
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const { loss1h, res } = row.original
|
|
||||||
if (loss1h === undefined || (!res && !loss1h)) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
|
||||||
}
|
|
||||||
let color = "bg-green-500"
|
|
||||||
if (loss1h) {
|
|
||||||
color = loss1h > 20 ? "bg-red-500" : "bg-yellow-500"
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
|
||||||
{loss1h}%
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "updated",
|
|
||||||
invertSorting: true,
|
|
||||||
accessorFn: (record) => record.updated,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const timestamp = getValue() as number
|
|
||||||
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => null,
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-10"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
<Trans>Open menu</Trans>
|
|
||||||
</span>
|
|
||||||
<MoreHorizontalIcon className="w-5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
deleteProbe(row.original.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="me-2.5 size-4" />
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
|
|
||||||
const val = cell.getValue() as number | undefined
|
|
||||||
if (!val) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
|
||||||
}
|
|
||||||
let color = "bg-green-500"
|
|
||||||
if (val > 200) {
|
|
||||||
color = "bg-yellow-500"
|
|
||||||
}
|
|
||||||
if (val > 2000) {
|
|
||||||
color = "bg-red-500"
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
|
||||||
{decimalString(val, val < 100 ? 2 : 1).toLocaleString()}ms
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeaderButton({
|
|
||||||
column,
|
|
||||||
name,
|
|
||||||
Icon,
|
|
||||||
}: {
|
|
||||||
column: Column<NetworkProbeRecord>
|
|
||||||
name: string
|
|
||||||
Icon: React.ElementType
|
|
||||||
}) {
|
|
||||||
const isSorted = column.getIsSorted()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
|
||||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
|
||||||
)}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
{Icon && <Icon className="size-4" />}
|
|
||||||
{name}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import {
|
|
||||||
type ColumnFiltersState,
|
|
||||||
type ColumnDef,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
type Row,
|
|
||||||
type RowSelectionState,
|
|
||||||
type SortingState,
|
|
||||||
type Table as TableType,
|
|
||||||
useReactTable,
|
|
||||||
type VisibilityState,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
|
||||||
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
|
||||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
|
||||||
import { isReadOnlyUser } from "@/lib/api"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
|
||||||
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
|
|
||||||
import { Trash2Icon } from "lucide-react"
|
|
||||||
import type { NetworkProbeRecord } from "@/types"
|
|
||||||
import { AddProbeDialog } from "./probe-dialog"
|
|
||||||
|
|
||||||
export default function NetworkProbesTableNew({
|
|
||||||
systemId,
|
|
||||||
probes,
|
|
||||||
}: {
|
|
||||||
systemId?: string
|
|
||||||
probes: NetworkProbeRecord[]
|
|
||||||
}) {
|
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
|
||||||
`sort-np-${systemId ? 1 : 0}`,
|
|
||||||
[{ id: systemId ? "name" : "system", desc: false }],
|
|
||||||
sessionStorage
|
|
||||||
)
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
||||||
const { toast } = useToast()
|
|
||||||
const canManageProbes = !isReadOnlyUser()
|
|
||||||
|
|
||||||
const { longestName, longestTarget } = useMemo(() => {
|
|
||||||
let longestName = 0
|
|
||||||
let longestTarget = 0
|
|
||||||
for (const p of probes) {
|
|
||||||
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
|
|
||||||
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
|
|
||||||
}
|
|
||||||
return { longestName, longestTarget }
|
|
||||||
}, [probes])
|
|
||||||
|
|
||||||
// Filter columns based on whether systemId is provided
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
let columns = getProbeColumns(longestName, longestTarget)
|
|
||||||
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
|
||||||
columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
|
|
||||||
if (!canManageProbes) {
|
|
||||||
return columns
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectionColumn: ColumnDef<NetworkProbeRecord> = {
|
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
className="ms-2"
|
|
||||||
checked={table.getIsAllRowsSelected() || (table.getIsSomeRowsSelected() && "indeterminate")}
|
|
||||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
|
||||||
aria-label={t`Select all`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
aria-label={t`Select row`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
size: 44,
|
|
||||||
}
|
|
||||||
|
|
||||||
return [selectionColumn, ...columns]
|
|
||||||
}, [systemId, longestName, longestTarget, canManageProbes])
|
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
|
||||||
setDeleteOpen(false)
|
|
||||||
const selectedIds = Object.keys(rowSelection)
|
|
||||||
if (!selectedIds.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let batch = pb.createBatch()
|
|
||||||
let inBatch = 0
|
|
||||||
for (const id of selectedIds) {
|
|
||||||
batch.collection("network_probes").delete(id)
|
|
||||||
inBatch++
|
|
||||||
if (inBatch >= 20) {
|
|
||||||
await batch.send()
|
|
||||||
batch = pb.createBatch()
|
|
||||||
inBatch = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inBatch) {
|
|
||||||
await batch.send()
|
|
||||||
}
|
|
||||||
table.resetRowSelection()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t`Error`,
|
|
||||||
description: (err as Error)?.message || t`Failed to delete probes.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: probes,
|
|
||||||
columns,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
defaultColumn: {
|
|
||||||
sortUndefined: "last",
|
|
||||||
size: 900,
|
|
||||||
minSize: 0,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
columnVisibility,
|
|
||||||
rowSelection,
|
|
||||||
globalFilter,
|
|
||||||
},
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
|
||||||
const probe = row.original
|
|
||||||
const systemName = $allSystemsById.get()[probe.system]?.name ?? ""
|
|
||||||
const searchString = `${probe.name}${probe.target}${probe.protocol}${systemName}`.toLocaleLowerCase()
|
|
||||||
return (filterValue as string)
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.every((term) => searchString.includes(term))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const rows = table.getRowModel().rows
|
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
|
||||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
|
||||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
|
||||||
<div className="px-2 sm:px-1">
|
|
||||||
<CardTitle className="mb-2">
|
|
||||||
<Trans>Network Probes</Trans>
|
|
||||||
</CardTitle>
|
|
||||||
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
|
||||||
<Trans>Response time monitoring from agents.</Trans>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:ms-auto flex items-center gap-2">
|
|
||||||
{canManageProbes && table.getFilteredSelectedRowModel().rows.length > 0 && (
|
|
||||||
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-1 items-center gap-4 z-50 backdrop-blur-md shrink-0 md:static md:p-0 md:w-auto md:gap-3">
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="destructive" className="h-9 shrink-0">
|
|
||||||
<Trash2Icon className="size-4 shrink-0" />
|
|
||||||
<span className="ms-1">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
<Trans>This will permanently delete all selected records from the database.</Trans>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className={cn(buttonVariants({ variant: "destructive" }))}
|
|
||||||
onClick={handleBulkDelete}
|
|
||||||
>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{probes.length > 0 && (
|
|
||||||
<Input
|
|
||||||
placeholder={t`Filter...`}
|
|
||||||
value={globalFilter}
|
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-50"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canManageProbes ? <AddProbeDialog systemId={systemId} /> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<div className="rounded-md">
|
|
||||||
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} rowSelection={rowSelection} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const NetworkProbesTable = memo(function NetworkProbeTable({
|
|
||||||
table,
|
|
||||||
rows,
|
|
||||||
colLength,
|
|
||||||
rowSelection: _rowSelection,
|
|
||||||
}: {
|
|
||||||
table: TableType<NetworkProbeRecord>
|
|
||||||
rows: Row<NetworkProbeRecord>[]
|
|
||||||
colLength: number
|
|
||||||
rowSelection: RowSelectionState
|
|
||||||
}) {
|
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
|
||||||
count: rows.length,
|
|
||||||
estimateSize: () => 54,
|
|
||||||
getScrollElement: () => scrollRef.current,
|
|
||||||
overscan: 5,
|
|
||||||
})
|
|
||||||
const virtualRows = virtualizer.getVirtualItems()
|
|
||||||
|
|
||||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
|
||||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
|
||||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
|
||||||
(!rows.length || rows.length > 2) && "min-h-50"
|
|
||||||
)}
|
|
||||||
ref={scrollRef}
|
|
||||||
>
|
|
||||||
{/* add header height to table size */}
|
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
|
||||||
<table className="text-sm w-full h-full text-nowrap">
|
|
||||||
<NetworkProbeTableHead table={table} />
|
|
||||||
<TableBody>
|
|
||||||
{rows.length ? (
|
|
||||||
virtualRows.map((virtualRow) => {
|
|
||||||
const row = rows[virtualRow.index]
|
|
||||||
return (
|
|
||||||
<NetworkProbeTableRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
virtualRow={virtualRow}
|
|
||||||
isSelected={row.getIsSelected()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
|
||||||
<Trans>No results.</Trans>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord> }) {
|
|
||||||
return (
|
|
||||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead className="px-2" key={header.id}>
|
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</TableHead>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
|
||||||
row,
|
|
||||||
virtualRow,
|
|
||||||
isSelected,
|
|
||||||
}: {
|
|
||||||
row: Row<NetworkProbeRecord>
|
|
||||||
virtualRow: VirtualItem
|
|
||||||
isSelected: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<TableRow data-state={isSelected && "selected"} className="transition-opacity">
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
className="py-0"
|
|
||||||
style={{
|
|
||||||
width: `${cell.column.getSize()}px`,
|
|
||||||
height: virtualRow.size,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { ChevronDownIcon, ListIcon } from "lucide-react"
|
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
|
||||||
import { $systems } from "@/lib/stores"
|
|
||||||
import * as v from "valibot"
|
|
||||||
|
|
||||||
type ProbeProtocol = "icmp" | "tcp" | "http"
|
|
||||||
|
|
||||||
type ProbeValues = {
|
|
||||||
system: string
|
|
||||||
target: string
|
|
||||||
protocol: ProbeProtocol
|
|
||||||
port: number
|
|
||||||
interval: string
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const Schema = v.object({
|
|
||||||
system: v.string(),
|
|
||||||
target: v.string(),
|
|
||||||
protocol: v.picklist(["icmp", "tcp", "http"]),
|
|
||||||
port: v.number(),
|
|
||||||
interval: v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600)),
|
|
||||||
enabled: v.boolean(),
|
|
||||||
name: v.optional(v.string()),
|
|
||||||
})
|
|
||||||
|
|
||||||
function buildProbePayload(values: ProbeValues) {
|
|
||||||
const normalizedPort = (values.protocol === "tcp" || values.protocol === "http") && !values.port ? 443 : values.port
|
|
||||||
const payload = v.parse(Schema, {
|
|
||||||
system: values.system,
|
|
||||||
target: values.target,
|
|
||||||
protocol: values.protocol,
|
|
||||||
port: normalizedPort,
|
|
||||||
interval: values.interval,
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
const trimmedName = values.name?.trim()
|
|
||||||
const targetName = values.target.replace(/^https?:\/\//i, "")
|
|
||||||
if (trimmedName) {
|
|
||||||
payload.name = trimmedName
|
|
||||||
} else if (targetName !== values.target) {
|
|
||||||
payload.name = targetName
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
|
|
||||||
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",")
|
|
||||||
const target = rawTarget.trim()
|
|
||||||
if (!target) {
|
|
||||||
throw new Error(`Line ${lineNumber}: target is required`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inferredProtocol: ProbeProtocol = /^https?:\/\//i.test(target) ? "http" : "icmp"
|
|
||||||
const protocolValue = rawProtocol.trim().toLowerCase() || inferredProtocol
|
|
||||||
if (protocolValue !== "icmp" && protocolValue !== "tcp" && protocolValue !== "http") {
|
|
||||||
throw new Error(`Line ${lineNumber}: protocol must be icmp, tcp, or http`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const portValue = rawPort.trim()
|
|
||||||
if (protocolValue === "tcp") {
|
|
||||||
const port = portValue ? Number(portValue) : 443
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
||||||
throw new Error(`Line ${lineNumber}: TCP entries require a port between 1 and 65535`)
|
|
||||||
}
|
|
||||||
return buildProbePayload({
|
|
||||||
system,
|
|
||||||
target,
|
|
||||||
protocol: "tcp",
|
|
||||||
port,
|
|
||||||
interval: rawInterval.trim() || "30",
|
|
||||||
name: rawName.join(",").trim() || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildProbePayload({
|
|
||||||
system,
|
|
||||||
target,
|
|
||||||
protocol: protocolValue,
|
|
||||||
port: 0,
|
|
||||||
interval: rawInterval.trim() || "30",
|
|
||||||
name: rawName.join(",").trim() || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [bulkOpen, setBulkOpen] = useState(false)
|
|
||||||
const [protocol, setProtocol] = useState<string>("icmp")
|
|
||||||
const [target, setTarget] = useState("")
|
|
||||||
const [port, setPort] = useState("")
|
|
||||||
const [probeInterval, setProbeInterval] = useState("30")
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [bulkInput, setBulkInput] = useState("")
|
|
||||||
const [bulkLoading, setBulkLoading] = useState(false)
|
|
||||||
const [selectedSystemId, setSelectedSystemId] = useState("")
|
|
||||||
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
|
|
||||||
const systems = useStore($systems)
|
|
||||||
const { toast } = useToast()
|
|
||||||
const { t } = useLingui()
|
|
||||||
const targetName = target.replace(/^https?:\/\//, "")
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setProtocol("icmp")
|
|
||||||
setTarget("")
|
|
||||||
setPort("")
|
|
||||||
setProbeInterval("30")
|
|
||||||
setName("")
|
|
||||||
setSelectedSystemId("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetBulkForm = () => {
|
|
||||||
setBulkInput("")
|
|
||||||
setBulkSelectedSystemId("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const openBulkAdd = () => {
|
|
||||||
if (!systemId && selectedSystemId) {
|
|
||||||
setBulkSelectedSystemId(selectedSystemId)
|
|
||||||
}
|
|
||||||
setOpen(false)
|
|
||||||
setBulkOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAdd = () => {
|
|
||||||
setBulkOpen(false)
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = buildProbePayload({
|
|
||||||
system: systemId ?? selectedSystemId,
|
|
||||||
target,
|
|
||||||
protocol: protocol as ProbeProtocol,
|
|
||||||
port: protocol === "tcp" ? Number(port) : 0,
|
|
||||||
interval: probeInterval,
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
await pb.collection("network_probes").create(payload)
|
|
||||||
resetForm()
|
|
||||||
setOpen(false)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBulkSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setBulkLoading(true)
|
|
||||||
let closedForSubmit = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const system = systemId ?? bulkSelectedSystemId
|
|
||||||
const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim())
|
|
||||||
if (!rawLines.length) {
|
|
||||||
throw new Error("Enter at least one probe.")
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloads = rawLines.map((line, index) => parseBulkProbeLine(line, index + 1, system))
|
|
||||||
setBulkOpen(false)
|
|
||||||
closedForSubmit = true
|
|
||||||
let batch = pb.createBatch()
|
|
||||||
let inBatch = 0
|
|
||||||
for (const payload of payloads) {
|
|
||||||
batch.collection("network_probes").create(payload)
|
|
||||||
inBatch++
|
|
||||||
if (inBatch > 20) {
|
|
||||||
await batch.send()
|
|
||||||
batch = pb.createBatch()
|
|
||||||
inBatch = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inBatch) {
|
|
||||||
await batch.send()
|
|
||||||
}
|
|
||||||
|
|
||||||
resetBulkForm()
|
|
||||||
toast({ title: t`Probes created`, description: `${payloads.length} probe(s) added.` })
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (closedForSubmit) {
|
|
||||||
setBulkOpen(true)
|
|
||||||
}
|
|
||||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
|
||||||
} finally {
|
|
||||||
setBulkLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-0 rounded-lg">
|
|
||||||
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow">
|
|
||||||
{/* <PlusIcon className="size-4 me-1" /> */}
|
|
||||||
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
|
||||||
</Button>
|
|
||||||
<div className="w-px h-full bg-muted"></div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" className="px-2 rounded-s-none border-s-0" aria-label={t`More probe actions`}>
|
|
||||||
<ChevronDownIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={openBulkAdd}>
|
|
||||||
<ListIcon className="size-4 me-2" />
|
|
||||||
<Trans>Bulk Add</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Configure response monitoring from this agent.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
|
|
||||||
{!systemId && (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>System</Trans>
|
|
||||||
</Label>
|
|
||||||
<Select value={selectedSystemId} onValueChange={setSelectedSystemId} required>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t`Select a system`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{systems.map((sys) => (
|
|
||||||
<SelectItem key={sys.id} value={sys.id}>
|
|
||||||
{sys.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Target</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={target}
|
|
||||||
onChange={(e) => setTarget(e.target.value)}
|
|
||||||
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Protocol</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Select value={protocol} onValueChange={setProtocol}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="icmp">ICMP</SelectItem>
|
|
||||||
<SelectItem value="tcp">TCP</SelectItem>
|
|
||||||
<SelectItem value="http">HTTP</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{protocol === "tcp" && (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Port</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={port}
|
|
||||||
onChange={(e) => setPort(e.target.value)}
|
|
||||||
placeholder="443"
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Interval (seconds)</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={probeInterval}
|
|
||||||
onChange={(e) => setProbeInterval(e.target.value)}
|
|
||||||
min={1}
|
|
||||||
max={3600}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>Name (optional)</Trans>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder={targetName || t`e.g. Cloudflare DNS`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
|
|
||||||
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Sheet open={bulkOpen} onOpenChange={setBulkOpen}>
|
|
||||||
<SheetContent className="w-full sm:max-w-xl gap-0">
|
|
||||||
<SheetHeader className="border-b">
|
|
||||||
<SheetTitle>
|
|
||||||
<Trans>Bulk Add {{ foo: t`Network Probes` }}</Trans>
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
<Trans>
|
|
||||||
Paste one probe per line. See{" "}
|
|
||||||
<a href={"#bulk-add-probes-docs"} className="underline underline-offset-2">
|
|
||||||
the documentation
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<form onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
|
|
||||||
<div className="flex-1 space-y-4 overflow-auto p-4">
|
|
||||||
{!systemId && (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
<Trans>System</Trans>
|
|
||||||
</Label>
|
|
||||||
<Select value={bulkSelectedSystemId} onValueChange={setBulkSelectedSystemId} required>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t`Select a system`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{systems.map((sys) => (
|
|
||||||
<SelectItem key={sys.id} value={sys.id}>
|
|
||||||
{sys.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="bulk-probes">
|
|
||||||
<Trans>Entries</Trans>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="bulk-probes"
|
|
||||||
value={bulkInput}
|
|
||||||
onChange={(e) => setBulkInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleBulkSubmit(e)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-120 font-mono text-sm bg-muted/40"
|
|
||||||
style={{ maxHeight: `calc(100vh - 20rem)` }}
|
|
||||||
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Homepage"].join("\n")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
target[,protocol[,port[,interval[,name]]]] • TCP and HTTP default to port 443.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="border-t">
|
|
||||||
<Button type="submit" disabled={bulkLoading || (!systemId && !bulkSelectedSystemId)}>
|
|
||||||
<Trans>Add {{ foo: t`Network Probes` }}</Trans>
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</form>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 } from "react"
|
|
||||||
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
|
|
||||||
import { ActiveAlerts } from "@/components/active-alerts"
|
|
||||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
|
||||||
import { useNetworkProbesData } from "@/lib/use-network-probes"
|
|
||||||
|
|
||||||
export default memo(() => {
|
|
||||||
const { t } = useLingui()
|
|
||||||
const { probes } = useNetworkProbesData({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = `${t`Network Probes`} / Beszel`
|
|
||||||
}, [t])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<ActiveAlerts />
|
|
||||||
<NetworkProbesTableNew probes={probes} />
|
|
||||||
</div>
|
|
||||||
<FooterRepoLink />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -24,7 +24,7 @@ interface ShoutrrrUrlCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NotificationSchema = v.object({
|
const NotificationSchema = v.object({
|
||||||
emails: v.array(v.pipe(v.string(), v.email())),
|
emails: v.array(v.pipe(v.string(), v.rfcEmail())),
|
||||||
webhooks: v.array(v.pipe(v.string(), v.url())),
|
webhooks: v.array(v.pipe(v.string(), v.url())),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
|||||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTable } from "./system/lazy-tables"
|
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
||||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||||
import { ContainerIcon, CpuIcon, HardDriveIcon, NetworkIcon, TerminalSquareIcon } from "lucide-react"
|
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||||
import { GpuIcon } from "../ui/icons"
|
import { GpuIcon } from "../ui/icons"
|
||||||
import SystemdTable from "../systemd-table/systemd-table"
|
import SystemdTable from "../systemd-table/systemd-table"
|
||||||
import ContainersTable from "../containers-table/containers-table"
|
import ContainersTable from "../containers-table/containers-table"
|
||||||
@@ -65,7 +65,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const hasGpu = hasGpuData || hasGpuPowerData
|
const hasGpu = hasGpuData || hasGpuPowerData
|
||||||
|
|
||||||
// keep tabsRef in sync for keyboard navigation
|
// keep tabsRef in sync for keyboard navigation
|
||||||
const tabs = ["core", "network", "disk"]
|
const tabs = ["core", "disk"]
|
||||||
if (hasGpu) tabs.push("gpu")
|
if (hasGpu) tabs.push("gpu")
|
||||||
if (hasContainers) tabs.push("containers")
|
if (hasContainers) tabs.push("containers")
|
||||||
if (hasSystemd) tabs.push("services")
|
if (hasSystemd) tabs.push("services")
|
||||||
@@ -145,8 +145,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
|
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
|
||||||
|
|
||||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||||
|
|
||||||
<LazyNetworkProbesTable systemId={system.id} systemData={systemData} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,10 +157,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<CpuIcon className="size-3.5" />
|
<CpuIcon className="size-3.5" />
|
||||||
<Trans context="Core system metrics">Core</Trans>
|
<Trans context="Core system metrics">Core</Trans>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="network" className="w-full flex items-center gap-1.5">
|
|
||||||
<NetworkIcon className="size-3.5" />
|
|
||||||
<Trans>Network</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="disk" className="w-full flex items-center gap-1.5">
|
<TabsTrigger value="disk" className="w-full flex items-center gap-1.5">
|
||||||
<HardDriveIcon className="size-3.5" />
|
<HardDriveIcon className="size-3.5" />
|
||||||
<Trans>Disk</Trans>
|
<Trans>Disk</Trans>
|
||||||
@@ -190,26 +184,16 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
|
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<CpuChart {...coreProps} />
|
<CpuChart {...coreProps} />
|
||||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
|
||||||
<MemoryChart {...coreProps} />
|
<MemoryChart {...coreProps} />
|
||||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||||
|
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||||
<BatteryChart {...coreProps} />
|
<BatteryChart {...coreProps} />
|
||||||
|
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="network" forceMount className={activeTab === "network" ? "contents" : "hidden"}>
|
|
||||||
{mountedTabs.has("network") && (
|
|
||||||
<>
|
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
|
||||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
|
||||||
</div>
|
|
||||||
<LazyNetworkProbesTable systemId={system.id} systemData={systemData} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
||||||
{mountedTabs.has("disk") && (
|
{mountedTabs.has("disk") && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { timeTicks } from "d3-time"
|
||||||
import { getPbTimestamp, pb } from "@/lib/api"
|
import { getPbTimestamp, pb } from "@/lib/api"
|
||||||
import { chartTimeData } from "@/lib/utils"
|
import { chartTimeData } from "@/lib/utils"
|
||||||
import type { ChartData, ChartTimes, ContainerStatsRecord, NetworkProbeStatsRecord, SystemStatsRecord } from "@/types"
|
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -16,6 +17,27 @@ export const cache = new Map<
|
|||||||
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
|
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
|
||||||
>()
|
>()
|
||||||
|
|
||||||
|
// create ticks and domain for charts
|
||||||
|
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
||||||
|
const cached = cache.get("td") as ChartTimeData | undefined
|
||||||
|
if (cached && cached.chartTime === chartTime) {
|
||||||
|
if (!lastCreated || cached.time >= lastCreated) {
|
||||||
|
return cached.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const buffer = chartTime === "1m" ? 400 : 20_000
|
||||||
|
const now = new Date(Date.now())
|
||||||
|
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||||
|
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||||
|
const data = {
|
||||||
|
ticks,
|
||||||
|
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
|
||||||
|
}
|
||||||
|
cache.set("td", { time: now.getTime(), data, chartTime })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
|
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
|
||||||
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
|
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
|
||||||
export function appendData<T extends { created: string | number | null }>(
|
export function appendData<T extends { created: string | number | null }>(
|
||||||
@@ -44,12 +66,12 @@ export function appendData<T extends { created: string | number | null }>(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkProbeStatsRecord>(
|
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||||
collection: string,
|
collection: string,
|
||||||
systemId: string,
|
systemId: string,
|
||||||
chartTime: ChartTimes,
|
chartTime: ChartTimes
|
||||||
cachedStats?: { created: string | number | null }[]
|
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
|
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
|
||||||
const lastCached = cachedStats?.at(-1)?.created as number
|
const lastCached = cachedStats?.at(-1)?.created as number
|
||||||
return await pb.collection<T>(collection).getFullList({
|
return await pb.collection<T>(collection).getFullList({
|
||||||
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import LineChartDefault from "@/components/charts/line-chart"
|
|
||||||
import type { DataPoint } from "@/components/charts/line-chart"
|
|
||||||
import { toFixedFloat, decimalString } from "@/lib/utils"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import { ChartCard, FilterBar } from "../chart-card"
|
|
||||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
|
||||||
import { useMemo } from "react"
|
|
||||||
import { atom } from "nanostores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
|
|
||||||
const $filter = atom("")
|
|
||||||
|
|
||||||
type ProbeChartProps = {
|
|
||||||
probeStats: NetworkProbeStatsRecord[]
|
|
||||||
grid?: boolean
|
|
||||||
probes: NetworkProbeRecord[]
|
|
||||||
chartData: ChartData
|
|
||||||
empty: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProbeChartBaseProps = ProbeChartProps & {
|
|
||||||
valueIndex: number
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
tickFormatter: (value: number) => string
|
|
||||||
contentFormatter: ({ value }: { value: number | string }) => string | number
|
|
||||||
domain?: [number | "auto", number | "auto"]
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProbeChart({
|
|
||||||
probeStats,
|
|
||||||
grid,
|
|
||||||
probes,
|
|
||||||
chartData,
|
|
||||||
empty,
|
|
||||||
valueIndex,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
tickFormatter,
|
|
||||||
contentFormatter,
|
|
||||||
domain,
|
|
||||||
}: ProbeChartBaseProps) {
|
|
||||||
const filter = useStore($filter)
|
|
||||||
|
|
||||||
const { dataPoints, visibleKeys } = useMemo(() => {
|
|
||||||
const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h)
|
|
||||||
const count = sortedProbes.length
|
|
||||||
const points: DataPoint<NetworkProbeStatsRecord>[] = []
|
|
||||||
const visibleIDs: string[] = []
|
|
||||||
const filterTerms = filter
|
|
||||||
? filter
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.filter((term) => term.length > 0)
|
|
||||||
: []
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const p = sortedProbes[i]
|
|
||||||
const label = p.name || p.target
|
|
||||||
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => label.toLowerCase().includes(term))
|
|
||||||
if (filtered) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
visibleIDs.push(p.id)
|
|
||||||
points.push({
|
|
||||||
order: i,
|
|
||||||
label,
|
|
||||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-",
|
|
||||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { dataPoints: points, visibleKeys: visibleIDs }
|
|
||||||
}, [probes, filter, valueIndex])
|
|
||||||
|
|
||||||
const filteredProbeStats = useMemo(() => {
|
|
||||||
if (!visibleKeys.length) return probeStats
|
|
||||||
return probeStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
|
|
||||||
}, [probeStats, visibleKeys])
|
|
||||||
|
|
||||||
const legend = dataPoints.length < 10
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartCard
|
|
||||||
legend={legend}
|
|
||||||
cornerEl={<FilterBar store={$filter} />}
|
|
||||||
empty={empty}
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
grid={grid}
|
|
||||||
>
|
|
||||||
<LineChartDefault
|
|
||||||
chartData={chartData}
|
|
||||||
customData={filteredProbeStats}
|
|
||||||
dataPoints={dataPoints}
|
|
||||||
domain={domain ?? ["auto", "auto"]}
|
|
||||||
connectNulls
|
|
||||||
tickFormatter={tickFormatter}
|
|
||||||
contentFormatter={contentFormatter}
|
|
||||||
legend={legend}
|
|
||||||
filter={filter}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResponseChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
|
|
||||||
const { t } = useLingui()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProbeChart
|
|
||||||
probeStats={probeStats}
|
|
||||||
grid={grid}
|
|
||||||
probes={probes}
|
|
||||||
chartData={chartData}
|
|
||||||
empty={empty}
|
|
||||||
valueIndex={0}
|
|
||||||
title={t`Response`}
|
|
||||||
description={t`Average response time (ms)`}
|
|
||||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
|
||||||
contentFormatter={({ value }) => {
|
|
||||||
if (typeof value !== "number") {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return `${decimalString(value, 2)} ms`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
|
|
||||||
const { t } = useLingui()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProbeChart
|
|
||||||
probeStats={probeStats}
|
|
||||||
grid={grid}
|
|
||||||
probes={probes}
|
|
||||||
chartData={chartData}
|
|
||||||
empty={empty}
|
|
||||||
valueIndex={4}
|
|
||||||
title={t`Loss`}
|
|
||||||
description={t`Packet loss (%)`}
|
|
||||||
domain={[0, 100]}
|
|
||||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
|
|
||||||
contentFormatter={({ value }) => {
|
|
||||||
if (typeof value !== "number") {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return `${decimalString(value, 2)}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -120,7 +120,8 @@ export function TemperatureChart({
|
|||||||
label: key,
|
label: key,
|
||||||
dataKey: dataKeys[key],
|
dataKey: dataKeys[key],
|
||||||
color: colorMap[key],
|
color: colorMap[key],
|
||||||
opacity: strokeOpacity,
|
strokeOpacity,
|
||||||
|
activeDot: !filtered,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [sortedKeys, filter, dataKeys, colorMap])
|
}, [sortedKeys, filter, dataKeys, colorMap])
|
||||||
@@ -134,7 +135,7 @@ export function TemperatureChart({
|
|||||||
// label: `Test ${++i}`,
|
// label: `Test ${++i}`,
|
||||||
// dataKey: () => 0,
|
// dataKey: () => 0,
|
||||||
// color: "red",
|
// color: "red",
|
||||||
// opacity: 1,
|
// strokeOpacity: 1,
|
||||||
// })
|
// })
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
@@ -202,6 +203,7 @@ export function TemperatureChart({
|
|||||||
return `${decimalString(value)} ${unit}`
|
return `${decimalString(value)} ${unit}`
|
||||||
}}
|
}}
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
|
filter={filter}
|
||||||
></LineChartDefault>
|
></LineChartDefault>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { lazy } from "react"
|
import { lazy } from "react"
|
||||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ResponseChart, LossChart } from "./charts/probes-charts"
|
|
||||||
import type { SystemData } from "./use-system-data"
|
|
||||||
import { $chartTime } from "@/lib/stores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { useNetworkProbesData } from "@/lib/use-network-probes"
|
|
||||||
|
|
||||||
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
||||||
|
|
||||||
@@ -39,46 +34,3 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const NetworkProbesTable = lazy(() => import("@/components/network-probes-table/network-probes-table"))
|
|
||||||
|
|
||||||
export function LazyNetworkProbesTable({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
|
|
||||||
const { isIntersecting, ref } = useIntersectionObserver()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
|
||||||
{isIntersecting && <ProbesTable systemId={systemId} systemData={systemData} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProbesTable({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
|
|
||||||
const { grid, chartData } = systemData ?? {}
|
|
||||||
const chartTime = useStore($chartTime)
|
|
||||||
|
|
||||||
const { probes, probeStats } = useNetworkProbesData({ systemId, loadStats: !!chartData, chartTime })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<NetworkProbesTable systemId={systemId} probes={probes} />
|
|
||||||
{!!chartData && !!probes.length && (
|
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
|
||||||
<ResponseChart
|
|
||||||
probeStats={probeStats}
|
|
||||||
grid={grid}
|
|
||||||
probes={probes}
|
|
||||||
chartData={chartData}
|
|
||||||
empty={!probeStats.length}
|
|
||||||
/>
|
|
||||||
<LossChart
|
|
||||||
probeStats={probeStats}
|
|
||||||
grid={grid}
|
|
||||||
probes={probes}
|
|
||||||
chartData={chartData}
|
|
||||||
empty={!probeStats.length}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import type {
|
|||||||
SystemStatsRecord,
|
SystemStatsRecord,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import { $router, navigate } from "../../router"
|
import { $router, navigate } from "../../router"
|
||||||
import { appendData, cache, getStats, makeContainerData, makeContainerPoint } from "./chart-data"
|
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
|
||||||
|
|
||||||
export type SystemData = ReturnType<typeof useSystemData>
|
export type SystemData = ReturnType<typeof useSystemData>
|
||||||
|
|
||||||
@@ -151,11 +151,16 @@ export function useSystemData(id: string) {
|
|||||||
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
|
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
|
||||||
|
|
||||||
const chartData: ChartData = useMemo(() => {
|
const chartData: ChartData = useMemo(() => {
|
||||||
|
const lastCreated = Math.max(
|
||||||
|
(systemStats.at(-1)?.created as number) ?? 0,
|
||||||
|
(containerData.at(-1)?.created as number) ?? 0
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
systemStats,
|
systemStats,
|
||||||
containerData,
|
containerData,
|
||||||
chartTime,
|
chartTime,
|
||||||
orientation: direction === "rtl" ? "right" : "left",
|
orientation: direction === "rtl" ? "right" : "left",
|
||||||
|
...getTimeData(chartTime, lastCreated),
|
||||||
agentVersion,
|
agentVersion,
|
||||||
}
|
}
|
||||||
}, [systemStats, containerData, direction])
|
}, [systemStats, containerData, direction])
|
||||||
@@ -195,8 +200,8 @@ export function useSystemData(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
|
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
||||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
|
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
||||||
]).then(([systemStats, containerStats]) => {
|
]).then(([systemStats, containerStats]) => {
|
||||||
// If another request has been made since this one, ignore the results
|
// If another request has been made since this one, ignore the results
|
||||||
if (requestId !== statsRequestId.current) {
|
if (requestId !== statsRequestId.current) {
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { useLingui } from "@lingui/react/macro"
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as RechartsPrimitive from "recharts"
|
import * as RechartsPrimitive from "recharts"
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import type { ChartTimes } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { Separator } from "./separator"
|
import { Separator } from "./separator"
|
||||||
import { AxisDomain } from "recharts/types/util/types"
|
import { AxisDomain } from "recharts/types/util/types"
|
||||||
import { timeTicks } from "d3-time"
|
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
@@ -401,37 +400,26 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
|
|||||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedAxis: {
|
let cachedAxis: JSX.Element
|
||||||
time: number
|
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
||||||
el: JSX.Element
|
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
|
||||||
}
|
return cachedAxis
|
||||||
|
|
||||||
const xAxis = (chartTime: ChartTimes, lastCreationTime: number) => {
|
|
||||||
if (Math.abs(lastCreationTime - cachedAxis?.time) < 1000) {
|
|
||||||
return cachedAxis.el
|
|
||||||
}
|
}
|
||||||
const now = new Date(lastCreationTime + 1000)
|
cachedAxis = (
|
||||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
<RechartsPrimitive.XAxis
|
||||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
dataKey="created"
|
||||||
const domain = [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()]
|
domain={domain}
|
||||||
cachedAxis = {
|
ticks={ticks}
|
||||||
time: lastCreationTime,
|
allowDataOverflow
|
||||||
el: (
|
type="number"
|
||||||
<RechartsPrimitive.XAxis
|
scale="time"
|
||||||
dataKey="created"
|
minTickGap={12}
|
||||||
domain={domain}
|
tickMargin={8}
|
||||||
ticks={ticks}
|
axisLine={false}
|
||||||
allowDataOverflow
|
tickFormatter={chartTimeData[chartTime].format}
|
||||||
type="number"
|
/>
|
||||||
scale="time"
|
)
|
||||||
minTickGap={12}
|
return cachedAxis
|
||||||
tickMargin={8}
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={chartTimeData[chartTime].format}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return cachedAxis.el
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted/40",
|
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted!",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
import { chartTimeData } from "@/lib/utils"
|
|
||||||
import type { ChartTimes, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import { getStats, appendData } from "@/components/routes/system/chart-data"
|
|
||||||
import { pb } from "@/lib/api"
|
|
||||||
import { toast } from "@/components/ui/use-toast"
|
|
||||||
import type { RecordListOptions, RecordSubscription } from "pocketbase"
|
|
||||||
|
|
||||||
const cache = new Map<string, NetworkProbeStatsRecord[]>()
|
|
||||||
|
|
||||||
function getCacheValue(systemId: string, chartTime: ChartTimes | "rt") {
|
|
||||||
return cache.get(`${systemId}${chartTime}`) || []
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCacheValue(
|
|
||||||
systemId: string,
|
|
||||||
chartTime: ChartTimes | "rt",
|
|
||||||
newStats: NetworkProbeStatsRecord[],
|
|
||||||
maxPoints = 100
|
|
||||||
) {
|
|
||||||
const cache_key = `${systemId}${chartTime}`
|
|
||||||
const existingStats = getCacheValue(systemId, chartTime)
|
|
||||||
if (existingStats) {
|
|
||||||
const { expectedInterval } = chartTimeData[chartTime]
|
|
||||||
const updatedStats = appendData(existingStats, newStats, expectedInterval, maxPoints)
|
|
||||||
cache.set(cache_key, updatedStats)
|
|
||||||
return updatedStats
|
|
||||||
} else {
|
|
||||||
cache.set(cache_key, newStats)
|
|
||||||
return newStats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const NETWORK_PROBE_FIELDS =
|
|
||||||
"id,name,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
|
|
||||||
|
|
||||||
interface UseNetworkProbesProps {
|
|
||||||
systemId?: string
|
|
||||||
loadStats?: boolean
|
|
||||||
chartTime?: ChartTimes
|
|
||||||
existingProbes?: NetworkProbeRecord[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|
||||||
const { systemId, loadStats, chartTime, existingProbes } = props
|
|
||||||
|
|
||||||
const [p, setProbes] = useState<NetworkProbeRecord[]>([])
|
|
||||||
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
|
||||||
const statsRequestId = useRef(0)
|
|
||||||
const pendingProbeEvents = useRef(new Map<string, RecordSubscription<NetworkProbeRecord>>())
|
|
||||||
const probeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
const probes = existingProbes ?? p
|
|
||||||
|
|
||||||
// clear old data when systemId changes
|
|
||||||
// useEffect(() => {
|
|
||||||
// return setProbes([])
|
|
||||||
// }, [systemId])
|
|
||||||
|
|
||||||
// initial load - fetch probes if not provided by caller
|
|
||||||
useEffect(() => {
|
|
||||||
if (!existingProbes) {
|
|
||||||
fetchProbes(systemId).then((probes) => setProbes(probes))
|
|
||||||
}
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
// Subscribe to updates if probes not provided by caller
|
|
||||||
useEffect(() => {
|
|
||||||
if (existingProbes) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let unsubscribe: (() => void) | undefined
|
|
||||||
|
|
||||||
function flushPendingProbeEvents() {
|
|
||||||
probeBatchTimeout.current = null
|
|
||||||
if (!pendingProbeEvents.current.size) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const events = pendingProbeEvents.current
|
|
||||||
pendingProbeEvents.current = new Map()
|
|
||||||
setProbes((currentProbes) => {
|
|
||||||
return applyProbeEvents(currentProbes ?? [], events.values(), systemId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const pbOptions: RecordListOptions = { fields: NETWORK_PROBE_FIELDS }
|
|
||||||
if (systemId) {
|
|
||||||
pbOptions.filter = pb.filter("system = {:system}", { system: systemId })
|
|
||||||
}
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
unsubscribe = await pb.collection<NetworkProbeRecord>("network_probes").subscribe(
|
|
||||||
"*",
|
|
||||||
(event) => {
|
|
||||||
pendingProbeEvents.current.set(event.record.id, event)
|
|
||||||
if (!probeBatchTimeout.current) {
|
|
||||||
probeBatchTimeout.current = setTimeout(flushPendingProbeEvents, 50)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pbOptions
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to subscribe to probes", error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (probeBatchTimeout.current !== null) {
|
|
||||||
clearTimeout(probeBatchTimeout.current)
|
|
||||||
probeBatchTimeout.current = null
|
|
||||||
}
|
|
||||||
pendingProbeEvents.current.clear()
|
|
||||||
unsubscribe?.()
|
|
||||||
}
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
// Subscribe to new probe stats
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadStats || !systemId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let unsubscribe: (() => void) | undefined
|
|
||||||
const pbOptions = {
|
|
||||||
fields: "stats,created,type",
|
|
||||||
filter: pb.filter("system = {:system}", { system: systemId }),
|
|
||||||
}
|
|
||||||
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
unsubscribe = await pb.collection<NetworkProbeStatsRecord>("network_probe_stats").subscribe(
|
|
||||||
"*",
|
|
||||||
(event) => {
|
|
||||||
if (!chartTime || event.action !== "create") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// if (typeof event.record.created === "string") {
|
|
||||||
// event.record.created = new Date(event.record.created).getTime()
|
|
||||||
// }
|
|
||||||
// return if not current chart time
|
|
||||||
// we could append to other chart times, but we would need to check the timestamps
|
|
||||||
// to make sure they fit in correctly, so for simplicity just ignore non-chart-time updates
|
|
||||||
// and fetch them via API when the user switches to that chart time
|
|
||||||
const chartTimeRecordType = chartTimeData[chartTime].type as ChartTimes
|
|
||||||
if (event.record.type !== chartTimeRecordType) {
|
|
||||||
// const lastCreated = getCacheValue(systemId, chartTime)?.at(-1)?.created ?? 0
|
|
||||||
// if (lastCreated) {
|
|
||||||
// // if the new record is close enough to the last cached record, append it to the cache so it's available immediately if the user switches to that chart time
|
|
||||||
// const { expectedInterval } = chartTimeData[chartTime]
|
|
||||||
// if (event.record.created - lastCreated < expectedInterval * 1.5) {
|
|
||||||
// console.log(
|
|
||||||
// `Caching out-of-chart-time probe stats record for chart time ${chartTime} (record type: ${event.record.type})`
|
|
||||||
// )
|
|
||||||
// const newStats = appendCacheValue(systemId, chartTime, [event.record])
|
|
||||||
// cache.set(`${systemId}${chartTime}`, newStats)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// console.log(`Received probe stats for non-current chart time (${event.record.type}), ignoring for now`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log("Appending new probe stats to chart:", event.record)
|
|
||||||
const newStats = appendCacheValue(systemId, chartTime, [event.record])
|
|
||||||
setProbeStats(newStats)
|
|
||||||
},
|
|
||||||
pbOptions
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to subscribe to probe stats:", error)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => unsubscribe?.()
|
|
||||||
}, [systemId])
|
|
||||||
|
|
||||||
// fetch missing probe stats on load and when chart time changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadStats || !systemId || !chartTime || chartTime === "1m") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { expectedInterval } = chartTimeData[chartTime]
|
|
||||||
const requestId = ++statsRequestId.current
|
|
||||||
|
|
||||||
const cachedProbeStats = getCacheValue(systemId, chartTime)
|
|
||||||
|
|
||||||
// Render from cache immediately if available
|
|
||||||
if (cachedProbeStats.length) {
|
|
||||||
setProbeStats(cachedProbeStats)
|
|
||||||
|
|
||||||
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
|
||||||
const lastCreated = cachedProbeStats.at(-1)?.created
|
|
||||||
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
|
||||||
console.log("Using cached probe stats, skipping fetch")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getStats<NetworkProbeStatsRecord>("network_probe_stats", systemId, chartTime, cachedProbeStats).then(
|
|
||||||
(probeStats) => {
|
|
||||||
// If another request has been made since this one, ignore the results
|
|
||||||
if (requestId !== statsRequestId.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const newStats = appendCacheValue(systemId, chartTime, probeStats)
|
|
||||||
setProbeStats(newStats)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}, [chartTime])
|
|
||||||
|
|
||||||
// subscribe to realtime metrics if chart time is 1m
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadStats || !systemId || chartTime !== "1m") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let unsubscribe: (() => void) | undefined
|
|
||||||
const cache_key = `${systemId}rt`
|
|
||||||
pb.realtime
|
|
||||||
.subscribe(
|
|
||||||
`rt_metrics`,
|
|
||||||
(data: { Probes: NetworkProbeStatsRecord["stats"] }) => {
|
|
||||||
let prev = getCacheValue(systemId, "rt")
|
|
||||||
const now = Date.now()
|
|
||||||
// if no previous data or the last data point is older than 1min,
|
|
||||||
// create a new data set starting with a point 1 second ago to seed the chart data
|
|
||||||
if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) {
|
|
||||||
prev = [{ created: now - 1000, stats: probesToStats(probes) }]
|
|
||||||
}
|
|
||||||
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
|
|
||||||
const newStats = appendData(prev, [stats], 1000, 120)
|
|
||||||
setProbeStats(() => newStats)
|
|
||||||
cache.set(cache_key, newStats)
|
|
||||||
},
|
|
||||||
{ query: { system: systemId } }
|
|
||||||
)
|
|
||||||
.then((us) => {
|
|
||||||
unsubscribe = us
|
|
||||||
})
|
|
||||||
return () => unsubscribe?.()
|
|
||||||
}, [chartTime, systemId])
|
|
||||||
|
|
||||||
return {
|
|
||||||
probes,
|
|
||||||
probeStats,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
|
|
||||||
const stats: NetworkProbeStatsRecord["stats"] = {}
|
|
||||||
for (const probe of probes) {
|
|
||||||
stats[probe.id] = [probe.res, probe.resAvg1h, probe.resMin1h, probe.resMax1h, probe.loss1h]
|
|
||||||
}
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProbes(systemId?: string) {
|
|
||||||
try {
|
|
||||||
const res = await pb.collection<NetworkProbeRecord>("network_probes").getList(0, 2000, {
|
|
||||||
fields: NETWORK_PROBE_FIELDS,
|
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
|
||||||
})
|
|
||||||
return res.items
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: (error as Error)?.message,
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyProbeEvents(
|
|
||||||
probes: NetworkProbeRecord[],
|
|
||||||
events: Iterable<RecordSubscription<NetworkProbeRecord>>,
|
|
||||||
systemId?: string
|
|
||||||
) {
|
|
||||||
// Use a map to handle updates/deletes in constant time
|
|
||||||
const probeById = new Map(probes.map((probe) => [probe.id, probe]))
|
|
||||||
const createdProbes: NetworkProbeRecord[] = []
|
|
||||||
|
|
||||||
for (const { action, record } of events) {
|
|
||||||
const matchesSystemScope = !systemId || record.system === systemId
|
|
||||||
|
|
||||||
if (action === "delete" || !matchesSystemScope) {
|
|
||||||
probeById.delete(record.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!probeById.has(record.id)) {
|
|
||||||
createdProbes.push(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
probeById.set(record.id, record)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProbes: NetworkProbeRecord[] = []
|
|
||||||
// Prepend brand new probes (matching previous behavior)
|
|
||||||
for (let index = createdProbes.length - 1; index >= 0; index -= 1) {
|
|
||||||
nextProbes.push(createdProbes[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the final list while preserving original order for existing probes
|
|
||||||
for (const probe of probes) {
|
|
||||||
const nextProbe = probeById.get(probe.id)
|
|
||||||
if (!nextProbe) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextProbes.push(nextProbe)
|
|
||||||
probeById.delete(probe.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextProbes
|
|
||||||
}
|
|
||||||
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|||||||
39
internal/site/src/types.d.ts
vendored
39
internal/site/src/types.d.ts
vendored
@@ -316,6 +316,8 @@ export interface ChartData {
|
|||||||
systemStats: SystemStatsRecord[]
|
systemStats: SystemStatsRecord[]
|
||||||
containerData: ChartDataContainer[]
|
containerData: ChartDataContainer[]
|
||||||
orientation: "right" | "left"
|
orientation: "right" | "left"
|
||||||
|
ticks: number[]
|
||||||
|
domain: number[]
|
||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,40 +546,3 @@ export interface UpdateInfo {
|
|||||||
v: string // new version
|
v: string // new version
|
||||||
url: string // url to new version
|
url: string // url to new version
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkProbeRecord {
|
|
||||||
id: string
|
|
||||||
system: string
|
|
||||||
name: string
|
|
||||||
target: string
|
|
||||||
protocol: "icmp" | "tcp" | "http"
|
|
||||||
port: number
|
|
||||||
res: number
|
|
||||||
resMin1h: number
|
|
||||||
resMax1h: number
|
|
||||||
resAvg1h: number
|
|
||||||
loss1h: number
|
|
||||||
interval: number
|
|
||||||
enabled: boolean
|
|
||||||
updated: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 0: avg 1 minute response in ms
|
|
||||||
*
|
|
||||||
* 1: avg response over 1 hour in ms
|
|
||||||
*
|
|
||||||
* 2: min response over the last hour in ms
|
|
||||||
*
|
|
||||||
* 3: max response over the last hour in ms
|
|
||||||
*
|
|
||||||
* 4: packet loss in %
|
|
||||||
*/
|
|
||||||
type ProbeResult = number[]
|
|
||||||
|
|
||||||
export interface NetworkProbeStatsRecord {
|
|
||||||
id?: string
|
|
||||||
type?: string
|
|
||||||
stats: Record<string, ProbeResult>
|
|
||||||
created: number // unix timestamp (ms) for Recharts xAxis
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user