mirror of
https://github.com/henrygd/beszel.git
synced 2026-06-30 17:28:22 +02:00
Compare commits
29 Commits
6472af1ba4
...
dev-probes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3534552d37 | ||
|
|
723401819f | ||
|
|
2ea576c989 | ||
|
|
526a2c6aab | ||
|
|
aaa8eb773f | ||
|
|
099935e78e | ||
|
|
d2eb3b259a | ||
|
|
b89314889d | ||
|
|
04e2b8b974 | ||
|
|
891b03426f | ||
|
|
b182b699d7 | ||
|
|
e65a4a515e | ||
|
|
df249b24f6 | ||
|
|
788483ac56 | ||
|
|
f830665984 | ||
|
|
af49ebf2df | ||
|
|
0378023b6f | ||
|
|
89ac8dc585 | ||
|
|
9896bcdf43 | ||
|
|
ddd47e67ac | ||
|
|
027159420c | ||
|
|
e154123511 | ||
|
|
9f7c1b22bb | ||
|
|
0d440e5fb9 | ||
|
|
5fc774666f | ||
|
|
8f03cbf11c | ||
|
|
1c5808f430 | ||
|
|
a35cc6ef39 | ||
|
|
16e0f6c4a2 |
@@ -213,11 +213,13 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|||||||
type SyncNetworkProbesHandler struct{}
|
type SyncNetworkProbesHandler struct{}
|
||||||
|
|
||||||
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
||||||
var configs []probe.Config
|
var req probe.SyncRequest
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &configs); err != nil {
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hctx.Agent.probeManager.SyncProbes(configs)
|
resp, err := hctx.Agent.probeManager.HandleSyncRequest(req)
|
||||||
slog.Info("network probes synced", "count", len(configs))
|
if err != nil {
|
||||||
return hctx.SendResponse("ok", hctx.RequestID)
|
return err
|
||||||
|
}
|
||||||
|
return hctx.SendResponse(resp, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|||||||
471
agent/probe.go
471
agent/probe.go
@@ -1,10 +1,14 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
// "strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +17,24 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 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 (<= 70s) use raw samples.
|
||||||
|
// 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
|
||||||
|
probeRawRetention = 61 * 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.
|
// ProbeManager manages network probe tasks.
|
||||||
type ProbeManager struct {
|
type ProbeManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -20,18 +42,37 @@ type ProbeManager struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probeTask owns retention buffers and cancellation for a single probe config.
|
||||||
type probeTask struct {
|
type probeTask struct {
|
||||||
config probe.Config
|
config probe.Config
|
||||||
cancel chan struct{}
|
cancel chan struct{}
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
samples []probeSample
|
samples []probeSample
|
||||||
|
buckets [probeMinuteBucketLen]probeBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probeSample stores one probe attempt and its collection time.
|
||||||
type probeSample struct {
|
type probeSample struct {
|
||||||
latencyMs float64 // -1 means loss
|
responseUs int64 // -1 means loss
|
||||||
timestamp time.Time
|
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 {
|
||||||
|
sumUs int64
|
||||||
|
minUs int64
|
||||||
|
maxUs int64
|
||||||
|
totalCount int64
|
||||||
|
successCount int64
|
||||||
|
}
|
||||||
|
|
||||||
func newProbeManager() *ProbeManager {
|
func newProbeManager() *ProbeManager {
|
||||||
return &ProbeManager{
|
return &ProbeManager{
|
||||||
probes: make(map[string]*probeTask),
|
probes: make(map[string]*probeTask),
|
||||||
@@ -39,6 +80,104 @@ func newProbeManager() *ProbeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newProbeTask(config probe.Config) *probeTask {
|
||||||
|
return &probeTask{
|
||||||
|
config: config,
|
||||||
|
cancel: make(chan struct{}),
|
||||||
|
samples: make([]probeSample, 0, 64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeTaskFromExisting(config probe.Config, existing *probeTask) *probeTask {
|
||||||
|
task := newProbeTask(config)
|
||||||
|
if existing == nil {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.mu.Lock()
|
||||||
|
defer existing.mu.Unlock()
|
||||||
|
task.samples = append(task.samples, existing.samples...)
|
||||||
|
task.buckets = existing.buckets
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProbeAggregate initializes an aggregate with an unset minimum value.
|
||||||
|
func newProbeAggregate() probeAggregate {
|
||||||
|
return probeAggregate{minUs: math.MaxInt64}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addResponse folds a single probe sample into the aggregate.
|
||||||
|
func (agg *probeAggregate) addResponse(responseUs int64) {
|
||||||
|
agg.totalCount++
|
||||||
|
if responseUs < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agg.successCount++
|
||||||
|
agg.sumUs += responseUs
|
||||||
|
if responseUs < agg.minUs {
|
||||||
|
agg.minUs = responseUs
|
||||||
|
}
|
||||||
|
if responseUs > agg.maxUs {
|
||||||
|
agg.maxUs = responseUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.sumUs += other.sumUs
|
||||||
|
if other.successCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if agg.minUs == math.MaxInt64 || other.minUs < agg.minUs {
|
||||||
|
agg.minUs = other.minUs
|
||||||
|
}
|
||||||
|
if other.maxUs > agg.maxUs {
|
||||||
|
agg.maxUs = other.maxUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 format.
|
||||||
|
func (agg probeAggregate) result() probe.Result {
|
||||||
|
avg := agg.avgResponse()
|
||||||
|
result := probe.Result{
|
||||||
|
AvgResponse: avg,
|
||||||
|
MinResponse: agg.minUs,
|
||||||
|
MaxResponse: agg.maxUs,
|
||||||
|
PacketLoss: agg.lossPercentage(),
|
||||||
|
}
|
||||||
|
if agg.successCount == 0 {
|
||||||
|
result.MinResponse, result.MaxResponse = 0, 0
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// avgResponse returns the rounded average of successful samples.
|
||||||
|
func (agg probeAggregate) avgResponse() int64 {
|
||||||
|
if agg.successCount == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return agg.sumUs / agg.successCount
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// SyncProbes replaces all probe tasks with the given configs.
|
||||||
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
@@ -47,7 +186,10 @@ func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
|||||||
// Build set of new keys
|
// Build set of new keys
|
||||||
newKeys := make(map[string]probe.Config, len(configs))
|
newKeys := make(map[string]probe.Config, len(configs))
|
||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
newKeys[cfg.Key()] = cfg
|
if cfg.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newKeys[cfg.ID] = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop removed probes
|
// Stop removed probes
|
||||||
@@ -58,18 +200,94 @@ func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start new probes (skip existing ones with same key)
|
// Start new probes and restart tasks whose config changed.
|
||||||
for key, cfg := range newKeys {
|
for key, cfg := range newKeys {
|
||||||
if _, exists := pm.probes[key]; exists {
|
task, exists := pm.probes[key]
|
||||||
|
if exists && task.config == cfg {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
task := &probeTask{
|
if exists {
|
||||||
config: cfg,
|
close(task.cancel)
|
||||||
cancel: make(chan struct{}),
|
|
||||||
samples: make([]probeSample, 0, 64),
|
|
||||||
}
|
}
|
||||||
|
task = newProbeTaskFromExisting(cfg, task)
|
||||||
pm.probes[key] = task
|
pm.probes[key] = task
|
||||||
go pm.runProbe(task)
|
go pm.runProbe(task, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSyncRequest applies a full or incremental probe sync request.
|
||||||
|
func (pm *ProbeManager) HandleSyncRequest(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")
|
||||||
|
}
|
||||||
|
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 = newProbeTaskFromExisting(config, task)
|
||||||
|
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, false)
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,52 +297,18 @@ func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
|
|||||||
defer pm.mu.RUnlock()
|
defer pm.mu.RUnlock()
|
||||||
|
|
||||||
results := make(map[string]probe.Result, len(pm.probes))
|
results := make(map[string]probe.Result, len(pm.probes))
|
||||||
cutoff := time.Now().Add(-time.Duration(durationMs) * time.Millisecond)
|
now := time.Now()
|
||||||
|
duration := time.Duration(durationMs) * time.Millisecond
|
||||||
|
|
||||||
for key, task := range pm.probes {
|
for _, task := range pm.probes {
|
||||||
task.mu.Lock()
|
task.mu.Lock()
|
||||||
var sum, minMs, maxMs float64
|
result, ok := task.resultLocked(duration, now)
|
||||||
var count, lossCount int
|
|
||||||
minMs = math.MaxFloat64
|
|
||||||
|
|
||||||
for _, s := range task.samples {
|
|
||||||
if s.timestamp.Before(cutoff) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
if s.latencyMs < 0 {
|
|
||||||
lossCount++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sum += s.latencyMs
|
|
||||||
if s.latencyMs < minMs {
|
|
||||||
minMs = s.latencyMs
|
|
||||||
}
|
|
||||||
if s.latencyMs > maxMs {
|
|
||||||
maxMs = s.latencyMs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
task.mu.Unlock()
|
task.mu.Unlock()
|
||||||
|
|
||||||
if count == 0 {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
results[task.config.ID] = result
|
||||||
successCount := count - lossCount
|
|
||||||
var avg float64
|
|
||||||
if successCount > 0 {
|
|
||||||
avg = math.Round(sum/float64(successCount)*100) / 100
|
|
||||||
}
|
|
||||||
if minMs == math.MaxFloat64 {
|
|
||||||
minMs = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
results[key] = probe.Result{
|
|
||||||
avg, // average latency in ms
|
|
||||||
math.Round(minMs*100) / 100, // min latency in ms
|
|
||||||
math.Round(maxMs*100) / 100, // max latency in ms
|
|
||||||
math.Round(float64(lossCount)/float64(count)*10000) / 100, // packet loss percentage
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -141,19 +325,34 @@ func (pm *ProbeManager) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runProbe executes a single probe task in a loop.
|
// runProbe executes a single probe task in a loop.
|
||||||
func (pm *ProbeManager) runProbe(task *probeTask) {
|
func (pm *ProbeManager) runProbe(task *probeTask, runNow bool) {
|
||||||
interval := time.Duration(task.config.Interval) * time.Second
|
interval := time.Duration(task.config.Interval) * time.Second
|
||||||
if interval < time.Second {
|
if interval < time.Second {
|
||||||
interval = 10 * time.Second
|
interval = 30 * time.Second
|
||||||
}
|
}
|
||||||
ticker := time.Tick(interval)
|
|
||||||
|
|
||||||
// Run immediately on start
|
stagger := getStagger(interval.Milliseconds())
|
||||||
|
|
||||||
|
slog.Debug("starting probe task", "target", task.config.Target, "delay", stagger.String(), "interval", interval.String())
|
||||||
|
|
||||||
|
if runNow {
|
||||||
pm.executeProbe(task)
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
// slog.Info("removed probe", "target", task.config.Target)
|
||||||
|
return
|
||||||
|
case <-time.After(stagger):
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.Tick(interval)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-task.cancel:
|
case <-task.cancel:
|
||||||
|
// slog.Info("removed probe", "target", task.config.Target)
|
||||||
return
|
return
|
||||||
case <-ticker:
|
case <-ticker:
|
||||||
pm.executeProbe(task)
|
pm.executeProbe(task)
|
||||||
@@ -161,32 +360,90 @@ func (pm *ProbeManager) runProbe(task *probeTask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProbeManager) executeProbe(task *probeTask) {
|
// getStagger returns a random duration between intervalSeconds/2 and intervalSeconds to stagger initial probe executions
|
||||||
var latencyMs float64
|
func getStagger(intervalMilli int64) time.Duration {
|
||||||
|
intervalMilliInt := int(intervalMilli)
|
||||||
switch task.config.Protocol {
|
randomDelayInt := rand.Intn(intervalMilliInt)
|
||||||
case "icmp":
|
if randomDelayInt < intervalMilliInt/2 {
|
||||||
latencyMs = probeICMP(task.config.Target)
|
randomDelayInt += intervalMilliInt / 2
|
||||||
case "tcp":
|
|
||||||
latencyMs = probeTCP(task.config.Target, task.config.Port)
|
|
||||||
case "http":
|
|
||||||
latencyMs = probeHTTP(pm.httpClient, task.config.Target)
|
|
||||||
default:
|
|
||||||
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sample := probeSample{
|
|
||||||
latencyMs: latencyMs,
|
|
||||||
timestamp: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
return time.Duration(randomDelayInt) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *ProbeManager) runProbeNow(task *probeTask) *probe.Result {
|
||||||
|
pm.executeProbe(task)
|
||||||
task.mu.Lock()
|
task.mu.Lock()
|
||||||
// Trim old samples beyond 120s to bound memory
|
defer task.mu.Unlock()
|
||||||
cutoff := time.Now().Add(-120 * time.Second)
|
result, ok := task.resultLocked(time.Minute, time.Now())
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultLocked returns the aggregated probe result for the requested duration along with a bool indicating whether any data was available.
|
||||||
|
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 probe.Result{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
result := agg.result()
|
||||||
|
|
||||||
|
result.AvgResponse1h = hourAgg.avgResponse()
|
||||||
|
result.MinResponse1h = hourAgg.minUs
|
||||||
|
result.MaxResponse1h = hourAgg.maxUs
|
||||||
|
result.PacketLoss1h = hourAgg.lossPercentage()
|
||||||
|
|
||||||
|
if hourAgg.successCount == 0 {
|
||||||
|
result.MinResponse1h, result.MaxResponse1h = 0, 0
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateLocked collects probe data for the requested time window.
|
||||||
|
func (task *probeTask) aggregateLocked(duration time.Duration, now time.Time) probeAggregate {
|
||||||
|
cutoff := now.Add(-duration)
|
||||||
|
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
|
||||||
|
if duration <= probeRawRetention {
|
||||||
|
return aggregateSamplesSince(task.samples, cutoff)
|
||||||
|
}
|
||||||
|
return aggregateBucketsSince(task.buckets[:], cutoff, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
|
||||||
|
func aggregateSamplesSince(samples []probeSample, cutoff time.Time) probeAggregate {
|
||||||
|
agg := newProbeAggregate()
|
||||||
|
for _, sample := range samples {
|
||||||
|
if sample.timestamp.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg.addResponse(sample.responseUs)
|
||||||
|
}
|
||||||
|
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
|
start := 0
|
||||||
for i := range task.samples {
|
for i := range task.samples {
|
||||||
if task.samples[i].timestamp.After(cutoff) {
|
if !task.samples[i].timestamp.Before(cutoff) {
|
||||||
start = i
|
start = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -199,16 +456,57 @@ func (pm *ProbeManager) executeProbe(task *probeTask) {
|
|||||||
task.samples = task.samples[:size]
|
task.samples = task.samples[:size]
|
||||||
}
|
}
|
||||||
task.samples = append(task.samples, sample)
|
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.responseUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeProbe runs the configured probe and records the sample.
|
||||||
|
func (pm *ProbeManager) executeProbe(task *probeTask) {
|
||||||
|
// slog.Info("running probe", "id", task.config.ID, "interval", task.config.Interval)
|
||||||
|
var responseUs int64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch task.config.Protocol {
|
||||||
|
case "icmp":
|
||||||
|
responseUs, err = probeICMP(task.config.Target)
|
||||||
|
case "tcp":
|
||||||
|
responseUs, err = probeTCP(task.config.Target, task.config.Port)
|
||||||
|
case "http":
|
||||||
|
responseUs, err = probeHTTP(pm.httpClient, task.config.Target)
|
||||||
|
default:
|
||||||
|
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("probe failed", "err", err, "target", task.config.Target, "protocol", task.config.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
sample := probeSample{
|
||||||
|
responseUs: responseUs,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
task.mu.Lock()
|
||||||
|
task.addSampleLocked(sample)
|
||||||
task.mu.Unlock()
|
task.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeTCP measures pure TCP handshake latency (excluding DNS resolution).
|
// probeTCP measures pure TCP handshake response (excluding DNS resolution).
|
||||||
// Returns -1 on failure.
|
// Returns -1 and an error on failure.
|
||||||
func probeTCP(target string, port uint16) float64 {
|
func probeTCP(target string, port uint16) (int64, error) {
|
||||||
// Resolve DNS first, outside the timing window
|
// Resolve DNS first, outside the timing window
|
||||||
ips, err := net.LookupHost(target)
|
ips, err := net.LookupHost(target)
|
||||||
if err != nil || len(ips) == 0 {
|
if err != nil || len(ips) == 0 {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
|
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
|
||||||
|
|
||||||
@@ -216,22 +514,25 @@ func probeTCP(target string, port uint16) float64 {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
return time.Since(start).Microseconds(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeHTTP measures HTTP GET request latency. Returns -1 on failure.
|
// probeHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
|
||||||
func probeHTTP(client *http.Client, url string) float64 {
|
func probeHTTP(client *http.Client, url string) (int64, error) {
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return -1
|
return -1, fmt.Errorf("HTTP error: %s", resp.Status)
|
||||||
}
|
}
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
return time.Since(start).Microseconds(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -26,7 +28,7 @@ type icmpPacketConn interface {
|
|||||||
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
||||||
// all native methods fail, the choice is cached so subsequent probes skip
|
// all native methods fail, the choice is cached so subsequent probes skip
|
||||||
// the trial-and-error overhead.
|
// the trial-and-error overhead.
|
||||||
type icmpMethod int
|
type icmpMethod uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
icmpUntried icmpMethod = iota // haven't tried yet
|
icmpUntried icmpMethod = iota // haven't tried yet
|
||||||
@@ -71,15 +73,15 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// probeICMP sends an ICMP echo request and measures round-trip latency.
|
// probeICMP sends an ICMP echo request and measures round-trip response.
|
||||||
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
||||||
// unprivileged datagram, or exec fallback) is detected once per address
|
// unprivileged datagram, or exec fallback) is detected once per address
|
||||||
// family and cached for subsequent probes.
|
// family and cached for subsequent probes.
|
||||||
// Returns latency in milliseconds, or -1 on failure.
|
// Returns response in microseconds, or -1 and an error on failure.
|
||||||
func probeICMP(target string) float64 {
|
func probeICMP(target string) (int64, error) {
|
||||||
family, ip := resolveICMPTarget(target)
|
family, ip, err := resolveICMPTarget(target)
|
||||||
if family == nil {
|
if err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
icmpModeMu.Lock()
|
icmpModeMu.Lock()
|
||||||
@@ -97,30 +99,30 @@ func probeICMP(target string) float64 {
|
|||||||
case icmpExecFallback:
|
case icmpExecFallback:
|
||||||
return probeICMPExec(target, family.isIPv6)
|
return probeICMPExec(target, family.isIPv6)
|
||||||
default:
|
default:
|
||||||
return -1
|
return -1, errors.New("unsupported ICMP mode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
||||||
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
||||||
func resolveICMPTarget(target string) (*icmpFamily, net.IP) {
|
func resolveICMPTarget(target string) (*icmpFamily, net.IP, error) {
|
||||||
if ip := net.ParseIP(target); ip != nil {
|
if ip := net.ParseIP(target); ip != nil {
|
||||||
if ip.To4() != nil {
|
if ip.To4() != nil {
|
||||||
return &icmpV4, ip.To4()
|
return &icmpV4, ip.To4(), nil
|
||||||
}
|
}
|
||||||
return &icmpV6, ip
|
return &icmpV6, ip, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, err := net.LookupIP(target)
|
ips, err := net.LookupIP(target)
|
||||||
if err != nil || len(ips) == 0 {
|
if err != nil || len(ips) == 0 {
|
||||||
return nil, nil
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
if v4 := ip.To4(); v4 != nil {
|
if v4 := ip.To4(); v4 != nil {
|
||||||
return &icmpV4, v4
|
return &icmpV4, v4, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &icmpV6, ips[0]
|
return &icmpV6, ips[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
||||||
@@ -129,31 +131,28 @@ func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string)
|
|||||||
label = "IPv6"
|
label = "IPv6"
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn, err := listen(family.rawNetwork, family.listenAddr); err == nil {
|
conn, err := listen(family.rawNetwork, family.listenAddr)
|
||||||
|
slog.Debug("ICMP raw socket test", "family", label, "err", err)
|
||||||
|
if err == nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
slog.Info("ICMP probe using raw socket", "family", label)
|
|
||||||
return icmpRaw
|
return icmpRaw
|
||||||
} else {
|
|
||||||
slog.Debug("ICMP raw socket unavailable", "family", label, "err", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn, err := listen(family.dgramNetwork, family.listenAddr); err == nil {
|
conn, err = listen(family.dgramNetwork, family.listenAddr)
|
||||||
|
slog.Debug("ICMP datagram socket test", "family", label, "err", err)
|
||||||
|
if err == nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
slog.Info("ICMP probe using unprivileged datagram socket", "family", label)
|
|
||||||
return icmpDatagram
|
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
|
return icmpExecFallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
||||||
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
|
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) (int64, error) {
|
||||||
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -169,7 +168,7 @@ func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
|
|||||||
}
|
}
|
||||||
msgBytes, err := msg.Marshal(nil)
|
msgBytes, err := msg.Marshal(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set deadline before sending
|
// Set deadline before sending
|
||||||
@@ -177,7 +176,7 @@ func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
|
|||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read reply
|
// Read reply
|
||||||
@@ -185,23 +184,23 @@ func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
|
|||||||
for {
|
for {
|
||||||
n, _, err := conn.ReadFrom(buf)
|
n, _, err := conn.ReadFrom(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if reply.Type == family.replyType {
|
if reply.Type == family.replyType {
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
return time.Since(start).Microseconds(), nil
|
||||||
}
|
}
|
||||||
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeICMPExec falls back to the system ping command. Returns -1 on failure.
|
// probeICMPExec falls back to the system ping command. Returns -1 and an error on failure.
|
||||||
func probeICMPExec(target string, isIPv6 bool) float64 {
|
func probeICMPExec(target string, isIPv6 bool) (int64, error) {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "windows":
|
case "windows":
|
||||||
@@ -210,7 +209,7 @@ func probeICMPExec(target string, isIPv6 bool) float64 {
|
|||||||
} else {
|
} else {
|
||||||
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
||||||
}
|
}
|
||||||
default: // linux, darwin, freebsd
|
default:
|
||||||
if isIPv6 {
|
if isIPv6 {
|
||||||
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
||||||
} else {
|
} else {
|
||||||
@@ -223,20 +222,20 @@ func probeICMPExec(target string, isIPv6 bool) float64 {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// If ping fails but we got output, still try to parse
|
// If ping fails but we got output, still try to parse
|
||||||
if len(output) == 0 {
|
if len(output) == 0 {
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := pingTimeRegex.FindSubmatch(output)
|
matches := pingTimeRegex.FindSubmatch(output)
|
||||||
if len(matches) >= 2 {
|
if len(matches) >= 2 {
|
||||||
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
||||||
return ms
|
return int64(math.Round(ms * 1000)), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: use wall clock time if ping succeeded but parsing failed
|
// Fallback: use wall clock time if ping succeeded but parsing failed
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
return time.Since(start).Microseconds(), nil
|
||||||
}
|
}
|
||||||
return -1
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,21 +96,24 @@ func TestDetectICMPMode(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolveICMPTarget(t *testing.T) {
|
func TestResolveICMPTarget(t *testing.T) {
|
||||||
t.Run("IPv4 literal", func(t *testing.T) {
|
t.Run("IPv4 literal", func(t *testing.T) {
|
||||||
family, ip := resolveICMPTarget("127.0.0.1")
|
family, ip, err := resolveICMPTarget("127.0.0.1")
|
||||||
|
require.NoError(t, err)
|
||||||
require.NotNil(t, family)
|
require.NotNil(t, family)
|
||||||
assert.False(t, family.isIPv6)
|
assert.False(t, family.isIPv6)
|
||||||
assert.Equal(t, "127.0.0.1", ip.String())
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("IPv6 literal", func(t *testing.T) {
|
t.Run("IPv6 literal", func(t *testing.T) {
|
||||||
family, ip := resolveICMPTarget("::1")
|
family, ip, err := resolveICMPTarget("::1")
|
||||||
|
require.NoError(t, err)
|
||||||
require.NotNil(t, family)
|
require.NotNil(t, family)
|
||||||
assert.True(t, family.isIPv6)
|
assert.True(t, family.isIPv6)
|
||||||
assert.Equal(t, "::1", ip.String())
|
assert.Equal(t, "::1", ip.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
||||||
family, ip := resolveICMPTarget("::ffff:127.0.0.1")
|
family, ip, err := resolveICMPTarget("::ffff:127.0.0.1")
|
||||||
|
require.NoError(t, err)
|
||||||
require.NotNil(t, family)
|
require.NotNil(t, family)
|
||||||
assert.False(t, family.isIPv6)
|
assert.False(t, family.isIPv6)
|
||||||
assert.Equal(t, "127.0.0.1", ip.String())
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
|
|||||||
356
agent/probe_test.go
Normal file
356
agent/probe_test.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
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{responseUs: 10, timestamp: now.Add(-90 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(1), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(20), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MaxResponse)
|
||||||
|
assert.Equal(t, 50.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTaskAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-11 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(10*time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(4), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(3), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(30), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(40), result.MaxResponse)
|
||||||
|
assert.Equal(t, 25.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-10 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now})
|
||||||
|
|
||||||
|
require.Len(t, task.samples, 1)
|
||||||
|
assert.Equal(t, int64(20), task.samples[0].responseUs)
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(10*time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(2), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(15), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(10), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MaxResponse)
|
||||||
|
assert.Equal(t, 0.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-30 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-50 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, 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)
|
||||||
|
assert.Equal(t, int64(30), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(25), result.AvgResponse1h)
|
||||||
|
assert.Equal(t, int64(30), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(10), result.MinResponse1h)
|
||||||
|
assert.Equal(t, int64(30), result.MaxResponse)
|
||||||
|
assert.Equal(t, int64(40), result.MaxResponse1h)
|
||||||
|
assert.Equal(t, 50.0, result.PacketLoss)
|
||||||
|
assert.Equal(t, 20.0, result.PacketLoss1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -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)
|
||||||
|
assert.Equal(t, int64(0), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(0), result.AvgResponse1h)
|
||||||
|
assert.Equal(t, int64(0), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(0), result.MinResponse1h)
|
||||||
|
assert.Equal(t, int64(0), result.MaxResponse)
|
||||||
|
assert.Equal(t, int64(0), result.MaxResponse1h)
|
||||||
|
assert.Equal(t, 100.0, result.PacketLoss)
|
||||||
|
assert.Equal(t, 100.0, result.PacketLoss1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.HandleSyncRequest(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)
|
||||||
|
assert.GreaterOrEqual(t, resp.Result.AvgResponse, int64(0))
|
||||||
|
assert.Equal(t, 0.0, resp.Result.PacketLoss)
|
||||||
|
assert.Equal(t, 0.0, resp.Result.PacketLoss1h)
|
||||||
|
|
||||||
|
task := pm.probes["probe-1"]
|
||||||
|
require.NotNil(t, task)
|
||||||
|
task.mu.Lock()
|
||||||
|
defer task.mu.Unlock()
|
||||||
|
require.Len(t, task.samples, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerUpsertProbeKeepsHistoryWhenOnlyIntervalChanges(t *testing.T) {
|
||||||
|
originalCfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
||||||
|
updatedCfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 30}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
existingTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
|
||||||
|
existingTask.addSampleLocked(probeSample{responseUs: 12, timestamp: now.Add(-50 * time.Minute)})
|
||||||
|
existingTask.addSampleLocked(probeSample{responseUs: 24, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{originalCfg.ID: existingTask},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pm.UpsertProbe(updatedCfg, false)
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
|
||||||
|
updatedTask := pm.probes[updatedCfg.ID]
|
||||||
|
require.NotNil(t, updatedTask)
|
||||||
|
assert.NotSame(t, existingTask, updatedTask)
|
||||||
|
assert.Equal(t, updatedCfg, updatedTask.config)
|
||||||
|
|
||||||
|
updatedTask.mu.Lock()
|
||||||
|
defer updatedTask.mu.Unlock()
|
||||||
|
require.Len(t, updatedTask.samples, 1)
|
||||||
|
assert.Equal(t, int64(24), updatedTask.samples[0].responseUs)
|
||||||
|
|
||||||
|
agg := updatedTask.aggregateLocked(time.Hour, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(2), agg.successCount)
|
||||||
|
assert.Equal(t, int64(18), agg.avgResponse())
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-existingTask.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected original probe task to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.HandleSyncRequest(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 TestProbeManagerGetRandomDelay(t *testing.T) {
|
||||||
|
for i := 1000; i < 360_000; i += 1000 {
|
||||||
|
delay := getStagger(int64(i))
|
||||||
|
assert.GreaterOrEqual(t, delay, time.Duration(i/2)*time.Millisecond)
|
||||||
|
assert.LessOrEqual(t, delay, time.Duration(i)*time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
responseUs, err := probeHTTP(server.Client(), server.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, responseUs, int64(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()
|
||||||
|
|
||||||
|
responseUs, err := probeHTTP(server.Client(), server.URL)
|
||||||
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
responseUs, err := probeTCP("127.0.0.1", port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, responseUs, int64(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())
|
||||||
|
|
||||||
|
responseUs, err := probeTCP("127.0.0.1", port)
|
||||||
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,32 +1,83 @@
|
|||||||
package probe
|
package probe
|
||||||
|
|
||||||
import "strconv"
|
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.
|
// Config defines a network probe task sent from hub to agent.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Target string `cbor:"0,keyasint" json:"target"`
|
// ID is the stable network_probes record ID generated by the hub.
|
||||||
Protocol string `cbor:"1,keyasint" json:"protocol"` // "icmp", "tcp", or "http"
|
ID string `cbor:"0,keyasint"`
|
||||||
Port uint16 `cbor:"2,keyasint,omitempty" json:"port,omitempty"`
|
Target string `cbor:"1,keyasint"`
|
||||||
Interval uint16 `cbor:"3,keyasint" json:"interval"` // seconds
|
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.
|
// Result holds aggregated probe results for a single target.
|
||||||
//
|
//
|
||||||
// 0: avg latency in ms
|
// 0: avg response in microseconds
|
||||||
//
|
//
|
||||||
// 1: min latency in ms
|
// 1: 1h average response in microseconds
|
||||||
//
|
//
|
||||||
// 2: max latency in ms
|
// 2: min response in microseconds
|
||||||
|
//
|
||||||
|
// 3: 1h min response in microseconds
|
||||||
|
//
|
||||||
|
// 4: max response in microseconds
|
||||||
|
//
|
||||||
|
// 5: 1h max response in microseconds
|
||||||
|
//
|
||||||
|
// 6: packet loss percentage (0-100)
|
||||||
|
//
|
||||||
|
// 7: 1h packet loss percentage (0-100)
|
||||||
|
type Result struct {
|
||||||
|
AvgResponse int64 `cbor:"0,keyasint,omitempty"`
|
||||||
|
AvgResponse1h int64 `cbor:"1,keyasint,omitempty"`
|
||||||
|
MinResponse int64 `cbor:"2,keyasint,omitempty"`
|
||||||
|
MinResponse1h int64 `cbor:"3,keyasint,omitempty"`
|
||||||
|
MaxResponse int64 `cbor:"4,keyasint,omitempty"`
|
||||||
|
MaxResponse1h int64 `cbor:"5,keyasint,omitempty"`
|
||||||
|
PacketLoss float64 `cbor:"6,keyasint,omitempty"`
|
||||||
|
PacketLoss1h float64 `cbor:"7,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats holds only 1m values for a single target, which are used for charts.
|
||||||
|
//
|
||||||
|
// 0: avg response in microseconds
|
||||||
|
//
|
||||||
|
// 1: min response in microseconds
|
||||||
|
//
|
||||||
|
// 2: max response in microseconds
|
||||||
//
|
//
|
||||||
// 3: packet loss percentage (0-100)
|
// 3: packet loss percentage (0-100)
|
||||||
type Result []float64
|
type Stats []float64
|
||||||
|
|
||||||
// Key returns the map key used for this probe config (e.g. "icmp:1.1.1.1", "tcp:host:443", "http:https://example.com").
|
func (s Stats) FromResult(result Result) Stats {
|
||||||
func (c Config) Key() string {
|
return Stats{
|
||||||
switch c.Protocol {
|
float64(result.AvgResponse),
|
||||||
case "tcp":
|
float64(result.MinResponse),
|
||||||
return c.Protocol + ":" + c.Target + ":" + strconv.FormatUint(uint64(c.Port), 10)
|
float64(result.MaxResponse),
|
||||||
default:
|
result.PacketLoss,
|
||||||
return c.Protocol + ":" + c.Target
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,155 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/probe"
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func bindNetworkProbesEvents(h *Hub) {
|
// generateProbeID creates a stable hash ID for a probe based on its configuration and the system it belongs to.
|
||||||
// on create, make sure the id is set to a stable hash
|
func generateProbeID(systemId string, config probe.Config) string {
|
||||||
h.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
args := []string{systemId, config.Target, config.Protocol}
|
||||||
systemID := e.Record.GetString("system")
|
// only use port for TCP probes, since for other protocols it's not relevant as standalone value
|
||||||
config := &probe.Config{
|
if config.Protocol == "tcp" {
|
||||||
Target: e.Record.GetString("target"),
|
args = append(args, strconv.FormatUint(uint64(config.Port), 10))
|
||||||
Protocol: e.Record.GetString("protocol"),
|
|
||||||
Port: uint16(e.Record.GetInt("port")),
|
|
||||||
Interval: uint16(e.Record.GetInt("interval")),
|
|
||||||
}
|
}
|
||||||
key := config.Key()
|
return systems.MakeStableHashId(args...)
|
||||||
id := systems.MakeStableHashId(systemID, key)
|
}
|
||||||
|
|
||||||
|
// 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)
|
e.Record.Set("id", id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// sync probe to agent on creation
|
// sync probe to agent on creation and persist the first result immediately when available
|
||||||
h.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
hub.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
systemID := e.Record.GetString("system")
|
err := e.Next()
|
||||||
h.syncProbesToAgent(systemID)
|
if err != nil {
|
||||||
return e.Next()
|
return err
|
||||||
|
}
|
||||||
|
if !e.Record.GetBool("enabled") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// if system connected, run the probe immediately
|
||||||
|
// if not, return and wait for the system to connect and sync probes on reg schedule
|
||||||
|
system, err := hub.sm.GetSystem(e.Record.GetString("system"))
|
||||||
|
if err == nil && system.Status == "up" {
|
||||||
|
go hub.upsertNetworkProbe(e.Record, true)
|
||||||
|
}
|
||||||
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// On API update requests, if the probe config changed in a way that requires a new ID, create a new
|
||||||
|
// record with the new ID and delete the old one. Otherwise, just update the existing probe on the agent.
|
||||||
|
hub.OnRecordUpdateRequest("network_probes").BindFunc(func(e *core.RecordRequestEvent) error {
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
// only tcp uses port - set other protocols port to zero
|
||||||
|
if e.Record.GetString("protocol") != "tcp" {
|
||||||
|
e.Record.Set("port", 0)
|
||||||
|
}
|
||||||
|
ID := generateProbeID(systemID, *probeConfigFromRecord(e.Record))
|
||||||
|
if ID != e.Record.Id {
|
||||||
|
newRecord := copyProbeToNewRecord(e.Record, ID)
|
||||||
|
if err := e.App.Save(newRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.App.Delete(e.Record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := e.Next()
|
||||||
|
if e.Record.GetBool("enabled") {
|
||||||
|
// if the probe is enabled, sync the updated config to the agent now
|
||||||
|
runNow := !e.Record.Original().GetBool("enabled")
|
||||||
|
err = hub.upsertNetworkProbe(e.Record, runNow)
|
||||||
|
} else {
|
||||||
|
// if the probe is paused, remove it from the agent
|
||||||
|
err = hub.deleteNetworkProbe(e.Record)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
hub.Logger().Warn("failed to sync updated probe", "system", systemID, "probe", e.Record.Id, "err", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
// sync probe to agent on delete
|
// sync probe to agent on delete
|
||||||
h.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
hub.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||||
systemID := e.Record.GetString("system")
|
if err := hub.deleteNetworkProbe(e.Record); err != nil {
|
||||||
h.syncProbesToAgent(systemID)
|
hub.Logger().Warn("failed to delete probe on agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
|
||||||
|
}
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
// TODO: if enabled changes, sync to agent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncProbesToAgent fetches enabled probes for a system and sends them to the agent.
|
// probeConfigFromRecord builds a probe config from a network_probes record.
|
||||||
func (h *Hub) syncProbesToAgent(systemID string) {
|
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) {
|
||||||
|
nowString := time.Now().UTC().Format(types.DefaultDateLayout)
|
||||||
|
record.Set("res", result.AvgResponse)
|
||||||
|
record.Set("resAvg1h", result.AvgResponse1h)
|
||||||
|
record.Set("resMin1h", result.MinResponse1h)
|
||||||
|
record.Set("resMax1h", result.MaxResponse1h)
|
||||||
|
record.Set("loss1h", result.PacketLoss1h)
|
||||||
|
record.Set("updated", nowString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyProbeToNewRecord creates a new record with the same field values as the old one.
|
||||||
|
// This is used when the probe config changes in a way that requires a new ID, so we need
|
||||||
|
// to create a new record with the new ID and delete the old one.
|
||||||
|
func copyProbeToNewRecord(oldRecord *core.Record, newID string) *core.Record {
|
||||||
|
collection := oldRecord.Collection()
|
||||||
|
newRecord := core.NewRecord(collection)
|
||||||
|
newRecord.Id = newID
|
||||||
|
fields := []string{"system", "name", "target", "protocol", "port", "interval", "enabled"}
|
||||||
|
for _, field := range fields {
|
||||||
|
newRecord.Set(field, oldRecord.Get(field))
|
||||||
|
}
|
||||||
|
return newRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertNetworkProbe creates or updates the record's probe on the target system. If runNow
|
||||||
|
// is true, it will also trigger an immediate probe run and update the record with the result.
|
||||||
|
func (h *Hub) upsertNetworkProbe(record *core.Record, runNow bool) error {
|
||||||
|
systemID := record.GetString("system")
|
||||||
system, err := h.sm.GetSystem(systemID)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
result, err := system.UpsertNetworkProbe(*probeConfigFromRecord(record), runNow)
|
||||||
configs := h.sm.GetProbeConfigsForSystem(systemID)
|
if err != nil || result == nil {
|
||||||
|
return err
|
||||||
go func() {
|
|
||||||
if err := system.SyncNetworkProbes(configs); err != nil {
|
|
||||||
h.Logger().Warn("failed to sync probes to agent", "system", systemID, "err", err)
|
|
||||||
}
|
}
|
||||||
}()
|
setProbeResultFields(record, *result)
|
||||||
|
return h.App.SaveNoValidate(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
155
internal/hub/probes_test.go
Normal file
155
internal/hub/probes_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
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: 0,
|
||||||
|
Interval: 60,
|
||||||
|
},
|
||||||
|
expected: "a20a5827",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTP probe on example.com with different port",
|
||||||
|
systemID: "sys123",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "http",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 8080,
|
||||||
|
Interval: 60,
|
||||||
|
},
|
||||||
|
expected: "a20a5827",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "ab602ae7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Same probe, different interval",
|
||||||
|
systemID: "sys1234",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "http",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 80,
|
||||||
|
Interval: 120,
|
||||||
|
},
|
||||||
|
expected: "ab602ae7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "6d13a4a4",
|
||||||
|
}, {
|
||||||
|
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: "ddd6c81",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TCP probe on example.com with port 443",
|
||||||
|
systemID: "sys789",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "tcp",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 443,
|
||||||
|
Interval: 30,
|
||||||
|
},
|
||||||
|
expected: "677b991",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TCP probe on example.com with port 8443",
|
||||||
|
systemID: "sys789",
|
||||||
|
config: probe.Config{
|
||||||
|
Protocol: "tcp",
|
||||||
|
Target: "example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Interval: 30,
|
||||||
|
},
|
||||||
|
expected: "84167969",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyProbeToNewRecordDropsResultFields(t *testing.T) {
|
||||||
|
hub, testApp, err := createTestHub(t)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer cleanupTestHub(hub, testApp)
|
||||||
|
|
||||||
|
collection, err := hub.FindCachedCollectionByNameOrId("network_probes")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
oldRecord := core.NewRecord(collection)
|
||||||
|
oldRecord.Load(map[string]any{
|
||||||
|
"system": "sys123",
|
||||||
|
"name": "Example",
|
||||||
|
"target": "https://example.com",
|
||||||
|
"protocol": "http",
|
||||||
|
"port": 443,
|
||||||
|
"interval": 60,
|
||||||
|
"enabled": true,
|
||||||
|
"res": 1200,
|
||||||
|
"resAvg1h": 1300,
|
||||||
|
"resMin1h": 900,
|
||||||
|
"resMax1h": 1600,
|
||||||
|
"loss1h": 5,
|
||||||
|
"updated": "2026-04-29 12:00:00.000Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
newRecord := copyProbeToNewRecord(oldRecord, "next12345")
|
||||||
|
|
||||||
|
assert.Equal(t, "next12345", newRecord.Id)
|
||||||
|
assert.Equal(t, "Example", newRecord.GetString("name"))
|
||||||
|
assert.Equal(t, "https://example.com", newRecord.GetString("target"))
|
||||||
|
assert.Equal(t, "http", newRecord.GetString("protocol"))
|
||||||
|
assert.Equal(t, 443, newRecord.GetInt("port"))
|
||||||
|
assert.True(t, newRecord.GetBool("enabled"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("res"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("resAvg1h"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("resMin1h"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("resMax1h"))
|
||||||
|
assert.Zero(t, newRecord.GetFloat("loss1h"))
|
||||||
|
assert.Equal(t, "", newRecord.GetString("updated"))
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"log/slog"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,6 +30,7 @@ import (
|
|||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -315,81 +315,94 @@ 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 {
|
func updateNetworkProbesRecords(app core.App, probeResults map[string]probe.Result, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(probeResults) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
collectionName := "network_probes"
|
const probeCollectionName = "network_probes"
|
||||||
|
|
||||||
// If realtime updates are active, we save via PocketBase records to trigger realtime events.
|
// 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
|
// Otherwise we can do a more efficient direct update via SQL
|
||||||
realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool {
|
realtimeActive := utils.RealtimeActiveForCollection(app, probeCollectionName, func(filterQuery string) bool {
|
||||||
slog.Info("Checking realtime subscription filter for network probes", "filterQuery", filterQuery)
|
|
||||||
return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId)
|
return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
nowMilli := now.UnixMilli()
|
||||||
|
nowString := now.Format(types.DefaultDateLayout)
|
||||||
var db dbx.Builder
|
var db dbx.Builder
|
||||||
var nowString string
|
|
||||||
var updateQuery *dbx.Query
|
var updateQuery *dbx.Query
|
||||||
if !realtimeActive {
|
if !realtimeActive {
|
||||||
db = app.DB()
|
db = app.DB()
|
||||||
nowString = time.Now().UTC().Format(types.DefaultDateLayout)
|
probeFields := []string{"res", "resMin1h", "resMax1h", "resAvg1h", "loss1h", "updated"}
|
||||||
sql := fmt.Sprintf("UPDATE %s SET latency={:latency}, loss={:loss}, updated={:updated} WHERE id={:id}", collectionName)
|
setClauses := make([]string, len(probeFields))
|
||||||
updateQuery = db.NewQuery(sql)
|
for i, f := range probeFields {
|
||||||
|
setClauses[i] = fmt.Sprintf("%s={:%s}", f, f)
|
||||||
|
}
|
||||||
|
queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", probeCollectionName, strings.Join(setClauses, ", "))
|
||||||
|
updateQuery = db.NewQuery(queryString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// insert network probe stats records
|
// update network_probes records
|
||||||
|
for id, result := range probeResults {
|
||||||
|
probeData := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"res": result.AvgResponse,
|
||||||
|
"resAvg1h": result.AvgResponse1h,
|
||||||
|
"resMin1h": result.MinResponse1h,
|
||||||
|
"resMax1h": result.MaxResponse1h,
|
||||||
|
"loss1h": result.PacketLoss1h,
|
||||||
|
"updated": nowString,
|
||||||
|
}
|
||||||
switch realtimeActive {
|
switch realtimeActive {
|
||||||
case true:
|
case true:
|
||||||
collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats")
|
var record *core.Record
|
||||||
|
record, err = app.FindRecordById(probeCollectionName, id)
|
||||||
|
if err == nil {
|
||||||
|
record.Load(probeData)
|
||||||
|
err = app.SaveNoValidate(record)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_, err = updateQuery.Bind(dbx.Params(probeData)).Execute()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", id, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle stats collection as well
|
||||||
|
const statsCollectionName = "network_probe_stats"
|
||||||
|
|
||||||
|
// we don't need the hour values for the stats collection
|
||||||
|
stats := make(map[string]probe.Stats, len(probeResults))
|
||||||
|
for key, result := range probeResults {
|
||||||
|
stats[key] = probe.Stats{}.FromResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
statsRecordData := map[string]any{
|
||||||
|
"system": systemId,
|
||||||
|
"type": "1m",
|
||||||
|
"created": nowMilli,
|
||||||
|
}
|
||||||
|
var statsJson types.JSONRaw
|
||||||
|
if err = statsJson.Scan(stats); err == nil {
|
||||||
|
statsRecordData["stats"] = statsJson
|
||||||
|
switch realtimeActive {
|
||||||
|
case true:
|
||||||
|
collection, _ := app.FindCachedCollectionByNameOrId(statsCollectionName)
|
||||||
record := core.NewRecord(collection)
|
record := core.NewRecord(collection)
|
||||||
record.Set("system", systemId)
|
record.Load(statsRecordData)
|
||||||
record.Set("stats", data)
|
|
||||||
record.Set("type", "1m")
|
|
||||||
err = app.SaveNoValidate(record)
|
err = app.SaveNoValidate(record)
|
||||||
default:
|
default:
|
||||||
if dataJson, e := json.Marshal(data); e == nil {
|
statsRecordData["id"] = security.PseudorandomStringWithAlphabet(10, core.DefaultIdAlphabet)
|
||||||
sql := "INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})"
|
_, err = db.Insert(statsCollectionName, dbx.Params(statsRecordData)).Execute()
|
||||||
insertQuery := db.NewQuery(sql)
|
|
||||||
_, err = insertQuery.Bind(dbx.Params{
|
|
||||||
"system": systemId,
|
|
||||||
"stats": dataJson,
|
|
||||||
"type": "1m",
|
|
||||||
"created": nowString,
|
|
||||||
}).Execute()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
|
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// update network_probes records
|
|
||||||
for key := range data {
|
|
||||||
probe := data[key]
|
|
||||||
id := MakeStableHashId(systemId, key)
|
|
||||||
switch realtimeActive {
|
|
||||||
case true:
|
|
||||||
var record *core.Record
|
|
||||||
record, err = app.FindRecordById(collectionName, id)
|
|
||||||
if err == nil {
|
|
||||||
record.Set("latency", probe[0])
|
|
||||||
record.Set("loss", probe[3])
|
|
||||||
err = app.SaveNoValidate(record)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
_, err = updateQuery.Bind(dbx.Params{
|
|
||||||
"id": id,
|
|
||||||
"latency": probe[0],
|
|
||||||
"loss": probe[3],
|
|
||||||
"updated": nowString,
|
|
||||||
}).Execute()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", key, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
configs := sm.GetProbeConfigsForSystem(systemId)
|
configs := sm.GetProbeConfigsForSystem(systemId)
|
||||||
if len(configs) > 0 {
|
if len(configs) > 0 {
|
||||||
if err := system.SyncNetworkProbes(configs); err != nil {
|
if err := system.SyncNetworkProbes(configs); err != nil {
|
||||||
sm.hub.Logger().Warn("failed to sync probes on connect", "system", systemId, "err", err)
|
sm.hub.Logger().Warn("failed to sync probes to agent", "system", systemId, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -344,26 +344,11 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
|||||||
|
|
||||||
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
||||||
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
||||||
records, err := sm.hub.FindRecordsByFilter(
|
var configs []probe.Config
|
||||||
"network_probes",
|
_ = sm.hub.DB().
|
||||||
"system = {:system} && enabled = true",
|
NewQuery("SELECT id, target, protocol, port, interval FROM network_probes WHERE system = {:system} AND enabled = true").
|
||||||
"",
|
Bind(dbx.Params{"system": systemID}).
|
||||||
0, 0,
|
All(&configs)
|
||||||
dbx.Params{"system": systemID},
|
|
||||||
)
|
|
||||||
if err != nil || len(records) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
configs := make([]probe.Config, 0, len(records))
|
|
||||||
for _, r := range records {
|
|
||||||
configs = append(configs, probe.Config{
|
|
||||||
Target: r.GetString("target"),
|
|
||||||
Protocol: r.GetString("protocol"),
|
|
||||||
Port: uint16(r.GetInt("port")),
|
|
||||||
Interval: uint16(r.GetInt("interval")),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return configs
|
return configs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,48 +10,39 @@ import (
|
|||||||
|
|
||||||
// SyncNetworkProbes sends probe configurations to the agent.
|
// SyncNetworkProbes sends probe configurations to the agent.
|
||||||
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
_, err := sys.syncNetworkProbes(probe.SyncRequest{Action: probe.SyncActionReplace, Configs: configs})
|
||||||
defer cancel()
|
return err
|
||||||
var result string
|
|
||||||
return sys.request(ctx, common.SyncNetworkProbes, configs, &result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchNetworkProbeResults fetches probe results from the agent.
|
// UpsertNetworkProbe sends a single probe configuration change to the agent.
|
||||||
// func (sys *System) FetchNetworkProbeResults() (map[string]probe.Result, error) {
|
func (sys *System) UpsertNetworkProbe(config probe.Config, runNow bool) (*probe.Result, error) {
|
||||||
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
resp, err := sys.syncNetworkProbes(probe.SyncRequest{
|
||||||
// defer cancel()
|
Action: probe.SyncActionUpsert,
|
||||||
// var results map[string]probe.Result
|
Config: config,
|
||||||
// err := sys.request(ctx, common.GetNetworkProbeResults, nil, &results)
|
RunNow: runNow,
|
||||||
// return results, err
|
})
|
||||||
// }
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Result == (probe.Result{}) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
result := resp.Result
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// hasEnabledProbes returns true if this system has any enabled network probes.
|
// DeleteNetworkProbe removes a single probe task from the agent.
|
||||||
// func (sys *System) hasEnabledProbes() bool {
|
func (sys *System) DeleteNetworkProbe(id string) error {
|
||||||
// count, err := sys.manager.hub.CountRecords("network_probes",
|
_, err := sys.syncNetworkProbes(probe.SyncRequest{
|
||||||
// dbx.NewExp("system = {:system} AND enabled = true", dbx.Params{"system": sys.Id}))
|
Action: probe.SyncActionDelete,
|
||||||
// return err == nil && count > 0
|
Config: probe.Config{ID: id},
|
||||||
// }
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// fetchAndSaveProbeResults fetches probe results and saves them to the database.
|
func (sys *System) syncNetworkProbes(req probe.SyncRequest) (probe.SyncResponse, error) {
|
||||||
// func (sys *System) fetchAndSaveProbeResults() {
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
// hub := sys.manager.hub
|
defer cancel()
|
||||||
|
var result probe.SyncResponse
|
||||||
// results, err := sys.FetchNetworkProbeResults()
|
return result, sys.request(ctx, common.SyncNetworkProbes, req, &result)
|
||||||
// if err != nil || len(results) == 0 {
|
}
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// collection, err := hub.FindCachedCollectionByNameOrId("network_probe_stats")
|
|
||||||
// if err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// record := core.NewRecord(collection)
|
|
||||||
// record.Set("system", sys.Id)
|
|
||||||
// record.Set("stats", results)
|
|
||||||
// record.Set("type", "1m")
|
|
||||||
|
|
||||||
// if err := hub.SaveNoValidate(record); err != nil {
|
|
||||||
// hub.Logger().Warn("failed to save probe stats", "system", sys.Id, "err", err)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -1701,21 +1701,15 @@ func init() {
|
|||||||
"viewRule": null
|
"viewRule": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "np_probes_001",
|
|
||||||
"listRule": null,
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
"createRule": null,
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
"deleteRule": null,
|
||||||
"name": "network_probes",
|
|
||||||
"type": "base",
|
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
"autogeneratePattern": "[a-z0-9]{10}",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "text3208210256",
|
"id": "text3208210256",
|
||||||
"max": 15,
|
"max": 10,
|
||||||
"min": 15,
|
"min": 6,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"pattern": "^[a-z0-9]+$",
|
"pattern": "^[a-z0-9]+$",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
@@ -1738,6 +1732,7 @@ func init() {
|
|||||||
"type": "relation"
|
"type": "relation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "np_name",
|
"id": "np_name",
|
||||||
"max": 200,
|
"max": 200,
|
||||||
@@ -1751,6 +1746,7 @@ func init() {
|
|||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "np_target",
|
"id": "np_target",
|
||||||
"max": 500,
|
"max": 500,
|
||||||
@@ -1772,7 +1768,11 @@ func init() {
|
|||||||
"required": true,
|
"required": true,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "select",
|
"type": "select",
|
||||||
"values": ["icmp", "tcp", "http"]
|
"values": [
|
||||||
|
"icmp",
|
||||||
|
"tcp",
|
||||||
|
"http"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
@@ -1798,6 +1798,66 @@ func init() {
|
|||||||
"system": false,
|
"system": false,
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number926446584",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "res",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1006954605",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "resAvg1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4267669802",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "resMin1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number591433223",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "resMax1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3726709001",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "loss1h",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "np_enabled",
|
"id": "np_enabled",
|
||||||
@@ -1819,36 +1879,37 @@ func init() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "autodate3332085495",
|
"id": "date3332085495",
|
||||||
|
"max": "",
|
||||||
|
"min": "",
|
||||||
"name": "updated",
|
"name": "updated",
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "autodate"
|
"type": "date"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"id": "np_probes_001",
|
||||||
"indexes": [
|
"indexes": [
|
||||||
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `enabled` + "`" + `\n)"
|
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||||
],
|
],
|
||||||
"system": false
|
"listRule": null,
|
||||||
|
"name": "network_probes",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "np_stats_001",
|
|
||||||
"listRule": null,
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
"createRule": null,
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
"deleteRule": null,
|
||||||
"name": "network_probe_stats",
|
|
||||||
"type": "base",
|
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
"autogeneratePattern": "[a-z0-9]{10}",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "text3208210256",
|
"id": "text3208210256",
|
||||||
"max": 15,
|
"max": 10,
|
||||||
"min": 15,
|
"min": 10,
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"pattern": "^[a-z0-9]+$",
|
"pattern": "^[a-z0-9]+$",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
@@ -1889,33 +1950,37 @@ func init() {
|
|||||||
"required": true,
|
"required": true,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "select",
|
"type": "select",
|
||||||
"values": ["1m", "10m", "20m", "120m", "480m"]
|
"values": [
|
||||||
|
"1m",
|
||||||
|
"10m",
|
||||||
|
"20m",
|
||||||
|
"120m",
|
||||||
|
"480m"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "autodate2990389176",
|
"id": "number2990389176",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
"name": "created",
|
"name": "created",
|
||||||
"onCreate": true,
|
"onlyInt": false,
|
||||||
"onUpdate": false,
|
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "autodate"
|
"type": "number"
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate3332085495",
|
|
||||||
"name": "updated",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"id": "np_stats_001",
|
||||||
"indexes": [
|
"indexes": [
|
||||||
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
||||||
],
|
],
|
||||||
"system": false
|
"listRule": null,
|
||||||
|
"name": "network_probe_stats",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(app core.App) error {
|
|
||||||
collection, err := app.FindCollectionByNameOrId("np_probes_001")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// add field
|
|
||||||
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number926446584",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "latency",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
}`)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// add field
|
|
||||||
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3726709001",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "loss",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
}`)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.Save(collection)
|
|
||||||
}, func(app core.App) error {
|
|
||||||
collection, err := app.FindCollectionByNameOrId("np_probes_001")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove field
|
|
||||||
collection.Fields.RemoveById("number926446584")
|
|
||||||
|
|
||||||
// remove field
|
|
||||||
collection.Fields.RemoveById("number3726709001")
|
|
||||||
|
|
||||||
return app.Save(collection)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(app core.App) error {
|
|
||||||
jsonData := `[
|
|
||||||
{
|
|
||||||
"id": "np_probes_001",
|
|
||||||
"listRule": null,
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"name": "network_probes",
|
|
||||||
"type": "base",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 15,
|
|
||||||
"min": 15,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_system",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_name",
|
|
||||||
"max": 200,
|
|
||||||
"min": 0,
|
|
||||||
"name": "name",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_target",
|
|
||||||
"max": 500,
|
|
||||||
"min": 1,
|
|
||||||
"name": "target",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_protocol",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"name": "protocol",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "select",
|
|
||||||
"values": ["icmp", "tcp", "http"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_port",
|
|
||||||
"max": 65535,
|
|
||||||
"min": 0,
|
|
||||||
"name": "port",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_interval",
|
|
||||||
"max": 3600,
|
|
||||||
"min": 1,
|
|
||||||
"name": "interval",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "np_enabled",
|
|
||||||
"name": "enabled",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "bool"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate2990389176",
|
|
||||||
"name": "created",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": false,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate3332085495",
|
|
||||||
"name": "updated",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `enabled` + "`" + `\n)"
|
|
||||||
],
|
|
||||||
"system": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "np_stats_001",
|
|
||||||
"listRule": null,
|
|
||||||
"viewRule": null,
|
|
||||||
"createRule": null,
|
|
||||||
"updateRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"name": "network_probe_stats",
|
|
||||||
"type": "base",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 15,
|
|
||||||
"min": 15,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "nps_system",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "nps_stats",
|
|
||||||
"maxSize": 2000000,
|
|
||||||
"name": "stats",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "nps_type",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"name": "type",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "select",
|
|
||||||
"values": ["1m", "10m", "20m", "120m", "480m"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate2990389176",
|
|
||||||
"name": "created",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": false,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate3332085495",
|
|
||||||
"name": "updated",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
|
||||||
],
|
|
||||||
"system": false
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|
||||||
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
|
|
||||||
}, func(app core.App) error {
|
|
||||||
// down: remove the network probe collections
|
|
||||||
if c, err := app.FindCollectionByNameOrId("network_probes"); err == nil {
|
|
||||||
if err := app.Delete(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c, err := app.FindCollectionByNameOrId("network_probe_stats"); err == nil {
|
|
||||||
if err := app.Delete(c); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
57
internal/records/probe_averaging_test.go
Normal file
57
internal/records/probe_averaging_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//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,5,20,1.5]}`,
|
||||||
|
})
|
||||||
|
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":[22.5,10,60,0]}`,
|
||||||
|
})
|
||||||
|
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, 4)
|
||||||
|
assert.InDelta(t, 16.25, stats[0], 0.001) // avg of avg
|
||||||
|
assert.InDelta(t, 5, stats[1], 0.001) // min of mins
|
||||||
|
assert.InDelta(t, 60, stats[2], 0.001) // max of maxes
|
||||||
|
assert.InDelta(t, 0.75, stats[3], 0.001) // avg of packet loss
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RecordManager struct {
|
type RecordManager struct {
|
||||||
@@ -40,7 +41,7 @@ type StatsRecord struct {
|
|||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
// start := time.Now()
|
now := time.Now().UTC()
|
||||||
longerRecordData := []LongerRecordData{
|
longerRecordData := []LongerRecordData{
|
||||||
{
|
{
|
||||||
shorterType: "1m",
|
shorterType: "1m",
|
||||||
@@ -71,6 +72,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 := [3]*core.Collection{}
|
||||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,49 +98,64 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
recordData := longerRecordData[i]
|
recordData := longerRecordData[i]
|
||||||
// log.Println("processing longer record type", recordData.longerType)
|
// log.Println("processing longer record type", recordData.longerType)
|
||||||
// add one minute padding for longer records because they are created slightly later than the job start time
|
// add one minute padding for longer records because they are created slightly later than the job start time
|
||||||
longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute)
|
longerRecordPeriod := now.Add(recordData.longerTimeDuration + time.Minute)
|
||||||
// shorter records are created independently of longer records, so we shouldn't need to add padding
|
// shorter records are created independently of longer records, so we shouldn't need to add padding
|
||||||
shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration)
|
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
|
||||||
// loop through both collections
|
// loop through both collections
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// check creation time of last longer record if not 10m, since 10m is created every run
|
// check creation time of last longer record if not 10m, since 10m is created every run
|
||||||
if recordData.longerType != "10m" {
|
if recordData.longerType != "10m" {
|
||||||
count, err := txApp.CountRecords(
|
var existingRecord struct {
|
||||||
collection.Id,
|
Id string
|
||||||
dbx.NewExp(
|
}
|
||||||
"system = {:system} AND type = {:type} AND created > {:created}",
|
|
||||||
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
params := dbx.Params{
|
||||||
),
|
"type": recordData.longerType,
|
||||||
)
|
"system": system.Id,
|
||||||
|
"created": getCreatedTimeField(collection.Name, longerRecordPeriod),
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.Select("id").
|
||||||
|
From(collection.Name).
|
||||||
|
Where(dbx.NewExp("system = {:system} AND type = {:type} AND created > {:created}", params)).
|
||||||
|
Limit(1).
|
||||||
|
One(&existingRecord)
|
||||||
|
|
||||||
// continue if longer record exists
|
// continue if longer record exists
|
||||||
if err != nil || count > 0 {
|
if existingRecord.Id != "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// get shorter records from the past x minutes
|
// get shorter records from the past x minutes
|
||||||
var recordIds RecordIds
|
var recordIds RecordIds
|
||||||
|
|
||||||
err := txApp.DB().
|
params := dbx.Params{
|
||||||
Select("id").
|
|
||||||
From(collection.Name).
|
|
||||||
AndWhere(dbx.NewExp(
|
|
||||||
"system={:system} AND type={:type} AND created > {:created}",
|
|
||||||
dbx.Params{
|
|
||||||
"type": recordData.shorterType,
|
"type": recordData.shorterType,
|
||||||
"system": system.Id,
|
"system": system.Id,
|
||||||
"created": shorterRecordPeriod,
|
"created": getCreatedTimeField(collection.Name, shorterRecordPeriod),
|
||||||
},
|
}
|
||||||
|
|
||||||
|
_ = txApp.DB().
|
||||||
|
Select("id").
|
||||||
|
From(collection.Name).
|
||||||
|
Where(dbx.NewExp(
|
||||||
|
"system={:system} AND type={:type} AND created > {:created}",
|
||||||
|
params,
|
||||||
)).
|
)).
|
||||||
All(&recordIds)
|
All(&recordIds)
|
||||||
|
|
||||||
// continue if not enough shorter records
|
// continue if not enough shorter records
|
||||||
if err != nil || len(recordIds) < recordData.minShorterRecords {
|
if len(recordIds) < recordData.minShorterRecords {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// average the shorter records and create longer record
|
// average the shorter records and create longer record
|
||||||
longerRecord := core.NewRecord(collection)
|
longerRecord := core.NewRecord(collection)
|
||||||
longerRecord.Set("system", system.Id)
|
longerRecord.Set("system", system.Id)
|
||||||
longerRecord.Set("type", recordData.longerType)
|
longerRecord.Set("type", recordData.longerType)
|
||||||
|
// network_probe_stats uses created as unix timestamp in milliseconds, so we need to set it manually here instead of relying on the default created field
|
||||||
|
if collection.Name == "network_probe_stats" {
|
||||||
|
longerRecord.Set("created", now.UnixMilli())
|
||||||
|
}
|
||||||
switch collection.Name {
|
switch collection.Name {
|
||||||
case "system_stats":
|
case "system_stats":
|
||||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||||
@@ -157,7 +174,14 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
// slog.Info("finished creating longer records", "time (ms)", time.Since(now).Milliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCreatedTimeField(collectionName string, period time.Time) any {
|
||||||
|
if collectionName == "network_probe_stats" {
|
||||||
|
return period.UnixMilli()
|
||||||
|
}
|
||||||
|
return period.Format(types.DefaultDateLayout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
@@ -507,11 +531,11 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AverageProbeStats averages probe stats across multiple records.
|
// AverageProbeStats averages probe stats across multiple records.
|
||||||
// For each probe key: avg of avgs, min of mins, max of maxes, avg of losses.
|
// 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 {
|
func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Stats {
|
||||||
type probeValues struct {
|
type probeValues struct {
|
||||||
sums probe.Result
|
sums probe.Stats
|
||||||
count float64
|
counts []int
|
||||||
}
|
}
|
||||||
|
|
||||||
query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}")
|
query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}")
|
||||||
@@ -522,42 +546,59 @@ func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) ma
|
|||||||
for _, rec := range records {
|
for _, rec := range records {
|
||||||
row.Stats = row.Stats[:0]
|
row.Stats = row.Stats[:0]
|
||||||
query.Bind(dbx.Params{"id": rec.Id}).One(&row)
|
query.Bind(dbx.Params{"id": rec.Id}).One(&row)
|
||||||
var rawStats map[string]probe.Result
|
var rawStats map[string]probe.Stats
|
||||||
if err := json.Unmarshal(row.Stats, &rawStats); err != nil {
|
if err := json.Unmarshal(row.Stats, &rawStats); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for key, vals := range rawStats {
|
for key, vals := range rawStats {
|
||||||
s, ok := sums[key]
|
s, ok := sums[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
s = &probeValues{sums: make(probe.Result, len(vals))}
|
s = &probeValues{sums: make(probe.Stats, len(vals)), counts: make([]int, len(vals))}
|
||||||
sums[key] = s
|
sums[key] = s
|
||||||
}
|
}
|
||||||
|
if len(vals) > len(s.sums) {
|
||||||
|
expandedSums := make(probe.Stats, 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 {
|
for i := range vals {
|
||||||
switch i {
|
switch i {
|
||||||
case 1: // min fields
|
case 1: // min fields
|
||||||
if s.count == 0 || vals[i] < s.sums[i] {
|
if s.counts[i] == 0 || vals[i] < s.sums[i] {
|
||||||
s.sums[i] = vals[i]
|
s.sums[i] = vals[i]
|
||||||
}
|
}
|
||||||
case 2: // max fields
|
case 2: // max fields
|
||||||
if vals[i] > s.sums[i] {
|
if s.counts[i] == 0 || vals[i] > s.sums[i] {
|
||||||
s.sums[i] = vals[i]
|
s.sums[i] = vals[i]
|
||||||
}
|
}
|
||||||
default: // average fields
|
default: // average fields
|
||||||
s.sums[i] += vals[i]
|
s.sums[i] += vals[i]
|
||||||
}
|
}
|
||||||
|
s.counts[i]++
|
||||||
}
|
}
|
||||||
s.count++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// compute final averages
|
// compute final averages
|
||||||
result := make(map[string]probe.Result, len(sums))
|
result := make(map[string]probe.Stats, len(sums))
|
||||||
for key, s := range sums {
|
for key, s := range sums {
|
||||||
if s.count == 0 {
|
if len(s.counts) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.sums[0] = twoDecimals(s.sums[0] / s.count) // avg latency
|
for i := range s.sums {
|
||||||
s.sums[3] = twoDecimals(s.sums[3] / s.count) // packet loss
|
switch i {
|
||||||
|
case 1, 2: // 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
|
result[key] = s.sums
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package records
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -75,26 +74,19 @@ func deleteOldSystemStats(app core.App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
db := app.DB()
|
||||||
|
|
||||||
for _, collection := range collections {
|
for _, collection := range collections {
|
||||||
// Build the WHERE clause
|
query := db.Delete(collection, dbx.NewExp("type={:type} AND created<{:created}"))
|
||||||
var conditionParts []string
|
for _, rd := range recordData {
|
||||||
var params dbx.Params = make(map[string]any)
|
if _, err := query.Bind(dbx.Params{
|
||||||
for i := range recordData {
|
"type": rd.recordType,
|
||||||
rd := recordData[i]
|
"created": getCreatedTimeField(collection, now.Add(-rd.retention)),
|
||||||
// Create parameterized condition for this record type
|
}).Execute(); err != nil {
|
||||||
dateParam := fmt.Sprintf("date%d", i)
|
|
||||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
|
||||||
params[dateParam] = now.Add(-rd.retention)
|
|
||||||
}
|
|
||||||
// Combine conditions with OR
|
|
||||||
conditionStr := strings.Join(conditionParts, " OR ")
|
|
||||||
// Construct and execute the full raw query
|
|
||||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
|
||||||
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function AreaChartDefault({
|
|||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats ?? []
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
@@ -111,6 +111,8 @@ export default function AreaChartDefault({
|
|||||||
})
|
})
|
||||||
}, [areasKey, displayMaxToggled])
|
}, [areasKey, displayMaxToggled])
|
||||||
|
|
||||||
|
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -146,7 +148,7 @@ export default function AreaChartDefault({
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
|
{XAxis}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
@@ -167,5 +169,5 @@ export default function AreaChartDefault({
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, filter, Areas])
|
}, [displayData, yAxisWidth, filter, Areas, XAxis])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type DataPoint<T = SystemStatsRecord> = {
|
|||||||
order?: number
|
order?: number
|
||||||
strokeOpacity?: number
|
strokeOpacity?: number
|
||||||
activeDot?: boolean
|
activeDot?: boolean
|
||||||
|
dot?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LineChartDefault({
|
export default function LineChartDefault({
|
||||||
@@ -67,7 +68,7 @@ export default function LineChartDefault({
|
|||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats ?? []
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
@@ -85,7 +86,9 @@ export default function LineChartDefault({
|
|||||||
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// Use a stable key derived from data point identities and visual properties
|
||||||
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity}${d.dot}`).join("\0")
|
||||||
|
|
||||||
|
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
|
||||||
|
|
||||||
const Lines = useMemo(() => {
|
const Lines = useMemo(() => {
|
||||||
return dataPoints?.map((dataPoint, i) => {
|
return dataPoints?.map((dataPoint, i) => {
|
||||||
@@ -99,7 +102,7 @@ export default function LineChartDefault({
|
|||||||
dataKey={dataPoint.dataKey}
|
dataKey={dataPoint.dataKey}
|
||||||
name={dataPoint.label}
|
name={dataPoint.label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
dot={false}
|
dot={dataPoint.dot || false}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeOpacity={dataPoint.strokeOpacity}
|
strokeOpacity={dataPoint.strokeOpacity}
|
||||||
@@ -148,7 +151,7 @@ export default function LineChartDefault({
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{xAxis(chartData.chartTime, displayData.at(-1)?.created as number)}
|
{XAxis}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
@@ -169,5 +172,5 @@ export default function LineChartDefault({
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, filter, Lines])
|
}, [displayData, yAxisWidth, filter, Lines, XAxis])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
|
import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { $allSystemsById, $longestSystemNameLen } from "@/lib/stores"
|
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
||||||
|
|
||||||
@@ -63,10 +63,13 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
|
|||||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const allSystems = useStore($allSystemsById)
|
const allSystems = useStore($allSystemsById)
|
||||||
const longestName = useStore($longestSystemNameLen)
|
const longestName = useStore($longestSystemName)
|
||||||
return (
|
return (
|
||||||
<div className="ms-1 max-w-40 truncate" style={{ width: `${longestName / 1.05}ch` }}>
|
<div className="ms-1 relative w-fit max-w-40">
|
||||||
{allSystems[getValue() as string]?.name ?? ""}
|
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||||
|
{longestName}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-0 truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn, decimalString, hourWithSeconds } from "@/lib/utils"
|
import { cn, copyToClipboard, decimalString, formatMicroseconds, hourWithSeconds } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
ActivityIcon,
|
|
||||||
WifiOffIcon,
|
WifiOffIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
ArrowLeftRightIcon,
|
ArrowLeftRightIcon,
|
||||||
@@ -12,42 +11,113 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
PenBoxIcon,
|
||||||
|
PauseCircleIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
CopyIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import type { NetworkProbeRecord } from "@/types"
|
import type { NetworkProbeRecord, SystemRecord } from "@/types"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { pb } from "@/lib/api"
|
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
|
||||||
import { toast } from "../ui/use-toast"
|
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { SystemStatus } from "@/lib/enums"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { formatBulkProbeLine } from "@/components/network-probes-table/probe-dialog"
|
||||||
|
import { Badge } from "../ui/badge"
|
||||||
|
|
||||||
const protocolColors: Record<string, string> = {
|
const protocolColors: Record<string, string> = {
|
||||||
icmp: "bg-blue-500/15 text-blue-400",
|
icmp: "bg-blue-500/15! text-blue-600 dark:text-blue-400",
|
||||||
tcp: "bg-purple-500/15 text-purple-400",
|
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
|
||||||
http: "bg-green-500/15 text-green-400",
|
http: "bg-green-500/15! text-green-700 dark:text-green-400",
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteProbe(id: string) {
|
const SYSTEM_STATUS_COLORS = {
|
||||||
try {
|
[SystemStatus.Up]: "bg-green-500",
|
||||||
await pb.collection("network_probes").delete(id)
|
[SystemStatus.Down]: "bg-red-500",
|
||||||
} catch (err: unknown) {
|
[SystemStatus.Paused]: "bg-primary/40",
|
||||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
[SystemStatus.Pending]: "bg-yellow-500",
|
||||||
}
|
} as const
|
||||||
}
|
|
||||||
|
|
||||||
export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<NetworkProbeRecord>[] {
|
/**
|
||||||
|
* A probe is considered muted if it's disabled or if its associated system is not up.
|
||||||
|
*/
|
||||||
|
const isMuted = (record: NetworkProbeRecord, systemRecord: SystemRecord | undefined) =>
|
||||||
|
!record.enabled || systemRecord?.status !== SystemStatus.Up
|
||||||
|
|
||||||
|
export function getProbeColumns(
|
||||||
|
longestName = "",
|
||||||
|
longestTarget = "",
|
||||||
|
{
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSetEnabled,
|
||||||
|
}: {
|
||||||
|
onEdit?: (probe: NetworkProbeRecord) => void
|
||||||
|
onDelete?: (probes: NetworkProbeRecord[]) => void | Promise<void>
|
||||||
|
onSetEnabled?: (probes: NetworkProbeRecord[], enabled: boolean) => void | Promise<void>
|
||||||
|
} = {}
|
||||||
|
): ColumnDef<NetworkProbeRecord>[] {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
className="ms-2"
|
||||||
|
checked={table.getIsAllRowsSelected() || (table.getIsSomeRowsSelected() && "indeterminate")}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||||
|
aria-label={t`Select all`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label={t`Select row`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
size: 44,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "name",
|
id: "name",
|
||||||
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
||||||
accessorFn: (record) => record.name || record.target,
|
accessorFn: (record) => record.name || record.target,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ row, getValue }) => {
|
||||||
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
const probe = row.original
|
||||||
{getValue() as string}
|
const { status } = useStore($allSystemsById)[probe.system] || {}
|
||||||
|
|
||||||
|
let color = "bg-green-500"
|
||||||
|
if (!probe.enabled || status === SystemStatus.Paused) {
|
||||||
|
color = "bg-primary/40"
|
||||||
|
} else if (status === SystemStatus.Down || status === SystemStatus.Pending) {
|
||||||
|
color = "bg-yellow-500"
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="ms-1.5 max-w-40 flex gap-2 items-center tabular-nums">
|
||||||
|
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||||
|
<div className="relative w-fit min-w-0 max-w-full">
|
||||||
|
<span className="invisible block overflow-hidden whitespace-nowrap" aria-hidden="true">
|
||||||
|
{longestName}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-0 truncate">{getValue() as string}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
@@ -56,12 +126,33 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
|||||||
const allSystems = $allSystemsById.get()
|
const allSystems = $allSystemsById.get()
|
||||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||||
return systemNameA.localeCompare(systemNameB)
|
const primary = systemNameA.localeCompare(systemNameB)
|
||||||
|
if (primary !== 0) {
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
return (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target)
|
||||||
},
|
},
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const allSystems = useStore($allSystemsById)
|
const system = useStore($allSystemsById)[getValue() as string] as SystemRecord | undefined
|
||||||
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
const longestSystemName = useStore($longestSystemName)
|
||||||
|
const name = system?.name
|
||||||
|
const status = system?.status as SystemStatus // undefined val is fine but makes lsp mad
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => (
|
||||||
|
<div className="ms-1.5 max-w-44 flex gap-2 items-center tabular-nums">
|
||||||
|
<span className={cn("shrink-0 size-2 rounded-full", SYSTEM_STATUS_COLORS[status])} />
|
||||||
|
<div className="relative w-fit min-w-0 max-w-full">
|
||||||
|
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||||
|
{longestSystemName}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-0 truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[status, name]
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,8 +161,11 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
|||||||
accessorFn: (record) => record.target,
|
accessorFn: (record) => record.target,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="ms-1.5 tabular-nums block truncate max-w-44" style={{ width: `${longestTarget / 1.05}ch` }}>
|
<div className="ms-1.5 relative w-fit max-w-44 tabular-nums">
|
||||||
{getValue() as string}
|
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||||
|
{longestTarget}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-0 truncate">{getValue() as string}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -81,62 +175,68 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
|||||||
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const protocol = getValue() as string
|
const protocol = getValue() as string
|
||||||
return (
|
return <Badge className={cn("uppercase", protocolColors[protocol])}>{protocol}</Badge>
|
||||||
<span className={cn("ms-1.5 px-2 py-0.5 rounded text-xs font-medium uppercase", protocolColors[protocol])}>
|
|
||||||
{protocol}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "interval",
|
id: "interval",
|
||||||
accessorFn: (record) => record.interval,
|
accessorFn: (record) => record.interval,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={TimerIcon} />,
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
|
||||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
|
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "latency",
|
id: "res",
|
||||||
accessorFn: (record) => record.latency,
|
accessorFn: (record) => record.res,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
|
||||||
cell: ({ row }) => {
|
cell: responseTimeCell,
|
||||||
const val = row.original.latency
|
|
||||||
if (!val) {
|
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
|
||||||
}
|
|
||||||
let color = "bg-green-500"
|
|
||||||
if (val > 200) {
|
|
||||||
color = "bg-yellow-500"
|
|
||||||
}
|
|
||||||
if (val > 2000) {
|
|
||||||
color = "bg-red-500"
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
|
||||||
{decimalString(val, val < 100 ? 2 : 1).toLocaleString()} ms
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "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",
|
id: "loss",
|
||||||
accessorFn: (record) => record.loss,
|
accessorFn: (record) => record.loss1h,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Loss`} Icon={WifiOffIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { loss, latency } = row.original
|
const { loss1h, res, system } = row.original
|
||||||
if (loss === undefined || (!latency && !loss)) {
|
const systemRecord = useStore($allSystemsById)[system]
|
||||||
|
|
||||||
|
if (loss1h === undefined || (!res && !loss1h)) {
|
||||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const muted = isMuted(row.original, systemRecord)
|
||||||
let color = "bg-green-500"
|
let color = "bg-green-500"
|
||||||
if (loss) {
|
if (muted) {
|
||||||
color = loss > 20 ? "bg-red-500" : "bg-yellow-500"
|
color = "bg-muted-foreground/50"
|
||||||
|
} else if (loss1h) {
|
||||||
|
color = loss1h > 20 ? "bg-red-500" : "bg-yellow-500"
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||||
{loss}%
|
{loss1h === 100 ? loss1h : decimalString(loss1h, loss1h >= 10 ? 1 : 2)}%
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -148,24 +248,31 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
|||||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const timestamp = getValue() as number
|
const timestamp = getValue() as number
|
||||||
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
if (!timestamp) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(timestamp)}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
header: () => null,
|
header: () => null,
|
||||||
size: 40,
|
size: 40,
|
||||||
cell: ({ row }) => (
|
cell: ({ row, table }) => {
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows
|
||||||
|
const actionRows =
|
||||||
|
row.getIsSelected() && selectedRows.length > 1
|
||||||
|
? selectedRows.map((selectedRow) => selectedRow.original)
|
||||||
|
: [row.original]
|
||||||
|
const isBulkAction = actionRows.length > 1
|
||||||
|
const shouldPause = actionRows.some((probe) => probe.enabled)
|
||||||
|
const bulkCopyContent = actionRows.map((probe) => formatBulkProbeLine(probe)).join("\n")
|
||||||
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="size-10">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-10"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
<Trans>Open menu</Trans>
|
<Trans>Open menu</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -173,10 +280,45 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||||
|
{!isBulkAction && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(event) => {
|
onClick={() => {
|
||||||
event.stopPropagation()
|
onEdit?.(row.original)
|
||||||
deleteProbe(row.original.id)
|
}}
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onSetEnabled?.(actionRows, !shouldPause)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shouldPause ? (
|
||||||
|
<>
|
||||||
|
<PauseCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Pause</Trans>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlayCircleIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Resume</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(bulkCopyContent)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Bulk copy</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onDelete?.(actionRows)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2Icon className="me-2.5 size-4" />
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
@@ -184,11 +326,45 @@ export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<N
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseTimeThresholds = {
|
||||||
|
http: { warning: 800_000, critical: 3_000_000 },
|
||||||
|
tcp: { warning: 500_000, critical: 2_000_000 },
|
||||||
|
icmp: { warning: 100_000, critical: 500_000 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
|
||||||
|
const probe = cell.row.original
|
||||||
|
const systemRecord = useStore($allSystemsById)[probe.system]
|
||||||
|
const responseTime = cell.getValue() as number | undefined
|
||||||
|
|
||||||
|
if (!responseTime) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const muted = isMuted(probe, systemRecord)
|
||||||
|
let color = "bg-green-500"
|
||||||
|
if (muted) {
|
||||||
|
color = "bg-muted-foreground/50"
|
||||||
|
} else if (responseTime > responseTimeThresholds[probe.protocol].warning) {
|
||||||
|
color = "bg-yellow-500"
|
||||||
|
}
|
||||||
|
if (!muted && responseTime > responseTimeThresholds[probe.protocol].critical) {
|
||||||
|
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)} />
|
||||||
|
{formatMicroseconds(responseTime)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function HeaderButton({
|
function HeaderButton({
|
||||||
column,
|
column,
|
||||||
name,
|
name,
|
||||||
@@ -210,7 +386,6 @@ function HeaderButton({
|
|||||||
>
|
>
|
||||||
{Icon && <Icon className="size-4" />}
|
{Icon && <Icon className="size-4" />}
|
||||||
{name}
|
{name}
|
||||||
{/* <ArrowUpDownIcon className="size-4" /> */}
|
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,47 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type Row,
|
type Row,
|
||||||
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
type Table as TableType,
|
type Table as TableType,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { memo, useCallback, useMemo, useRef, useState } from "react"
|
||||||
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
||||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { isReadOnlyUser } from "@/lib/api"
|
import { isReadOnlyUser } from "@/lib/api"
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
import { pb } from "@/lib/api"
|
||||||
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
|
import { $allSystemsById, $chartTime, $direction } from "@/lib/stores"
|
||||||
|
import { cn, isVisuallyLonger, useBrowserStorage } from "@/lib/utils"
|
||||||
import type { NetworkProbeRecord } from "@/types"
|
import type { NetworkProbeRecord } from "@/types"
|
||||||
import { AddProbeDialog } from "./probe-dialog"
|
import { AddProbeDialog, EditProbeDialog } from "./probe-dialog"
|
||||||
|
import { ArrowLeftRightIcon, EthernetPortIcon, GlobeIcon, ServerIcon, XIcon } from "lucide-react"
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||||
|
import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/probes-charts"
|
||||||
|
import { useNetworkProbeStats } from "@/lib/use-network-probes"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import type { ChartData } from "@/types"
|
||||||
|
import { parseSemVer } from "@/lib/utils"
|
||||||
|
import { Separator } from "../ui/separator"
|
||||||
|
import { $router, Link } from "../router"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
export default function NetworkProbesTableNew({
|
export default function NetworkProbesTableNew({
|
||||||
systemId,
|
systemId,
|
||||||
@@ -38,35 +63,149 @@ export default function NetworkProbesTableNew({
|
|||||||
)
|
)
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([])
|
||||||
|
const [editingProbe, setEditingProbe] = useState<NetworkProbeRecord>()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const canManageProbes = !isReadOnlyUser()
|
||||||
|
|
||||||
const { longestName, longestTarget } = useMemo(() => {
|
const [longestName, longestTarget] = useMemo(() => {
|
||||||
let longestName = 0
|
let longestName = ""
|
||||||
let longestTarget = 0
|
let longestTarget = ""
|
||||||
for (const p of probes) {
|
for (const p of probes) {
|
||||||
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
|
const name = p.name || p.target
|
||||||
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
|
if (isVisuallyLonger(name, longestName)) {
|
||||||
|
longestName = name
|
||||||
}
|
}
|
||||||
return { longestName, longestTarget }
|
if (isVisuallyLonger(p.target, longestTarget)) {
|
||||||
|
longestTarget = p.target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [longestName, longestTarget]
|
||||||
}, [probes])
|
}, [probes])
|
||||||
|
|
||||||
// Filter columns based on whether systemId is provided
|
const runProbeBatch = useCallback(
|
||||||
|
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
|
||||||
|
let batch = pb.createBatch()
|
||||||
|
let inBatch = 0
|
||||||
|
for (const id of ids) {
|
||||||
|
enqueue(batch, id)
|
||||||
|
if (++inBatch >= 20) {
|
||||||
|
await batch.send()
|
||||||
|
batch = pb.createBatch()
|
||||||
|
inBatch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inBatch) {
|
||||||
|
await batch.send()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteRequest = useCallback(
|
||||||
|
async (probesToDelete: NetworkProbeRecord[]) => {
|
||||||
|
if (!probesToDelete.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = probesToDelete.map((probe) => probe.id)
|
||||||
|
if (ids.length === 1) {
|
||||||
|
try {
|
||||||
|
await pb.collection("network_probes").delete(ids[0])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: (err as Error)?.message || t`Failed to delete probes.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingDeleteIds(ids)
|
||||||
|
setDeleteOpen(true)
|
||||||
|
},
|
||||||
|
[toast]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
if (!pendingDeleteIds.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runProbeBatch(pendingDeleteIds, (batch, id) => batch.collection("network_probes").delete(id))
|
||||||
|
setPendingDeleteIds([])
|
||||||
|
setRowSelection({})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: (err as Error)?.message || t`Failed to delete probes.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetEnabled = useCallback(
|
||||||
|
async (probesToUpdate: NetworkProbeRecord[], enabled: boolean) => {
|
||||||
|
if (!probesToUpdate.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingUpdates = probesToUpdate.filter((probe) => probe.enabled !== enabled)
|
||||||
|
if (!pendingUpdates.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pendingUpdates.length === 1) {
|
||||||
|
await pb.collection("network_probes").update(pendingUpdates[0].id, { enabled })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await runProbeBatch(
|
||||||
|
pendingUpdates.map((probe) => probe.id),
|
||||||
|
(batch, id) => batch.collection("network_probes").update(id, { enabled })
|
||||||
|
)
|
||||||
|
if (probesToUpdate.length > 1) {
|
||||||
|
setRowSelection({})
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: (err as Error)?.message || t`Failed to update probes.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[runProbeBatch, toast]
|
||||||
|
)
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
let columns = getProbeColumns(longestName, longestTarget)
|
let columns = getProbeColumns(longestName, longestTarget, {
|
||||||
|
onEdit: setEditingProbe,
|
||||||
|
onDelete: handleDeleteRequest,
|
||||||
|
onSetEnabled: handleSetEnabled,
|
||||||
|
})
|
||||||
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
columns = isReadOnlyUser() ? columns.filter((col) => col.id !== "actions") : columns
|
columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
|
||||||
return columns
|
return columns
|
||||||
}, [systemId, longestName, longestTarget])
|
}, [canManageProbes, handleDeleteRequest, handleSetEnabled, longestName, systemId, longestTarget])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: probes,
|
data: probes,
|
||||||
columns,
|
columns,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
size: 900,
|
size: 900,
|
||||||
@@ -76,6 +215,7 @@ export default function NetworkProbesTableNew({
|
|||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
globalFilter,
|
globalFilter,
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
@@ -102,24 +242,81 @@ export default function NetworkProbesTableNew({
|
|||||||
<Trans>Network Probes</Trans>
|
<Trans>Network Probes</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||||
<Trans>ICMP/TCP/HTTP latency monitoring from agents</Trans>
|
<Trans>Response time monitoring from agents.</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:ms-auto flex items-center gap-2">
|
<div className="md:ms-auto flex items-center gap-2">
|
||||||
{probes.length > 0 && (
|
{probes.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
className="ms-auto px-4 w-full max-w-full md:w-50"
|
||||||
/>
|
/>
|
||||||
|
{globalFilter && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t`Clear`}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => setGlobalFilter("")}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isReadOnlyUser() ? <AddProbeDialog systemId={systemId} /> : null}
|
</div>
|
||||||
|
)}
|
||||||
|
{canManageProbes ? <AddProbeDialog systemId={systemId} probes={probes} /> : null}
|
||||||
|
{canManageProbes ? (
|
||||||
|
<EditProbeDialog
|
||||||
|
systemId={systemId}
|
||||||
|
probe={editingProbe}
|
||||||
|
open={!!editingProbe}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingProbe(undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDeleteOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setPendingDeleteIds([])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md">
|
<div className="rounded-md">
|
||||||
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
|
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} rowSelection={rowSelection} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -129,13 +326,22 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
|
|||||||
table,
|
table,
|
||||||
rows,
|
rows,
|
||||||
colLength,
|
colLength,
|
||||||
|
rowSelection: _rowSelection,
|
||||||
}: {
|
}: {
|
||||||
table: TableType<NetworkProbeRecord>
|
table: TableType<NetworkProbeRecord>
|
||||||
rows: Row<NetworkProbeRecord>[]
|
rows: Row<NetworkProbeRecord>[]
|
||||||
colLength: number
|
colLength: number
|
||||||
|
rowSelection: RowSelectionState
|
||||||
}) {
|
}) {
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [activeProbeId, setActiveProbeId] = useState<string | null>(null)
|
||||||
|
const activeProbe = activeProbeId ? table.options.data.find((probe) => probe.id === activeProbeId) : undefined
|
||||||
|
const openSheet = useCallback((probe: NetworkProbeRecord) => {
|
||||||
|
setActiveProbeId(probe.id)
|
||||||
|
setSheetOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
@@ -165,7 +371,15 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
|
|||||||
{rows.length ? (
|
{rows.length ? (
|
||||||
virtualRows.map((virtualRow) => {
|
virtualRows.map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index]
|
const row = rows[virtualRow.index]
|
||||||
return <NetworkProbeTableRow key={row.id} row={row} virtualRow={virtualRow} />
|
return (
|
||||||
|
<NetworkProbeTableRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
virtualRow={virtualRow}
|
||||||
|
isSelected={row.getIsSelected()}
|
||||||
|
openSheet={openSheet}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -177,6 +391,13 @@ const NetworkProbesTable = memo(function NetworkProbeTable({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<NetworkProbeSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setSheetOpen(nextOpen)
|
||||||
|
}}
|
||||||
|
probe={activeProbe}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -202,12 +423,20 @@ function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord>
|
|||||||
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
||||||
row,
|
row,
|
||||||
virtualRow,
|
virtualRow,
|
||||||
|
isSelected,
|
||||||
|
openSheet,
|
||||||
}: {
|
}: {
|
||||||
row: Row<NetworkProbeRecord>
|
row: Row<NetworkProbeRecord>
|
||||||
virtualRow: VirtualItem
|
virtualRow: VirtualItem
|
||||||
|
isSelected: boolean
|
||||||
|
openSheet: (probe: NetworkProbeRecord) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TableRow data-state={row.getIsSelected() && "selected"} className="transition-opacity">
|
<TableRow
|
||||||
|
data-state={isSelected && "selected"}
|
||||||
|
className="cursor-pointer transition-opacity"
|
||||||
|
onClick={() => openSheet(row.original)}
|
||||||
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
@@ -223,3 +452,87 @@ const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function NetworkProbeSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
probe,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
probe?: NetworkProbeRecord
|
||||||
|
}) {
|
||||||
|
if (!probe) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <NetworkProbeSheetContent key={probe.system} open={open} onOpenChange={onOpenChange} probe={probe} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetworkProbeSheetContent({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
probe,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
probe: NetworkProbeRecord
|
||||||
|
}) {
|
||||||
|
const chartTime = useStore($chartTime)
|
||||||
|
const direction = useStore($direction)
|
||||||
|
const system = useStore($allSystemsById)[probe.system]
|
||||||
|
|
||||||
|
const probeStats = useNetworkProbeStats({ systemId: probe.system, chartTime })
|
||||||
|
|
||||||
|
const chartData = useMemo<ChartData>(
|
||||||
|
() => ({
|
||||||
|
agentVersion: parseSemVer(system?.info?.v),
|
||||||
|
orientation: direction === "rtl" ? "right" : "left",
|
||||||
|
chartTime,
|
||||||
|
}),
|
||||||
|
[probeStats]
|
||||||
|
)
|
||||||
|
const hasProbeStats = probeStats.some((record) => record.stats?.[probe.id] != null)
|
||||||
|
const probeLabel = probe.name || probe.target
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="w-full sm:max-w-220 overflow-auto p-4 sm:p-6">
|
||||||
|
<SheetHeader className="mb-0 border-b p-0 pb-4">
|
||||||
|
<SheetTitle>{probeLabel}</SheetTitle>
|
||||||
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<ServerIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
<Link className="hover:underline" href={getPagePath($router, "system", { id: system?.id ?? "" })}>
|
||||||
|
{system?.name ?? ""}
|
||||||
|
</Link>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
{probe.protocol.toUpperCase()}
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<GlobeIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
{probe.target}
|
||||||
|
{probe.protocol === "tcp" && probe.port > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<EthernetPortIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
<span>{probe.port}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ChartTimeSelect className="bg-card" agentVersion={chartData.agentVersion} />
|
||||||
|
<AvgMinMaxResponseChart probeStats={probeStats} probe={probe} chartData={chartData} empty={!hasProbeStats} />
|
||||||
|
<LossChart
|
||||||
|
probeStats={probeStats}
|
||||||
|
grid={false}
|
||||||
|
probes={[probe]}
|
||||||
|
chartData={chartData}
|
||||||
|
empty={!hasProbeStats}
|
||||||
|
showFilter={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
@@ -9,53 +9,474 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
} 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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { PlusIcon } from "lucide-react"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { ChevronDownIcon, ListIcon, ServerIcon } from "lucide-react"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
|
import type { NetworkProbeRecord } from "@/types"
|
||||||
|
import * as v from "valibot"
|
||||||
|
|
||||||
export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
type ProbeProtocol = "icmp" | "tcp" | "http"
|
||||||
|
|
||||||
|
type ProbeValues = {
|
||||||
|
system: string
|
||||||
|
target: string
|
||||||
|
protocol: ProbeProtocol
|
||||||
|
port: number
|
||||||
|
interval: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NormalizedProbeValues = Omit<ProbeValues, "system" | "interval"> & {
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkProbeLineSource = Pick<NetworkProbeRecord, "target" | "protocol" | "port" | "interval" | "name">
|
||||||
|
|
||||||
|
const defaultInterval = 30
|
||||||
|
|
||||||
|
const ProbeProtocolSchema = v.picklist(["icmp", "tcp", "http"])
|
||||||
|
|
||||||
|
const ProbeIntervalSchema = v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600))
|
||||||
|
|
||||||
|
// Both the single-probe form and the bulk importer flow through this schema so
|
||||||
|
// defaults and HTTP target normalization stay in one place.
|
||||||
|
const NormalizedProbeValuesSchema = v.pipe(
|
||||||
|
v.object({
|
||||||
|
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
|
||||||
|
protocol: ProbeProtocolSchema,
|
||||||
|
port: v.number(),
|
||||||
|
interval: ProbeIntervalSchema,
|
||||||
|
name: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
|
}),
|
||||||
|
v.transform((input): NormalizedProbeValues => {
|
||||||
|
let { protocol, port } = input
|
||||||
|
let httpTarget = input.target
|
||||||
|
if (protocol === "icmp" || protocol === "http") {
|
||||||
|
if (protocol === "http") {
|
||||||
|
httpTarget = normalizeHttpTarget(input.target, port)
|
||||||
|
}
|
||||||
|
port = 0
|
||||||
|
} else if (protocol === "tcp" && !port) {
|
||||||
|
port = 443
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// HTTP probes may be entered as bare hostnames, so normalize them to a
|
||||||
|
// scheme-bearing URL before the payload is sent to PocketBase.
|
||||||
|
target: protocol === "http" ? httpTarget : input.target,
|
||||||
|
protocol,
|
||||||
|
port,
|
||||||
|
interval: input.interval,
|
||||||
|
name: input.name || undefined,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
v.forward(
|
||||||
|
v.check((input) => {
|
||||||
|
if (input.protocol === "icmp" || input.protocol === "http") {
|
||||||
|
return input.port === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.isInteger(input.port) && input.port >= 1 && input.port <= 65535
|
||||||
|
}, "Port must be between 1 and 65535"),
|
||||||
|
["port"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bulk parsing only trims raw CSV fields. Inference, defaults, and protocol-
|
||||||
|
// specific validation still go through the shared normalization schema above.
|
||||||
|
const BulkProbeSchema = v.object({
|
||||||
|
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
|
||||||
|
protocol: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
|
port: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
|
interval: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
|
name: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeHttpTarget(target: string, port = 0) {
|
||||||
|
const useExplicitPort = port > 0 && port !== 80 && port !== 443
|
||||||
|
const hasOriginOnlyTarget = /^https?:\/\/[^/?#]+$/i.test(target)
|
||||||
|
if (!/^https?:\/\//i.test(target)) {
|
||||||
|
const scheme = port === 80 ? "http" : "https"
|
||||||
|
return `${scheme}://${target}${useExplicitPort ? `:${port}` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(target)
|
||||||
|
} catch {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedUrl.port && useExplicitPort) {
|
||||||
|
parsedUrl.port = `${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// avoid converting "http://localhost:8090" to "http://localhost:8090/" - keep the original formatting if the URL is just an origin
|
||||||
|
if (hasOriginOnlyTarget && parsedUrl.pathname === "/" && !parsedUrl.search && !parsedUrl.hash) {
|
||||||
|
return parsedUrl.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedUrl.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingEmptyFields(fields: string[]) {
|
||||||
|
let lastValueIndex = fields.length - 1
|
||||||
|
while (lastValueIndex > 0 && !fields[lastValueIndex]) {
|
||||||
|
lastValueIndex--
|
||||||
|
}
|
||||||
|
return fields.slice(0, lastValueIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProbePayload(values: ProbeValues, enabled = true) {
|
||||||
|
const normalizedValues = v.safeParse(NormalizedProbeValuesSchema, values)
|
||||||
|
if (!normalizedValues.success) {
|
||||||
|
throw new Error(normalizedValues.issues[0]?.message || "Invalid probe")
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
system: values.system,
|
||||||
|
enabled,
|
||||||
|
...normalizedValues.output,
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = normalizedValues.output.name?.trim()
|
||||||
|
const targetName = normalizedValues.output.target.replace(/^https?:\/\//i, "")
|
||||||
|
if (trimmedName) {
|
||||||
|
payload.name = trimmedName
|
||||||
|
} else if (targetName !== normalizedValues.output.target) {
|
||||||
|
payload.name = targetName
|
||||||
|
} else {
|
||||||
|
payload.name = ""
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProbeIdentity = Pick<ProbeValues, "system" | "target" | "protocol" | "port">
|
||||||
|
function getProbeIdentityKey({ system, target, protocol, port }: ProbeIdentity) {
|
||||||
|
return `${system}${target}${protocol}${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBulkProbeLine(line: string, lineNumber: number, system: string) {
|
||||||
|
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", ...rawName] = line.split(",")
|
||||||
|
const parsed = v.safeParse(BulkProbeSchema, {
|
||||||
|
target: rawTarget,
|
||||||
|
protocol: rawProtocol,
|
||||||
|
port: rawPort,
|
||||||
|
interval: rawInterval,
|
||||||
|
name: rawName.join(","),
|
||||||
|
})
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid probe entry"}`)
|
||||||
|
}
|
||||||
|
const protocol = (parsed.output.protocol?.toLowerCase() ||
|
||||||
|
(/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as ProbeProtocol
|
||||||
|
|
||||||
|
return buildProbePayload({
|
||||||
|
system,
|
||||||
|
target: parsed.output.target,
|
||||||
|
protocol,
|
||||||
|
port: parsed.output.port ? Number(parsed.output.port) : 0,
|
||||||
|
interval: parsed.output.interval || `${defaultInterval}`,
|
||||||
|
name: parsed.output.name || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBulkProbeLine(probe: BulkProbeLineSource) {
|
||||||
|
const port = probe.protocol !== "tcp" || probe.port === 443 ? "" : `${probe.port}`
|
||||||
|
const interval = probe.interval === defaultInterval ? "" : `${probe.interval}`
|
||||||
|
return trimTrailingEmptyFields([probe.target, probe.protocol, port, interval, probe.name?.trim() || ""]).join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddProbeDialog({ systemId, probes }: { systemId?: string; probes: NetworkProbeRecord[] }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [protocol, setProtocol] = useState<string>("icmp")
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
const [target, setTarget] = useState("")
|
const [bulkInput, setBulkInput] = useState("")
|
||||||
const [port, setPort] = useState("")
|
const [bulkLoading, setBulkLoading] = useState(false)
|
||||||
const [probeInterval, setProbeInterval] = useState("30")
|
const [bulkSelectedSystemId, setBulkSelectedSystemId] = useState("")
|
||||||
const [name, setName] = useState("")
|
const bulkFormRef = useRef<HTMLFormElement>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { t } = useLingui()
|
||||||
|
const systems = useStore($systems)
|
||||||
|
|
||||||
|
const resetBulkForm = () => {
|
||||||
|
setBulkInput("")
|
||||||
|
// setBulkSelectedSystemId("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBulkAdd = (selectedSystemId?: string) => {
|
||||||
|
if (!systemId && selectedSystemId) {
|
||||||
|
setBulkSelectedSystemId(selectedSystemId)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
setBulkOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setBulkOpen(false)
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBulkSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBulkLoading(true)
|
||||||
|
let closedForSubmit = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const system = systemId ?? bulkSelectedSystemId
|
||||||
|
if (!system) {
|
||||||
|
throw new Error("Select a system.")
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
const existingProbeKeys = new Set(
|
||||||
|
probes.filter((probe) => probe.system === system).map((probe) => getProbeIdentityKey(probe))
|
||||||
|
)
|
||||||
|
const newPayloads = [] as typeof payloads
|
||||||
|
|
||||||
|
for (const payload of payloads) {
|
||||||
|
const probeKey = getProbeIdentityKey(payload)
|
||||||
|
if (existingProbeKeys.has(probeKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existingProbeKeys.add(probeKey)
|
||||||
|
newPayloads.push(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPayloads.length) {
|
||||||
|
throw new Error("No new probes. All entries exist.")
|
||||||
|
}
|
||||||
|
|
||||||
|
closedForSubmit = true
|
||||||
|
let batch = pb.createBatch()
|
||||||
|
let inBatch = 0
|
||||||
|
for (const payload of newPayloads) {
|
||||||
|
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: `${newPayloads.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(systemId)}>
|
||||||
|
<ListIcon className="size-4 me-2" />
|
||||||
|
<Trans>Bulk Add</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setOpen(nextOpen)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProbeDialogContent open={open} setOpen={setOpen} systemId={systemId} onOpenBulkAdd={openBulkAdd} />
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Sheet
|
||||||
|
open={bulkOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setBulkOpen(nextOpen)
|
||||||
|
if (!nextOpen) {
|
||||||
|
resetBulkForm()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>target[,protocol[,port[,interval[,name]]]]</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<form ref={bulkFormRef} onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
|
||||||
|
<div className="flex-1 flex flex-col space-y-4 overflow-auto p-4">
|
||||||
|
{!systemId && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="sr-only">
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select value={bulkSelectedSystemId} onValueChange={setBulkSelectedSystemId} required>
|
||||||
|
<SelectTrigger className="relative ps-10 pe-5 bg-card">
|
||||||
|
<ServerIcon className="size-3.5 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
|
||||||
|
<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="grow flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bulk-probes" className="sr-only">
|
||||||
|
Entries
|
||||||
|
</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()
|
||||||
|
bulkFormRef.current?.requestSubmit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="font-mono grow text-sm bg-card"
|
||||||
|
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60,Example"].join("\n")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">target[,protocol[,port[,interval[,name]]]]</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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditProbeDialog({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
systemId,
|
||||||
|
probe,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
systemId?: string
|
||||||
|
probe?: NetworkProbeRecord
|
||||||
|
}) {
|
||||||
|
const hasOpened = useRef(false)
|
||||||
|
if (!probe && !hasOpened.current) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
hasOpened.current = true
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<ProbeDialogContent open={open} setOpen={setOpen} systemId={systemId} probe={probe} />
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProbeDialogContent({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
systemId,
|
||||||
|
probe,
|
||||||
|
onOpenBulkAdd,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
systemId?: string
|
||||||
|
probe?: NetworkProbeRecord
|
||||||
|
onOpenBulkAdd?: (selectedSystemId?: string) => void
|
||||||
|
}) {
|
||||||
|
const [protocol, setProtocol] = useState<ProbeProtocol>(probe?.protocol ?? "icmp")
|
||||||
|
const [target, setTarget] = useState(probe?.target ?? "")
|
||||||
|
const [port, setPort] = useState(probe?.protocol === "tcp" && probe.port ? String(probe.port) : "")
|
||||||
|
const [probeInterval, setProbeInterval] = useState(String(probe?.interval ?? defaultInterval))
|
||||||
|
const [name, setName] = useState(probe?.name ?? "")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selectedSystemId, setSelectedSystemId] = useState("")
|
const [selectedSystemId, setSelectedSystemId] = useState(probe?.system ?? "")
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
const isEditing = !!probe
|
||||||
const targetName = target.replace(/^https?:\/\//, "")
|
const targetName = target.replace(/^https?:\/\//, "")
|
||||||
|
|
||||||
const resetForm = () => {
|
// When the dialog is opened, initialize form fields with probe values (if editing) or defaults (if adding).
|
||||||
setProtocol("icmp")
|
useEffect(() => {
|
||||||
setTarget("")
|
if (!open) {
|
||||||
setPort("")
|
return
|
||||||
setProbeInterval("30")
|
|
||||||
setName("")
|
|
||||||
setSelectedSystemId("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
setProtocol(probe?.protocol ?? "icmp")
|
||||||
|
setTarget(probe?.target ?? "")
|
||||||
|
setPort(probe?.protocol === "tcp" && probe.port ? String(probe.port) : "")
|
||||||
|
setProbeInterval(String(probe?.interval ?? defaultInterval))
|
||||||
|
setName(probe?.name ?? "")
|
||||||
|
setSelectedSystemId(probe?.system ?? "")
|
||||||
|
setLoading(false)
|
||||||
|
}, [open, probe])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pb.collection("network_probes").create({
|
const selectedSystem = systemId ?? selectedSystemId
|
||||||
system: systemId ?? selectedSystemId,
|
if (!selectedSystem) {
|
||||||
name: name || targetName,
|
throw new Error("Select a system.")
|
||||||
|
}
|
||||||
|
const payload = buildProbePayload(
|
||||||
|
{
|
||||||
|
system: selectedSystem,
|
||||||
target,
|
target,
|
||||||
protocol,
|
protocol,
|
||||||
port: protocol === "tcp" ? Number(port) : 0,
|
port: protocol === "tcp" ? Number(port) : 0,
|
||||||
interval: Number(probeInterval),
|
interval: probeInterval,
|
||||||
enabled: true,
|
name,
|
||||||
})
|
},
|
||||||
resetForm()
|
probe ? probe.enabled : true
|
||||||
|
)
|
||||||
|
if (probe) {
|
||||||
|
await pb.collection("network_probes").update(probe.id, payload)
|
||||||
|
} else {
|
||||||
|
await pb.collection("network_probes").create(payload)
|
||||||
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
||||||
@@ -65,20 +486,13 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
<PlusIcon className="size-4 me-1" />
|
|
||||||
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
|
{isEditing ? <Trans>Edit {{ foo: t`Network Probe` }}</Trans> : <Trans>Add {{ foo: t`Network Probe` }}</Trans>}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>Configure latency monitoring from this agent.</Trans>
|
<Trans>Configure response monitoring from this agent.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
|
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
|
||||||
@@ -108,7 +522,7 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
|||||||
<Input
|
<Input
|
||||||
value={target}
|
value={target}
|
||||||
onChange={(e) => setTarget(e.target.value)}
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
|
placeholder={protocol === "http" ? "http://localhost:8090" : "1.1.1.1"}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +530,8 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
|||||||
<Label>
|
<Label>
|
||||||
<Trans>Protocol</Trans>
|
<Trans>Protocol</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={protocol} onValueChange={setProtocol}>
|
|
||||||
|
<Select value={protocol} onValueChange={(value) => setProtocol(value as ProbeProtocol)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -139,7 +554,6 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
|||||||
placeholder="443"
|
placeholder="443"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -167,12 +581,33 @@ export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
{!isEditing && onOpenBulkAdd && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenBulkAdd(selectedSystemId)}
|
||||||
|
disabled={loading}
|
||||||
|
className="me-auto"
|
||||||
|
>
|
||||||
|
<ListIcon className="size-4 me-2" />
|
||||||
|
<Trans>Bulk Add</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
|
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
|
||||||
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
|
{loading ? (
|
||||||
|
isEditing ? (
|
||||||
|
<Trans>Saving...</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Creating...</Trans>
|
||||||
|
)
|
||||||
|
) : isEditing ? (
|
||||||
|
<Trans>Save {{ foo: t`Probe` }}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { memo, useEffect } from "react"
|
|||||||
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
|
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
|
||||||
import { ActiveAlerts } from "@/components/active-alerts"
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
import { useNetworkProbesData } from "@/lib/use-network-probes"
|
import { useNetworkProbes } from "@/lib/use-network-probes"
|
||||||
|
|
||||||
export default memo(() => {
|
export default memo(() => {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const { probes } = useNetworkProbesData({})
|
const probes = useNetworkProbes({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t`Network Probes`} / Beszel`
|
document.title = `${t`Network Probes`} / Beszel`
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
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,
|
||||||
|
ChartDataContainer,
|
||||||
|
ChartTimes,
|
||||||
|
ContainerStatsRecord,
|
||||||
|
NetworkProbeStatsRecord,
|
||||||
|
SystemStatsRecord,
|
||||||
|
} from "@/types"
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -19,7 +26,7 @@ export const cache = new Map<
|
|||||||
/** 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 }>(
|
||||||
prev: T[],
|
prev: T[] = [],
|
||||||
newRecords: T[],
|
newRecords: T[],
|
||||||
expectedInterval: number,
|
expectedInterval: number,
|
||||||
maxLen?: number
|
maxLen?: number
|
||||||
@@ -48,13 +55,14 @@ export async function getStats<T extends SystemStatsRecord | ContainerStatsRecor
|
|||||||
collection: string,
|
collection: string,
|
||||||
systemId: string,
|
systemId: string,
|
||||||
chartTime: ChartTimes,
|
chartTime: ChartTimes,
|
||||||
cachedStats?: { created: string | number | null }[]
|
cachedStats?: { created: string | number | null }[],
|
||||||
|
createdIsNumber?: boolean
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
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}", {
|
||||||
id: systemId,
|
id: systemId,
|
||||||
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
|
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined, createdIsNumber),
|
||||||
type: chartTimeData[chartTime].type,
|
type: chartTimeData[chartTime].type,
|
||||||
}),
|
}),
|
||||||
fields: "created,stats",
|
fields: "created,stats",
|
||||||
@@ -62,11 +70,11 @@ export async function getStats<T extends SystemStatsRecord | ContainerStatsRecor
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
|
export function makeContainerData(containers: ContainerStatsRecord[]): ChartDataContainer[] {
|
||||||
const result = [] as ChartData["containerData"]
|
const result = [] as ChartDataContainer[]
|
||||||
for (const { created, stats } of containers) {
|
for (const { created, stats } of containers) {
|
||||||
if (!created) {
|
if (!created) {
|
||||||
result.push({ created: null } as ChartData["containerData"][0])
|
result.push({ created: null } as ChartDataContainer)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.push(makeContainerPoint(new Date(created).getTime(), stats))
|
result.push(makeContainerPoint(new Date(created).getTime(), stats))
|
||||||
@@ -75,11 +83,8 @@ export function makeContainerData(containers: ContainerStatsRecord[]): ChartData
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Transform a single realtime container stats message into a ChartDataContainer point. */
|
/** Transform a single realtime container stats message into a ChartDataContainer point. */
|
||||||
export function makeContainerPoint(
|
export function makeContainerPoint(created: number, stats: ContainerStatsRecord["stats"]): ChartDataContainer {
|
||||||
created: number,
|
const point: ChartDataContainer = { created } as ChartDataContainer
|
||||||
stats: ContainerStatsRecord["stats"]
|
|
||||||
): ChartData["containerData"][0] {
|
|
||||||
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
|
|
||||||
for (const container of stats) {
|
for (const container of stats) {
|
||||||
;(point as Record<string, unknown>)[container.n] = container
|
;(point as Record<string, unknown>)[container.n] = container
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import LineChartDefault from "@/components/charts/line-chart"
|
import LineChartDefault from "@/components/charts/line-chart"
|
||||||
import type { DataPoint } from "@/components/charts/line-chart"
|
import type { DataPoint } from "@/components/charts/line-chart"
|
||||||
import { toFixedFloat, decimalString } from "@/lib/utils"
|
import { decimalString, formatMicroseconds, toFixedFloat } from "@/lib/utils"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { ChartCard, FilterBar } from "../chart-card"
|
import { ChartCard, FilterBar } from "../chart-card"
|
||||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { atom } from "nanostores"
|
import { atom } from "nanostores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { probeKey } from "@/lib/use-network-probes"
|
|
||||||
|
|
||||||
const $filter = atom("")
|
const $filter = atom("")
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ type ProbeChartProps = {
|
|||||||
probes: NetworkProbeRecord[]
|
probes: NetworkProbeRecord[]
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
empty: boolean
|
empty: boolean
|
||||||
|
showFilter?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProbeChartBaseProps = ProbeChartProps & {
|
type ProbeChartBaseProps = ProbeChartProps & {
|
||||||
@@ -40,55 +40,60 @@ function ProbeChart({
|
|||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
domain,
|
domain,
|
||||||
|
showFilter = probes.length > 1,
|
||||||
}: ProbeChartBaseProps) {
|
}: ProbeChartBaseProps) {
|
||||||
const filter = useStore($filter)
|
const storedFilter = useStore($filter)
|
||||||
|
const filter = showFilter ? storedFilter : ""
|
||||||
|
|
||||||
const { dataPoints, visibleKeys } = useMemo(() => {
|
const { dataPoints, visibleKeys } = useMemo(() => {
|
||||||
const sortedProbes = [...probes].sort((a, b) => b.latency - a.latency)
|
const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h)
|
||||||
const count = sortedProbes.length
|
const count = sortedProbes.length
|
||||||
const points: DataPoint<NetworkProbeStatsRecord>[] = []
|
const points: DataPoint<NetworkProbeStatsRecord>[] = []
|
||||||
const visibleKeys: string[] = []
|
const visibleIDs: string[] = []
|
||||||
const filterTerms = filter
|
const filterTerms = filter
|
||||||
? filter
|
? filter
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.filter((term) => term.length > 0)
|
.filter((term) => term.length > 0)
|
||||||
: []
|
: []
|
||||||
|
const dot = chartData.chartTime === "1m"
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const p = sortedProbes[i]
|
const p = sortedProbes[i]
|
||||||
const key = probeKey(p)
|
const label = p.name || p.target
|
||||||
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
|
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => label.toLowerCase().includes(term))
|
||||||
if (filtered) {
|
if (filtered) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
visibleKeys.push(key)
|
visibleIDs.push(p.id)
|
||||||
points.push({
|
points.push({
|
||||||
order: i,
|
order: i,
|
||||||
label: p.name || p.target,
|
label,
|
||||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[valueIndex] ?? "-",
|
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-",
|
||||||
|
dot,
|
||||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { dataPoints: points, visibleKeys }
|
return { dataPoints: points, visibleKeys: visibleIDs }
|
||||||
}, [probes, filter, valueIndex])
|
}, [probes, filter, valueIndex, chartData.chartTime])
|
||||||
|
|
||||||
const filteredProbeStats = useMemo(() => {
|
const filteredProbeStats = useMemo(() => {
|
||||||
if (!visibleKeys.length) return probeStats
|
if (!visibleKeys.length) return probeStats
|
||||||
return probeStats.filter((record) => visibleKeys.some((key) => record.stats?.[key] != null))
|
return probeStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
|
||||||
}, [probeStats, visibleKeys])
|
}, [probeStats, visibleKeys])
|
||||||
|
|
||||||
const legend = dataPoints.length < 10
|
const legend = dataPoints.length < 10 && showFilter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
legend={legend}
|
legend={legend || !showFilter}
|
||||||
cornerEl={<FilterBar store={$filter} />}
|
cornerEl={showFilter ? <FilterBar store={$filter} /> : undefined}
|
||||||
empty={empty}
|
empty={empty}
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
>
|
>
|
||||||
<LineChartDefault
|
<LineChartDefault
|
||||||
|
truncate
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
customData={filteredProbeStats}
|
customData={filteredProbeStats}
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
@@ -103,7 +108,7 @@ function ProbeChart({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LatencyChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
|
export function ResponseChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,19 +119,98 @@ export function LatencyChart({ probeStats, grid, probes, chartData, empty }: Pro
|
|||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
empty={empty}
|
empty={empty}
|
||||||
valueIndex={0}
|
valueIndex={0}
|
||||||
title={t`Latency`}
|
title={t`Response`}
|
||||||
description={t`Average round-trip time (ms)`}
|
description={t`Average response time`}
|
||||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
tickFormatter={(value) => formatMicroseconds(value, false)}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
if (typeof value !== "number") {
|
if (typeof value !== "number") {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return `${decimalString(value, 2)} ms`
|
return formatMicroseconds(value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AvgMinMaxResponseChartProps {
|
||||||
|
probeStats: NetworkProbeStatsRecord[]
|
||||||
|
probe: NetworkProbeRecord | null
|
||||||
|
chartData: ChartData
|
||||||
|
empty: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvgMinMaxResponseChart({ probeStats, probe, chartData, empty }: AvgMinMaxResponseChartProps) {
|
||||||
|
const { t } = useLingui()
|
||||||
|
|
||||||
|
const { chartTime } = chartData
|
||||||
|
const hasLongInterval = (probe?.interval ?? 61) > 60
|
||||||
|
|
||||||
|
// only one probe is relevant for this chart
|
||||||
|
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
|
||||||
|
const dataFn = (index: number) => (record: NetworkProbeStatsRecord) =>
|
||||||
|
record.stats?.[probe?.id ?? ""]?.[index] ?? "-"
|
||||||
|
const avgPoint = {
|
||||||
|
label: "Avg",
|
||||||
|
dataKey: dataFn(0),
|
||||||
|
color: 1,
|
||||||
|
order: 0,
|
||||||
|
}
|
||||||
|
if (chartTime === "1m" || (hasLongInterval && chartTime === "1h")) {
|
||||||
|
// avg, min, max are all the same for 1m interval, so just show avg
|
||||||
|
return [avgPoint]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Max",
|
||||||
|
dataKey: dataFn(2),
|
||||||
|
color: 3,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
avgPoint,
|
||||||
|
{
|
||||||
|
label: "Min",
|
||||||
|
dataKey: dataFn(1),
|
||||||
|
color: 2,
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [chartTime, hasLongInterval])
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (!probe) return []
|
||||||
|
return probeStats.filter((record) => record.stats && probe.id in record.stats)
|
||||||
|
}, [probe, probeStats])
|
||||||
|
|
||||||
|
const legend = dataPoints.length > 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
legend={true}
|
||||||
|
empty={empty}
|
||||||
|
title={t`Response`}
|
||||||
|
description={t`Average, minimum, and maximum response time`}
|
||||||
|
grid={false}
|
||||||
|
>
|
||||||
|
<LineChartDefault
|
||||||
|
truncate
|
||||||
|
chartData={chartData}
|
||||||
|
customData={data}
|
||||||
|
dataPoints={dataPoints}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
connectNulls
|
||||||
|
legend={legend}
|
||||||
|
tickFormatter={(value) => formatMicroseconds(value, false)}
|
||||||
|
contentFormatter={({ value }) => {
|
||||||
|
if (typeof value !== "number") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return formatMicroseconds(value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
|
export function LossChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { lazy } from "react"
|
import { lazy } from "react"
|
||||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LatencyChart, LossChart } from "./charts/probes-charts"
|
import { ResponseChart, LossChart } from "./charts/probes-charts"
|
||||||
import type { SystemData } from "./use-system-data"
|
import type { SystemData } from "./use-system-data"
|
||||||
import { $chartTime } from "@/lib/stores"
|
import { $chartTime } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { useNetworkProbesData } from "@/lib/use-network-probes"
|
import { useNetworkProbes, useNetworkProbeStats } from "@/lib/use-network-probes"
|
||||||
|
|
||||||
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
||||||
|
|
||||||
@@ -56,14 +56,15 @@ function ProbesTable({ systemId, systemData }: { systemId: string; systemData: S
|
|||||||
const { grid, chartData } = systemData ?? {}
|
const { grid, chartData } = systemData ?? {}
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
const { probes, probeStats } = useNetworkProbesData({ systemId, loadStats: !!chartData, chartTime })
|
const probes = useNetworkProbes({ systemId })
|
||||||
|
const probeStats = useNetworkProbeStats({ systemId, chartTime })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NetworkProbesTable systemId={systemId} probes={probes} />
|
<NetworkProbesTable systemId={systemId} probes={probes} />
|
||||||
{!!chartData && !!probes.length && (
|
{!!chartData && !!probes.length && (
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<LatencyChart
|
<ResponseChart
|
||||||
probeStats={probeStats}
|
probeStats={probeStats}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
probes={probes}
|
probes={probes}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
formatTemperature,
|
formatTemperature,
|
||||||
cn,
|
cn,
|
||||||
getVisualStringWidth,
|
|
||||||
secondsToString,
|
secondsToString,
|
||||||
hourWithSeconds,
|
hourWithSeconds,
|
||||||
formatShortDate,
|
formatShortDate,
|
||||||
@@ -106,9 +105,9 @@ function formatCapacity(bytes: number): string {
|
|||||||
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||||
|
|
||||||
export const createColumns = (
|
export const createColumns = (
|
||||||
longestName: number,
|
longestName: string,
|
||||||
longestModel: number,
|
longestModel: string,
|
||||||
longestDevice: number
|
longestDevice: string
|
||||||
): ColumnDef<SmartDeviceRecord>[] => [
|
): ColumnDef<SmartDeviceRecord>[] => [
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
@@ -123,8 +122,11 @@ export const createColumns = (
|
|||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const allSystems = useStore($allSystemsById)
|
const allSystems = useStore($allSystemsById)
|
||||||
return (
|
return (
|
||||||
<div className="ms-1.5 max-w-40 block truncate" style={{ width: `${longestName / 1.05}ch` }}>
|
<div className="ms-1.5 relative w-fit max-w-44">
|
||||||
{allSystems[getValue() as string]?.name ?? ""}
|
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||||
|
{longestName}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-0 truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -134,12 +136,11 @@ export const createColumns = (
|
|||||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div
|
<div className="font-medium ms-1 relative w-fit max-w-44" title={getValue() as string}>
|
||||||
className="font-medium max-w-40 truncate ms-1"
|
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||||
title={getValue() as string}
|
{longestDevice}
|
||||||
style={{ width: `${longestDevice / 1.05}ch` }}
|
</span>
|
||||||
>
|
<span className="absolute inset-0 truncate">{getValue() as string}</span>
|
||||||
{getValue() as string}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -150,12 +151,11 @@ export const createColumns = (
|
|||||||
<HeaderButton column={column} name={t({ message: "Model", comment: "Device model" })} Icon={Box} />
|
<HeaderButton column={column} name={t({ message: "Model", comment: "Device model" })} Icon={Box} />
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div
|
<div className="ms-1 relative w-fit max-w-44" title={getValue() as string}>
|
||||||
className="max-w-48 truncate ms-1"
|
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||||
title={getValue() as string}
|
{longestModel}
|
||||||
style={{ width: `${longestModel / 1.05}ch` }}
|
</span>
|
||||||
>
|
<span className="absolute inset-0 truncate">{getValue() as string}</span>
|
||||||
{getValue() as string}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -309,7 +309,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
|
|
||||||
// Calculate the right width for the columns based on the longest strings among the displayed devices
|
// Calculate the right width for the columns based on the longest strings among the displayed devices
|
||||||
const { longestName, longestModel, longestDevice } = useMemo(() => {
|
const { longestName, longestModel, longestDevice } = useMemo(() => {
|
||||||
const result = { longestName: 0, longestModel: 0, longestDevice: 0 }
|
const result = { longestName: "", longestModel: "", longestDevice: "" }
|
||||||
if (!smartDevices || Object.keys(allSystems).length === 0) {
|
if (!smartDevices || Object.keys(allSystems).length === 0) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -318,10 +318,16 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
if (!systemId && !seenSystems.has(device.system)) {
|
if (!systemId && !seenSystems.has(device.system)) {
|
||||||
seenSystems.add(device.system)
|
seenSystems.add(device.system)
|
||||||
const name = allSystems[device.system]?.name ?? ""
|
const name = allSystems[device.system]?.name ?? ""
|
||||||
result.longestName = Math.max(result.longestName, getVisualStringWidth(name))
|
if (name.length > result.longestName.length) {
|
||||||
|
result.longestName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((device.model ?? "").length > result.longestModel.length) {
|
||||||
|
result.longestModel = device.model ?? ""
|
||||||
|
}
|
||||||
|
if ((device.name ?? "").length > result.longestDevice.length) {
|
||||||
|
result.longestDevice = device.name ?? ""
|
||||||
}
|
}
|
||||||
result.longestModel = Math.max(result.longestModel, getVisualStringWidth(device.model ?? ""))
|
|
||||||
result.longestDevice = Math.max(result.longestDevice, getVisualStringWidth(device.name ?? ""))
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [smartDevices, systemId, allSystems])
|
}, [smartDevices, systemId, allSystems])
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export function useSystemData(id: string) {
|
|||||||
// derived values
|
// derived values
|
||||||
const isLongerChart = !["1m", "1h"].includes(chartTime)
|
const isLongerChart = !["1m", "1h"].includes(chartTime)
|
||||||
const showMax = maxValues && isLongerChart
|
const showMax = maxValues && isLongerChart
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats?.length === 0
|
||||||
const lastGpus = systemStats.at(-1)?.stats?.g
|
const lastGpus = systemStats.at(-1)?.stats?.g
|
||||||
const isPodman = details?.podman ?? system.info?.p ?? false
|
const isPodman = details?.podman ?? system.info?.p ?? false
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { memo, useMemo, useRef, useState } from "react"
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
import { $longestSystemName, $userSettings } from "@/lib/stores"
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@@ -135,7 +135,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
Icon: ServerIcon,
|
Icon: ServerIcon,
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const { name, id } = info.row.original
|
const { name, id } = info.row.original
|
||||||
const longestName = useStore($longestSystemNameLen)
|
const longestName = useStore($longestSystemName)
|
||||||
const linkUrl = getPagePath($router, "system", { id })
|
const linkUrl = getPagePath($router, "system", { id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,8 +145,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
<Link
|
<Link
|
||||||
href={linkUrl}
|
href={linkUrl}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="truncate z-10 relative"
|
className="relative w-fit max-w-48 z-10"
|
||||||
style={{ width: `${longestName / 1.05}ch` }}
|
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
// set title on hover if text is truncated to show full name
|
// set title on hover if text is truncated to show full name
|
||||||
const a = e.currentTarget
|
const a = e.currentTarget
|
||||||
@@ -157,7 +156,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{name}
|
<span className="invisible block" aria-hidden="true">
|
||||||
|
{longestName}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-0 truncate">{name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
<Link href={linkUrl} className="inset-0 absolute size-full" aria-label={name}></Link>
|
<Link href={linkUrl} className="inset-0 absolute size-full" aria-label={name}></Link>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { JSX } from "react"
|
|
||||||
import { useLingui } from "@lingui/react/macro"
|
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 { ChartTimes } from "@/types"
|
||||||
import { Separator } from "./separator"
|
import { Separator } from "./separator"
|
||||||
import { AxisDomain } from "recharts/types/util/types"
|
import type { AxisDomain } from "recharts/types/util/types"
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
@@ -102,7 +101,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
labelKey?: string
|
labelKey?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
filter?: string
|
filter?: string
|
||||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
contentFormatter?: (item: unknown, key: string) => React.ReactNode | string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
showTotal?: boolean
|
showTotal?: boolean
|
||||||
totalLabel?: React.ReactNode
|
totalLabel?: React.ReactNode
|
||||||
@@ -176,7 +175,13 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalKey = "__total__"
|
const totalKey = "__total__"
|
||||||
const totalItem: any = {
|
const totalItem: {
|
||||||
|
value: number
|
||||||
|
name: string
|
||||||
|
dataKey: string
|
||||||
|
color: string | undefined
|
||||||
|
payload?: unknown
|
||||||
|
} = {
|
||||||
value: totalValue,
|
value: totalValue,
|
||||||
name: totalName,
|
name: totalName,
|
||||||
dataKey: totalKey,
|
dataKey: totalKey,
|
||||||
@@ -401,21 +406,23 @@ 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: {
|
interface XAxisData {
|
||||||
time: number
|
el: React.ReactElement
|
||||||
el: JSX.Element
|
domain: [number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
const xAxis = (chartTime: ChartTimes, lastCreationTime: number) => {
|
const xAxisCache = new Map<ChartTimes, XAxisData>()
|
||||||
if (Math.abs(lastCreationTime - cachedAxis?.time) < 1000) {
|
|
||||||
return cachedAxis.el
|
function createXAxisData(chartTime: ChartTimes): XAxisData {
|
||||||
}
|
// console.log("Creating XAxis for", chartTime, new Date())
|
||||||
const now = new Date(lastCreationTime + 1000)
|
const axisEndTime = Date.now() + 500
|
||||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
const axisEndDate = new Date(axisEndTime)
|
||||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
const startTime = chartTimeData[chartTime].getOffset(axisEndDate)
|
||||||
const domain = [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()]
|
const ticks = timeTicks(startTime, axisEndDate, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||||
cachedAxis = {
|
const domain: [number, number] = [startTime.getTime(), axisEndTime]
|
||||||
time: lastCreationTime,
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
el: (
|
el: (
|
||||||
<RechartsPrimitive.XAxis
|
<RechartsPrimitive.XAxis
|
||||||
dataKey="created"
|
dataKey="created"
|
||||||
@@ -431,7 +438,25 @@ const xAxis = (chartTime: ChartTimes, lastCreationTime: number) => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function xAxis(chartTime: ChartTimes, lastCreated: number) {
|
||||||
|
if (!lastCreated) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const cachedAxis = xAxisCache.get(chartTime)
|
||||||
|
|
||||||
|
const expectedInterval = chartTimeData[chartTime].expectedInterval
|
||||||
|
const conservativeEndTime = Date.now() - expectedInterval / 2
|
||||||
|
const axisEndTime = Math.max(lastCreated, conservativeEndTime)
|
||||||
|
|
||||||
|
if (cachedAxis && axisEndTime < cachedAxis.domain[1]) {
|
||||||
return cachedAxis.el
|
return cachedAxis.el
|
||||||
|
}
|
||||||
|
|
||||||
|
const axisData = createXAxisData(chartTime)
|
||||||
|
xAxisCache.set(chartTime, axisData)
|
||||||
|
return axisData.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!",
|
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted/40!",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -54,8 +54,11 @@ export async function updateUserSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
|
export function getPbTimestamp(timeString: ChartTimes, d?: Date, createdIsNumber?: boolean) {
|
||||||
d ||= chartTimeData[timeString].getOffset(new Date())
|
d ||= chartTimeData[timeString].getOffset(new Date())
|
||||||
|
if (createdIsNumber) {
|
||||||
|
return d.getTime()
|
||||||
|
}
|
||||||
const year = d.getUTCFullYear()
|
const year = d.getUTCFullYear()
|
||||||
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
|
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
|
||||||
const day = String(d.getUTCDate()).padStart(2, "0")
|
const day = String(d.getUTCDate()).padStart(2, "0")
|
||||||
|
|||||||
@@ -70,7 +70,5 @@ export const $copyContent = atom("")
|
|||||||
/** Direction for localization */
|
/** Direction for localization */
|
||||||
export const $direction = atom<"ltr" | "rtl">("ltr")
|
export const $direction = atom<"ltr" | "rtl">("ltr")
|
||||||
|
|
||||||
/** Longest system name length. Used to set table column width. I know this
|
/** Longest system name string. Used to reserve width in virtualized tables. */
|
||||||
* is stupid but the table is virtualized and I know this will work.
|
export const $longestSystemName = atom("")
|
||||||
*/
|
|
||||||
export const $longestSystemNameLen = atom(8)
|
|
||||||
|
|||||||
@@ -5,20 +5,17 @@ import {
|
|||||||
$allSystemsById,
|
$allSystemsById,
|
||||||
$allSystemsByName,
|
$allSystemsByName,
|
||||||
$downSystems,
|
$downSystems,
|
||||||
$longestSystemNameLen,
|
$longestSystemName,
|
||||||
$pausedSystems,
|
$pausedSystems,
|
||||||
$upSystems,
|
$upSystems,
|
||||||
} from "@/lib/stores"
|
} from "@/lib/stores"
|
||||||
import { getVisualStringWidth, updateFavicon } from "@/lib/utils"
|
import { isVisuallyLonger, updateFavicon } from "@/lib/utils"
|
||||||
import type { SystemRecord } from "@/types"
|
import type { SystemRecord } from "@/types"
|
||||||
import { SystemStatus } from "./enums"
|
import { SystemStatus } from "./enums"
|
||||||
|
|
||||||
const COLLECTION = pb.collection<SystemRecord>("systems")
|
const COLLECTION = pb.collection<SystemRecord>("systems")
|
||||||
const FIELDS_DEFAULT = "id,name,host,port,info,status"
|
const FIELDS_DEFAULT = "id,name,host,port,info,status"
|
||||||
|
|
||||||
/** Maximum system name length for display purposes */
|
|
||||||
const MAX_SYSTEM_NAME_LENGTH = 22
|
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
|
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
|
||||||
let unsub: (() => void) | undefined | void
|
let unsub: (() => void) | undefined | void
|
||||||
@@ -44,7 +41,7 @@ export function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!newSystem) {
|
if (!newSystem) {
|
||||||
onSystemsChanged(newSystems, undefined)
|
onSystemsChanged(newSystems, newSystem, oldSystem)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,20 +65,28 @@ export function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// run things that need to be done when systems change
|
// run things that need to be done when systems change
|
||||||
onSystemsChanged(newSystems, newSystem)
|
onSystemsChanged(newSystems, newSystem, oldSystem)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update the longest system name length and favicon based on system status */
|
/** Update the longest system name string and favicon based on system status */
|
||||||
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
|
function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: SystemRecord, oldSystem?: SystemRecord) {
|
||||||
const downSystemsStore = $downSystems.get()
|
const downSystemsStore = $downSystems.get()
|
||||||
const downSystems = Object.values(downSystemsStore)
|
const downSystems = Object.values(downSystemsStore)
|
||||||
|
|
||||||
// Update longest system name length
|
// if the old system's old name was the longest, we need to find the new longest name
|
||||||
const longestName = $longestSystemNameLen.get()
|
// otherwise, if the changed system's new name is longer than the current longest, update it
|
||||||
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, getVisualStringWidth(changedSystem?.name || ""))
|
const longestName = $longestSystemName.get()
|
||||||
if (nameLen > longestName) {
|
if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) {
|
||||||
$longestSystemNameLen.set(nameLen)
|
let newLongest = ""
|
||||||
|
for (const id in systems) {
|
||||||
|
if (isVisuallyLonger(systems[id].name, newLongest)) {
|
||||||
|
newLongest = systems[id].name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$longestSystemName.set(newLongest)
|
||||||
|
} else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) {
|
||||||
|
$longestSystemName.set(newSystem.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFavicon(downSystems.length)
|
updateFavicon(downSystems.length)
|
||||||
|
|||||||
@@ -31,26 +31,20 @@ function appendCacheValue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NETWORK_PROBE_FIELDS = "id,name,system,target,protocol,port,interval,latency,loss,enabled,updated"
|
const NETWORK_PROBE_FIELDS =
|
||||||
|
"id,name,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
|
||||||
|
|
||||||
interface UseNetworkProbesProps {
|
interface UseNetworkProbesProps {
|
||||||
systemId?: string
|
systemId?: string
|
||||||
loadStats?: boolean
|
|
||||||
chartTime?: ChartTimes
|
|
||||||
existingProbes?: NetworkProbeRecord[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
export function useNetworkProbes(props: UseNetworkProbesProps) {
|
||||||
const { systemId, loadStats, chartTime, existingProbes } = props
|
const { systemId } = props
|
||||||
|
|
||||||
const [p, setProbes] = useState<NetworkProbeRecord[]>([])
|
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
||||||
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
|
||||||
const statsRequestId = useRef(0)
|
|
||||||
const pendingProbeEvents = useRef(new Map<string, RecordSubscription<NetworkProbeRecord>>())
|
const pendingProbeEvents = useRef(new Map<string, RecordSubscription<NetworkProbeRecord>>())
|
||||||
const probeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const probeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const probes = existingProbes ?? p
|
|
||||||
|
|
||||||
// clear old data when systemId changes
|
// clear old data when systemId changes
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// return setProbes([])
|
// return setProbes([])
|
||||||
@@ -58,16 +52,11 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
|
|
||||||
// initial load - fetch probes if not provided by caller
|
// initial load - fetch probes if not provided by caller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!existingProbes) {
|
|
||||||
fetchProbes(systemId).then((probes) => setProbes(probes))
|
fetchProbes(systemId).then((probes) => setProbes(probes))
|
||||||
}
|
|
||||||
}, [systemId])
|
}, [systemId])
|
||||||
|
|
||||||
// Subscribe to updates if probes not provided by caller
|
// Subscribe to updates if probes not provided by caller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingProbes) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
|
|
||||||
function flushPendingProbeEvents() {
|
function flushPendingProbeEvents() {
|
||||||
@@ -114,15 +103,74 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
}
|
}
|
||||||
}, [systemId])
|
}, [systemId])
|
||||||
|
|
||||||
// Subscribe to new probe stats
|
return probes
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseNetworkProbeStatsProps {
|
||||||
|
systemId?: string
|
||||||
|
chartTime: ChartTimes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNetworkProbeStats(props: UseNetworkProbeStatsProps) {
|
||||||
|
const { systemId, chartTime } = props
|
||||||
|
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
||||||
|
const requestID = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadStats || !systemId) {
|
if (!systemId) {
|
||||||
|
setProbeStats([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chartTime === "1m") {
|
||||||
|
setProbeStats(getCacheValue(systemId, "rt"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProbeStats(getCacheValue(systemId, chartTime))
|
||||||
|
}, [systemId, chartTime])
|
||||||
|
|
||||||
|
// fetch missing probe stats on load and when chart time changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!systemId || !chartTime || chartTime === "1m") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { expectedInterval } = chartTimeData[chartTime]
|
||||||
|
const requestId = ++requestID.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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats<NetworkProbeStatsRecord>("network_probe_stats", systemId, chartTime, cachedProbeStats, true).then(
|
||||||
|
(probeStats) => {
|
||||||
|
// If another request has been made since this one, ignore the results
|
||||||
|
if (requestId !== requestID.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newStats = appendCacheValue(systemId, chartTime, probeStats)
|
||||||
|
setProbeStats(newStats)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, [systemId, chartTime])
|
||||||
|
|
||||||
|
// Subscribe to new probe stats on non-1m chart times (1h, 12h, etc)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!systemId || !chartTime || chartTime === "1m") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
const pbOptions = {
|
const pbOptions = {
|
||||||
fields: "stats,created,type",
|
fields: "stats,created,type",
|
||||||
filter: pb.filter("system = {:system}", { system: systemId }),
|
filter: pb.filter("system={:system} && type={:type}", { system: systemId, type: chartTimeData[chartTime].type }),
|
||||||
}
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
@@ -130,34 +178,9 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
unsubscribe = await pb.collection<NetworkProbeStatsRecord>("network_probe_stats").subscribe(
|
unsubscribe = await pb.collection<NetworkProbeStatsRecord>("network_probe_stats").subscribe(
|
||||||
"*",
|
"*",
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!chartTime || event.action !== "create") {
|
if (event.action !== "create") {
|
||||||
return
|
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)
|
// console.log("Appending new probe stats to chart:", event.record)
|
||||||
const newStats = appendCacheValue(systemId, chartTime, [event.record])
|
const newStats = appendCacheValue(systemId, chartTime, [event.record])
|
||||||
setProbeStats(newStats)
|
setProbeStats(newStats)
|
||||||
@@ -170,46 +193,11 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return () => unsubscribe?.()
|
return () => unsubscribe?.()
|
||||||
}, [systemId])
|
}, [systemId, chartTime])
|
||||||
|
|
||||||
// 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
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadStats || !systemId || chartTime !== "1m") {
|
if (!systemId || chartTime !== "1m") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
@@ -218,13 +206,13 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
.subscribe(
|
.subscribe(
|
||||||
`rt_metrics`,
|
`rt_metrics`,
|
||||||
(data: { Probes: NetworkProbeStatsRecord["stats"] }) => {
|
(data: { Probes: NetworkProbeStatsRecord["stats"] }) => {
|
||||||
let prev = getCacheValue(systemId, "rt")
|
const prev = getCacheValue(systemId, "rt")
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
// if no previous data or the last data point is older than 1min,
|
// 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
|
// 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) {
|
// if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) {
|
||||||
prev = [{ created: now - 1000, stats: probesToStats(probes) }]
|
// prev = [{ created: now - 30_000, stats: probesToStats(probes) }]
|
||||||
}
|
// }
|
||||||
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
|
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
|
||||||
const newStats = appendData(prev, [stats], 1000, 120)
|
const newStats = appendData(prev, [stats], 1000, 120)
|
||||||
setProbeStats(() => newStats)
|
setProbeStats(() => newStats)
|
||||||
@@ -238,31 +226,13 @@ export function useNetworkProbesData(props: UseNetworkProbesProps) {
|
|||||||
return () => unsubscribe?.()
|
return () => unsubscribe?.()
|
||||||
}, [chartTime, systemId])
|
}, [chartTime, systemId])
|
||||||
|
|
||||||
return {
|
return probeStats
|
||||||
probes,
|
|
||||||
probeStats,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
async function fetchProbes(system?: string) {
|
||||||
export function probeKey(p: NetworkProbeRecord) {
|
|
||||||
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
|
||||||
return `${p.protocol}:${p.target}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function probesToStats(probes: NetworkProbeRecord[]): NetworkProbeStatsRecord["stats"] {
|
|
||||||
const stats: NetworkProbeStatsRecord["stats"] = {}
|
|
||||||
for (const probe of probes) {
|
|
||||||
const key = probeKey(probe)
|
|
||||||
stats[key] = [probe.latency, 0, 0, probe.loss]
|
|
||||||
}
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProbes(systemId?: string) {
|
|
||||||
try {
|
try {
|
||||||
const res = await pb.collection<NetworkProbeRecord>("network_probes").getList(0, 2000, {
|
const res = await pb.collection<NetworkProbeRecord>("network_probes").getList(0, 2000, {
|
||||||
fields: NETWORK_PROBE_FIELDS,
|
fields: NETWORK_PROBE_FIELDS,
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
filter: system ? pb.filter("system={:system}", { system }) : undefined,
|
||||||
})
|
})
|
||||||
return res.items
|
return res.items
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const formatShortDate = (timestamp: string) => {
|
|||||||
return shortDateFormatter.format(new Date(timestamp))
|
return shortDateFormatter.format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hourWithSeconds = (timestamp: string) => {
|
export const hourWithSeconds = (timestamp: string | number) => {
|
||||||
return hourWithSecondsFormatter.format(new Date(timestamp))
|
return hourWithSecondsFormatter.format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,8 @@ export const updateFavicon = (() => {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
||||||
${downCount > 0 &&
|
${
|
||||||
|
downCount > 0 &&
|
||||||
`
|
`
|
||||||
<circle cx="40" cy="50" r="22" fill="#f00"/>
|
<circle cx="40" cy="50" r="22" fill="#f00"/>
|
||||||
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
|
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
|
||||||
@@ -121,7 +122,7 @@ export const updateFavicon = (() => {
|
|||||||
`
|
`
|
||||||
const blob = new Blob([svg], { type: "image/svg+xml" })
|
const blob = new Blob([svg], { type: "image/svg+xml" })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
; (document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -198,6 +199,26 @@ export function decimalString(num: number, digits = 2) {
|
|||||||
return formatter.format(num)
|
return formatter.format(num)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatMicroseconds(microseconds: number, showDigits = true): string {
|
||||||
|
if (!Number.isFinite(microseconds)) {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (microseconds < 1000) {
|
||||||
|
return `${microseconds}μs`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (microseconds < 1_000_000) {
|
||||||
|
const milliseconds = microseconds / 1000
|
||||||
|
const digits = milliseconds >= 10 ? 1 : 2
|
||||||
|
return `${decimalString(milliseconds, showDigits ? digits : 0)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const seconds = microseconds / 1_000_000
|
||||||
|
const digits = seconds >= 10 ? 1 : 2
|
||||||
|
return `${decimalString(seconds, showDigits ? digits : 0)}s`
|
||||||
|
}
|
||||||
|
|
||||||
/** Get value from local or session storage */
|
/** Get value from local or session storage */
|
||||||
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
|
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
|
||||||
const saved = storageInterface?.getItem(key)
|
const saved = storageInterface?.getItem(key)
|
||||||
@@ -370,7 +391,7 @@ export function formatDuration(
|
|||||||
* const semVer = "1.2.3"
|
* const semVer = "1.2.3"
|
||||||
* const { major, minor, patch } = parseSemVer(semVer)
|
* const { major, minor, patch } = parseSemVer(semVer)
|
||||||
* console.log(major, minor, patch) // 1, 2, 3
|
* console.log(major, minor, patch) // 1, 2, 3
|
||||||
*/
|
*/
|
||||||
export const parseSemVer = (semVer = ""): SemVer => {
|
export const parseSemVer = (semVer = ""): SemVer => {
|
||||||
// if (semVer.startsWith("v")) {
|
// if (semVer.startsWith("v")) {
|
||||||
// semVer = semVer.slice(1)
|
// semVer = semVer.slice(1)
|
||||||
@@ -422,10 +443,22 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
|
|||||||
}) as T
|
}) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the visual width of a string, accounting for full-width characters */
|
const visualWidthCache = new Map<string, number>()
|
||||||
export function getVisualStringWidth(str: string): number {
|
|
||||||
|
/** Get the visual width of a string, accounting for full-width and narrow punctuation characters.
|
||||||
|
* Don't use for monospaced fonts, use .length instead
|
||||||
|
*/
|
||||||
|
function getVisualStringWidth(str: string): number {
|
||||||
|
const cached = visualWidthCache.get(str)
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
let width = 0
|
let width = 0
|
||||||
for (const char of str) {
|
for (const char of str) {
|
||||||
|
if (char === ".") {
|
||||||
|
width += 0.7
|
||||||
|
continue
|
||||||
|
}
|
||||||
const code = char.codePointAt(0) || 0
|
const code = char.codePointAt(0) || 0
|
||||||
// Hangul Jamo and Syllables are often slightly thinner than Hanzi/Kanji
|
// Hangul Jamo and Syllables are often slightly thinner than Hanzi/Kanji
|
||||||
if ((code >= 0x1100 && code <= 0x115f) || (code >= 0xac00 && code <= 0xd7af)) {
|
if ((code >= 0x1100 && code <= 0x115f) || (code >= 0xac00 && code <= 0xd7af)) {
|
||||||
@@ -443,16 +476,27 @@ export function getVisualStringWidth(str: string): number {
|
|||||||
code > 0xffff // Emojis and other supplementary plane characters
|
code > 0xffff // Emojis and other supplementary plane characters
|
||||||
width += isFullWidth ? 2 : 1
|
width += isFullWidth ? 2 : 1
|
||||||
}
|
}
|
||||||
|
visualWidthCache.set(str, width)
|
||||||
return width
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compare the visual width of two strings imprecisely */
|
||||||
|
export function isVisuallyLonger(str1: string, str2: string): boolean {
|
||||||
|
return getVisualStringWidth(str1) > getVisualStringWidth(str2)
|
||||||
|
}
|
||||||
|
|
||||||
/** Format seconds to hours, minutes, or seconds */
|
/** Format seconds to hours, minutes, or seconds */
|
||||||
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
|
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
|
||||||
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))
|
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))
|
||||||
const countString = count.toLocaleString()
|
const countString = count.toLocaleString()
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case "minute":
|
case "minute":
|
||||||
return plural(count, { one: `${countString} minute`, few: `${countString} minutes`, many: `${countString} minutes`, other: `${countString} minutes` })
|
return plural(count, {
|
||||||
|
one: `${countString} minute`,
|
||||||
|
few: `${countString} minutes`,
|
||||||
|
many: `${countString} minutes`,
|
||||||
|
other: `${countString} minutes`,
|
||||||
|
})
|
||||||
case "hour":
|
case "hour":
|
||||||
return plural(count, { one: `${countString} hour`, other: `${countString} hours` })
|
return plural(count, { one: `${countString} hour`, other: `${countString} hours` })
|
||||||
case "day":
|
case "day":
|
||||||
|
|||||||
24
internal/site/src/types.d.ts
vendored
24
internal/site/src/types.d.ts
vendored
@@ -313,8 +313,8 @@ export interface SemVer {
|
|||||||
|
|
||||||
export interface ChartData {
|
export interface ChartData {
|
||||||
agentVersion: SemVer
|
agentVersion: SemVer
|
||||||
systemStats: SystemStatsRecord[]
|
systemStats?: SystemStatsRecord[]
|
||||||
containerData: ChartDataContainer[]
|
containerData?: ChartDataContainer[]
|
||||||
orientation: "right" | "left"
|
orientation: "right" | "left"
|
||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
}
|
}
|
||||||
@@ -552,27 +552,33 @@ export interface NetworkProbeRecord {
|
|||||||
target: string
|
target: string
|
||||||
protocol: "icmp" | "tcp" | "http"
|
protocol: "icmp" | "tcp" | "http"
|
||||||
port: number
|
port: number
|
||||||
latency: number
|
res: number
|
||||||
|
resMin1h: number
|
||||||
|
resMax1h: number
|
||||||
|
resAvg1h: number
|
||||||
loss: number
|
loss: number
|
||||||
|
loss1h: number
|
||||||
interval: number
|
interval: number
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
updated: string
|
updated: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 0: avg latency in ms
|
* Stats holds only 1m values for a single target, which are used for charts.
|
||||||
*
|
*
|
||||||
* 1: min latency in ms
|
* 0: avg response in microseconds
|
||||||
*
|
*
|
||||||
* 2: max latency in ms
|
* 1: min response in microseconds
|
||||||
*
|
*
|
||||||
* 3: packet loss in %
|
* 2: max response in microseconds
|
||||||
|
*
|
||||||
|
* 3: packet loss percentage (0-100)
|
||||||
*/
|
*/
|
||||||
type ProbeResult = number[]
|
type ProbeStats = number[]
|
||||||
|
|
||||||
export interface NetworkProbeStatsRecord {
|
export interface NetworkProbeStatsRecord {
|
||||||
id?: string
|
id?: string
|
||||||
type?: string
|
type?: string
|
||||||
stats: Record<string, ProbeResult>
|
stats: Record<string, ProbeStats>
|
||||||
created: number // unix timestamp (ms) for Recharts xAxis
|
created: number // unix timestamp (ms) for Recharts xAxis
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user