mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-14 22:37:48 +02:00
Compare commits
82 Commits
0f2e16c63c
...
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 | ||
|
|
6472af1ba4 | ||
|
|
e931165566 | ||
|
|
48fe407292 | ||
|
|
a95376b4a2 | ||
|
|
732983493a | ||
|
|
264b17f429 | ||
|
|
cef5ab10a5 | ||
|
|
3a881e1d5e | ||
|
|
209bb4ebb4 | ||
|
|
e71ffd4d2a | ||
|
|
ea19ef6334 | ||
|
|
40da2b4358 | ||
|
|
d0d5912d85 | ||
|
|
4162186ae0 | ||
|
|
a71617e058 | ||
|
|
578ba985e9 | ||
|
|
e5507fa106 | ||
|
|
a024c3cfd0 | ||
|
|
07466804e7 | ||
|
|
981c788d6f | ||
|
|
f5576759de | ||
|
|
be0b708064 | ||
|
|
485830452e | ||
|
|
2fd00cd0b5 | ||
|
|
853a294157 | ||
|
|
aa9ab49654 | ||
|
|
9a5959b57e | ||
|
|
50f8548479 | ||
|
|
bc0581ea61 | ||
|
|
ab3a3de46c | ||
|
|
1556e53926 | ||
|
|
e3ade3aeb8 | ||
|
|
b013f06956 | ||
|
|
fab5e8a656 | ||
|
|
3a0896e57e | ||
|
|
7fdc403470 | ||
|
|
e833d44c43 | ||
|
|
77dd4bdaf5 | ||
|
|
ecba63c4bb | ||
|
|
f9feaf5343 | ||
|
|
ddf5e925c8 | ||
|
|
865e6db90f | ||
|
|
a42d899e64 | ||
|
|
3eaf12a7d5 | ||
|
|
3793b27958 | ||
|
|
5b02158228 | ||
|
|
0ae8c42ae0 | ||
|
|
ea80f3c5a2 | ||
|
|
c3dffff5e4 | ||
|
|
06fdd0e7a8 | ||
|
|
6e3fd90834 | ||
|
|
5ab82183fa | ||
|
|
a68e02ca84 |
@@ -48,6 +48,7 @@ type Agent struct {
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
smartManager *SmartManager // Manages SMART data
|
||||
systemdManager *systemdManager // Manages systemd services
|
||||
probeManager *ProbeManager // Manages network probes
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||
@@ -121,6 +122,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
// initialize handler registry
|
||||
agent.handlerRegistry = NewHandlerRegistry()
|
||||
|
||||
// initialize probe manager
|
||||
agent.probeManager = newProbeManager()
|
||||
|
||||
// initialize disk info
|
||||
agent.initializeDiskInfo()
|
||||
|
||||
@@ -178,6 +182,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
||||
}
|
||||
}
|
||||
|
||||
if a.probeManager != nil {
|
||||
data.Probes = a.probeManager.GetResults(cacheTimeMs)
|
||||
slog.Debug("Probes", "data", data.Probes)
|
||||
}
|
||||
|
||||
// skip updating systemd services if cache time is not the default 60sec interval
|
||||
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||
|
||||
@@ -82,6 +82,9 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
paths, err := getBatteryPaths()
|
||||
if err != nil {
|
||||
return batteryPercent, batteryState, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/lxzan/gws"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -104,6 +105,11 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||
}
|
||||
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
||||
|
||||
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
|
||||
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
|
||||
os.Setenv("ALL_PROXY", val)
|
||||
}
|
||||
|
||||
client.options = &gws.ClientOption{
|
||||
Addr: client.hubURL.String(),
|
||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
@@ -112,6 +118,9 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||
"X-Token": []string{client.token},
|
||||
"X-Beszel": []string{beszel.Version},
|
||||
},
|
||||
NewDialer: func() (gws.Dialer, error) {
|
||||
return proxy.FromEnvironment(), nil
|
||||
},
|
||||
}
|
||||
return client.options
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/health"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -111,13 +115,37 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
_ = health.Update()
|
||||
case <-sigCtx.Done():
|
||||
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
||||
_ = c.agent.StopServer()
|
||||
c.closeWebSocket()
|
||||
return health.CleanUp()
|
||||
return c.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
|
||||
//
|
||||
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
|
||||
//
|
||||
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// c.cancel = cancel
|
||||
//
|
||||
// for {
|
||||
// select {
|
||||
// case <-ctx.Done():
|
||||
// return c.stop()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func (c *ConnectionManager) Stop() {
|
||||
// c.cancel()
|
||||
// }
|
||||
func (c *ConnectionManager) stop() error {
|
||||
_ = c.agent.StopServer()
|
||||
c.agent.probeManager.Stop()
|
||||
c.closeWebSocket()
|
||||
return health.CleanUp()
|
||||
}
|
||||
|
||||
// handleEvent processes connection events and updates the connection state accordingly.
|
||||
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||
switch event {
|
||||
@@ -185,9 +213,16 @@ func (c *ConnectionManager) connect() {
|
||||
|
||||
// Try WebSocket first, if it fails, start SSH server
|
||||
err := c.startWebSocketConnection()
|
||||
if err != nil && c.State == Disconnected {
|
||||
c.startSSHServer()
|
||||
c.startWsTicker()
|
||||
if err != nil {
|
||||
if shouldExitOnErr(err) {
|
||||
time.Sleep(2 * time.Second) // prevent tight restart loop
|
||||
_ = c.stop()
|
||||
os.Exit(1)
|
||||
}
|
||||
if c.State == Disconnected {
|
||||
c.startSSHServer()
|
||||
c.startWsTicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +259,14 @@ func (c *ConnectionManager) closeWebSocket() {
|
||||
c.wsClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
|
||||
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
|
||||
func shouldExitOnErr(err error) bool {
|
||||
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
|
||||
if opErr, ok := errors.AsType[*net.OpError](err); ok {
|
||||
return strings.Contains(opErr.Err.Error(), "lookup")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package agent
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -298,3 +299,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
|
||||
cm.connect()
|
||||
}, "Connect should not panic without WebSocket client")
|
||||
}
|
||||
|
||||
func TestShouldExitOnErr(t *testing.T) {
|
||||
createDialErr := func(msg string) error {
|
||||
return &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: errors.New(msg),
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
envValue string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no env var",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var false",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "false",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var true, matching error",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "env var true, matching error with extra context",
|
||||
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
|
||||
envValue: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "env var true, non-matching error",
|
||||
err: errors.New("connection refused"),
|
||||
envValue: "true",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var true, dial but not lookup",
|
||||
err: createDialErr("connection timeout"),
|
||||
envValue: "true",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
|
||||
result := shouldExitOnErr(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
||||
func readSysfsFloat(path string) (float64, error) {
|
||||
val, err := utils.ReadStringFileLimited(path, 64)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseFloat(val, 64)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
|
||||
"log/slog"
|
||||
@@ -51,6 +52,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||
registry.Register(common.SyncNetworkProbes, &SyncNetworkProbesHandler{})
|
||||
|
||||
return registry
|
||||
}
|
||||
@@ -203,3 +205,21 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
||||
|
||||
return hctx.SendResponse(details, hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// SyncNetworkProbesHandler handles probe configuration sync from hub
|
||||
type SyncNetworkProbesHandler struct{}
|
||||
|
||||
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
||||
var req probe.SyncRequest
|
||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := hctx.Agent.probeManager.HandleSyncRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hctx.SendResponse(resp, hctx.RequestID)
|
||||
}
|
||||
|
||||
538
agent/probe.go
Normal file
538
agent/probe.go
Normal file
@@ -0,0 +1,538 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
// "strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"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.
|
||||
type ProbeManager struct {
|
||||
mu sync.RWMutex
|
||||
probes map[string]*probeTask // key = probe.Config.Key()
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// probeTask owns retention buffers and cancellation for a single probe config.
|
||||
type probeTask struct {
|
||||
config probe.Config
|
||||
cancel chan struct{}
|
||||
mu sync.Mutex
|
||||
samples []probeSample
|
||||
buckets [probeMinuteBucketLen]probeBucket
|
||||
}
|
||||
|
||||
// probeSample stores one probe attempt and its collection time.
|
||||
type probeSample struct {
|
||||
responseUs int64 // -1 means loss
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// probeBucket stores one minute of aggregated probe data.
|
||||
type probeBucket struct {
|
||||
minute int32
|
||||
filled bool
|
||||
stats probeAggregate
|
||||
}
|
||||
|
||||
// probeAggregate accumulates successful response stats and total sample counts.
|
||||
type probeAggregate struct {
|
||||
sumUs int64
|
||||
minUs int64
|
||||
maxUs int64
|
||||
totalCount int64
|
||||
successCount int64
|
||||
}
|
||||
|
||||
func newProbeManager() *ProbeManager {
|
||||
return &ProbeManager{
|
||||
probes: make(map[string]*probeTask),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func newProbeTask(config probe.Config) *probeTask {
|
||||
return &probeTask{
|
||||
config: config,
|
||||
cancel: make(chan struct{}),
|
||||
samples: make([]probeSample, 0, 64),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// Build set of new keys
|
||||
newKeys := make(map[string]probe.Config, len(configs))
|
||||
for _, cfg := range configs {
|
||||
if cfg.ID == "" {
|
||||
continue
|
||||
}
|
||||
newKeys[cfg.ID] = cfg
|
||||
}
|
||||
|
||||
// Stop removed probes
|
||||
for key, task := range pm.probes {
|
||||
if _, exists := newKeys[key]; !exists {
|
||||
close(task.cancel)
|
||||
delete(pm.probes, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new probes and restart tasks whose config changed.
|
||||
for key, cfg := range newKeys {
|
||||
task, exists := pm.probes[key]
|
||||
if exists && task.config == cfg {
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
close(task.cancel)
|
||||
}
|
||||
task = newProbeTaskFromExisting(cfg, task)
|
||||
pm.probes[key] = 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)
|
||||
}
|
||||
}
|
||||
|
||||
// GetResults returns aggregated results for all probes over the last supplied duration in ms.
|
||||
func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
results := make(map[string]probe.Result, len(pm.probes))
|
||||
now := time.Now()
|
||||
duration := time.Duration(durationMs) * time.Millisecond
|
||||
|
||||
for _, task := range pm.probes {
|
||||
task.mu.Lock()
|
||||
result, ok := task.resultLocked(duration, now)
|
||||
task.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
results[task.config.ID] = result
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Stop stops all probe tasks.
|
||||
func (pm *ProbeManager) Stop() {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
for key, task := range pm.probes {
|
||||
close(task.cancel)
|
||||
delete(pm.probes, key)
|
||||
}
|
||||
}
|
||||
|
||||
// runProbe executes a single probe task in a loop.
|
||||
func (pm *ProbeManager) runProbe(task *probeTask, runNow bool) {
|
||||
interval := time.Duration(task.config.Interval) * time.Second
|
||||
if interval < time.Second {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
|
||||
stagger := getStagger(interval.Milliseconds())
|
||||
|
||||
slog.Debug("starting probe task", "target", task.config.Target, "delay", stagger.String(), "interval", interval.String())
|
||||
|
||||
if runNow {
|
||||
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 {
|
||||
select {
|
||||
case <-task.cancel:
|
||||
// slog.Info("removed probe", "target", task.config.Target)
|
||||
return
|
||||
case <-ticker:
|
||||
pm.executeProbe(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getStagger returns a random duration between intervalSeconds/2 and intervalSeconds to stagger initial probe executions
|
||||
func getStagger(intervalMilli int64) time.Duration {
|
||||
intervalMilliInt := int(intervalMilli)
|
||||
randomDelayInt := rand.Intn(intervalMilliInt)
|
||||
if randomDelayInt < intervalMilliInt/2 {
|
||||
randomDelayInt += intervalMilliInt / 2
|
||||
}
|
||||
return time.Duration(randomDelayInt) * time.Millisecond
|
||||
}
|
||||
|
||||
func (pm *ProbeManager) runProbeNow(task *probeTask) *probe.Result {
|
||||
pm.executeProbe(task)
|
||||
task.mu.Lock()
|
||||
defer task.mu.Unlock()
|
||||
result, ok := task.resultLocked(time.Minute, time.Now())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &result
|
||||
}
|
||||
|
||||
// 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
|
||||
for i := range task.samples {
|
||||
if !task.samples[i].timestamp.Before(cutoff) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
if i == len(task.samples)-1 {
|
||||
start = len(task.samples)
|
||||
}
|
||||
}
|
||||
if start > 0 {
|
||||
size := copy(task.samples, task.samples[start:])
|
||||
task.samples = task.samples[:size]
|
||||
}
|
||||
task.samples = append(task.samples, sample)
|
||||
|
||||
minute := int32(sample.timestamp.Unix() / 60)
|
||||
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per probe.
|
||||
bucket := &task.buckets[minute%probeMinuteBucketLen]
|
||||
if !bucket.filled || bucket.minute != minute {
|
||||
bucket.minute = minute
|
||||
bucket.filled = true
|
||||
bucket.stats = newProbeAggregate()
|
||||
}
|
||||
bucket.stats.addResponse(sample.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()
|
||||
}
|
||||
|
||||
// probeTCP measures pure TCP handshake response (excluding DNS resolution).
|
||||
// Returns -1 and an error on failure.
|
||||
func probeTCP(target string, port uint16) (int64, error) {
|
||||
// Resolve DNS first, outside the timing window
|
||||
ips, err := net.LookupHost(target)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return -1, err
|
||||
}
|
||||
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
|
||||
|
||||
// Measure only the TCP handshake
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
conn.Close()
|
||||
return time.Since(start).Microseconds(), nil
|
||||
}
|
||||
|
||||
// probeHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
|
||||
func probeHTTP(client *http.Client, url string) (int64, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
start := time.Now()
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return -1, fmt.Errorf("HTTP error: %s", resp.Status)
|
||||
}
|
||||
return time.Since(start).Microseconds(), nil
|
||||
}
|
||||
241
agent/probe_ping.go
Normal file
241
agent/probe_ping.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.org/x/net/ipv6"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
||||
|
||||
type icmpPacketConn interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
||||
// all native methods fail, the choice is cached so subsequent probes skip
|
||||
// the trial-and-error overhead.
|
||||
type icmpMethod uint8
|
||||
|
||||
const (
|
||||
icmpUntried icmpMethod = iota // haven't tried yet
|
||||
icmpRaw // privileged raw socket
|
||||
icmpDatagram // unprivileged datagram socket
|
||||
icmpExecFallback // shell out to system ping command
|
||||
)
|
||||
|
||||
// icmpFamily holds the network parameters and cached detection result for one address family.
|
||||
type icmpFamily struct {
|
||||
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
||||
dgramNetwork string // e.g. "udp4" or "udp6"
|
||||
listenAddr string // "0.0.0.0" or "::"
|
||||
echoType icmp.Type // outgoing echo request type
|
||||
replyType icmp.Type // expected echo reply type
|
||||
proto int // IANA protocol number for parsing replies
|
||||
isIPv6 bool
|
||||
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
||||
}
|
||||
|
||||
var (
|
||||
icmpV4 = icmpFamily{
|
||||
rawNetwork: "ip4:icmp",
|
||||
dgramNetwork: "udp4",
|
||||
listenAddr: "0.0.0.0",
|
||||
echoType: ipv4.ICMPTypeEcho,
|
||||
replyType: ipv4.ICMPTypeEchoReply,
|
||||
proto: 1,
|
||||
}
|
||||
icmpV6 = icmpFamily{
|
||||
rawNetwork: "ip6:ipv6-icmp",
|
||||
dgramNetwork: "udp6",
|
||||
listenAddr: "::",
|
||||
echoType: ipv6.ICMPTypeEchoRequest,
|
||||
replyType: ipv6.ICMPTypeEchoReply,
|
||||
proto: 58,
|
||||
isIPv6: true,
|
||||
}
|
||||
icmpModeMu sync.Mutex
|
||||
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
||||
return icmp.ListenPacket(network, listenAddr)
|
||||
}
|
||||
)
|
||||
|
||||
// probeICMP sends an ICMP echo request and measures round-trip response.
|
||||
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
||||
// unprivileged datagram, or exec fallback) is detected once per address
|
||||
// family and cached for subsequent probes.
|
||||
// Returns response in microseconds, or -1 and an error on failure.
|
||||
func probeICMP(target string) (int64, error) {
|
||||
family, ip, err := resolveICMPTarget(target)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
icmpModeMu.Lock()
|
||||
if family.mode == icmpUntried {
|
||||
family.mode = detectICMPMode(family, icmpListen)
|
||||
}
|
||||
mode := family.mode
|
||||
icmpModeMu.Unlock()
|
||||
|
||||
switch mode {
|
||||
case icmpRaw:
|
||||
return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
|
||||
case icmpDatagram:
|
||||
return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
|
||||
case icmpExecFallback:
|
||||
return probeICMPExec(target, family.isIPv6)
|
||||
default:
|
||||
return -1, errors.New("unsupported ICMP mode")
|
||||
}
|
||||
}
|
||||
|
||||
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
||||
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
||||
func resolveICMPTarget(target string) (*icmpFamily, net.IP, error) {
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
return &icmpV4, ip.To4(), nil
|
||||
}
|
||||
return &icmpV6, ip, nil
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(target)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return &icmpV4, v4, nil
|
||||
}
|
||||
}
|
||||
return &icmpV6, ips[0], nil
|
||||
}
|
||||
|
||||
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
||||
label := "IPv4"
|
||||
if family.isIPv6 {
|
||||
label = "IPv6"
|
||||
}
|
||||
|
||||
conn, err := listen(family.rawNetwork, family.listenAddr)
|
||||
slog.Debug("ICMP raw socket test", "family", label, "err", err)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return icmpRaw
|
||||
}
|
||||
|
||||
conn, err = listen(family.dgramNetwork, family.listenAddr)
|
||||
slog.Debug("ICMP datagram socket test", "family", label, "err", err)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return icmpDatagram
|
||||
}
|
||||
|
||||
return icmpExecFallback
|
||||
}
|
||||
|
||||
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
||||
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) (int64, error) {
|
||||
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Build ICMP echo request
|
||||
msg := &icmp.Message{
|
||||
Type: family.echoType,
|
||||
Code: 0,
|
||||
Body: &icmp.Echo{
|
||||
ID: os.Getpid() & 0xffff,
|
||||
Seq: 1,
|
||||
Data: []byte("beszel-probe"),
|
||||
},
|
||||
}
|
||||
msgBytes, err := msg.Marshal(nil)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
// Set deadline before sending
|
||||
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
// Read reply
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
if reply.Type == family.replyType {
|
||||
return time.Since(start).Microseconds(), nil
|
||||
}
|
||||
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
||||
}
|
||||
}
|
||||
|
||||
// probeICMPExec falls back to the system ping command. Returns -1 and an error on failure.
|
||||
func probeICMPExec(target string, isIPv6 bool) (int64, error) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if isIPv6 {
|
||||
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
|
||||
} else {
|
||||
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
||||
}
|
||||
default:
|
||||
if isIPv6 {
|
||||
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
||||
} else {
|
||||
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// If ping fails but we got output, still try to parse
|
||||
if len(output) == 0 {
|
||||
return -1, err
|
||||
}
|
||||
}
|
||||
|
||||
matches := pingTimeRegex.FindSubmatch(output)
|
||||
if len(matches) >= 2 {
|
||||
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
||||
return int64(math.Round(ms * 1000)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use wall clock time if ping succeeded but parsing failed
|
||||
if err == nil {
|
||||
return time.Since(start).Microseconds(), nil
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
121
agent/probe_ping_test.go
Normal file
121
agent/probe_ping_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testICMPPacketConn struct{}
|
||||
|
||||
func (testICMPPacketConn) Close() error { return nil }
|
||||
|
||||
func TestDetectICMPMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
family *icmpFamily
|
||||
rawErr error
|
||||
udpErr error
|
||||
want icmpMethod
|
||||
wantNetworks []string
|
||||
}{
|
||||
{
|
||||
name: "IPv4 prefers raw socket when available",
|
||||
family: &icmpV4,
|
||||
want: icmpRaw,
|
||||
wantNetworks: []string{"ip4:icmp"},
|
||||
},
|
||||
{
|
||||
name: "IPv4 uses datagram when raw unavailable",
|
||||
family: &icmpV4,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
want: icmpDatagram,
|
||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||
},
|
||||
{
|
||||
name: "IPv4 falls back to exec when both unavailable",
|
||||
family: &icmpV4,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
udpErr: errors.New("protocol not supported"),
|
||||
want: icmpExecFallback,
|
||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||
},
|
||||
{
|
||||
name: "IPv6 prefers raw socket when available",
|
||||
family: &icmpV6,
|
||||
want: icmpRaw,
|
||||
wantNetworks: []string{"ip6:ipv6-icmp"},
|
||||
},
|
||||
{
|
||||
name: "IPv6 uses datagram when raw unavailable",
|
||||
family: &icmpV6,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
want: icmpDatagram,
|
||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||
},
|
||||
{
|
||||
name: "IPv6 falls back to exec when both unavailable",
|
||||
family: &icmpV6,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
udpErr: errors.New("protocol not supported"),
|
||||
want: icmpExecFallback,
|
||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
calls := make([]string, 0, 2)
|
||||
listen := func(network, listenAddr string) (icmpPacketConn, error) {
|
||||
require.Equal(t, tt.family.listenAddr, listenAddr)
|
||||
calls = append(calls, network)
|
||||
switch network {
|
||||
case tt.family.rawNetwork:
|
||||
if tt.rawErr != nil {
|
||||
return nil, tt.rawErr
|
||||
}
|
||||
case tt.family.dgramNetwork:
|
||||
if tt.udpErr != nil {
|
||||
return nil, tt.udpErr
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected network %q", network)
|
||||
}
|
||||
return testICMPPacketConn{}, nil
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
|
||||
assert.Equal(t, tt.wantNetworks, calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveICMPTarget(t *testing.T) {
|
||||
t.Run("IPv4 literal", func(t *testing.T) {
|
||||
family, ip, err := resolveICMPTarget("127.0.0.1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, family)
|
||||
assert.False(t, family.isIPv6)
|
||||
assert.Equal(t, "127.0.0.1", ip.String())
|
||||
})
|
||||
|
||||
t.Run("IPv6 literal", func(t *testing.T) {
|
||||
family, ip, err := resolveICMPTarget("::1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, family)
|
||||
assert.True(t, family.isIPv6)
|
||||
assert.Equal(t, "::1", ip.String())
|
||||
})
|
||||
|
||||
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
||||
family, ip, err := resolveICMPTarget("::ffff:127.0.0.1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, family)
|
||||
assert.False(t, family.isIPv6)
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1118,6 +1118,9 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
smartData.SerialNumber = data.SerialNumber
|
||||
smartData.FirmwareVersion = data.FirmwareVersion
|
||||
smartData.Capacity = data.UserCapacity.Bytes
|
||||
if smartData.Capacity == 0 {
|
||||
smartData.Capacity = data.NVMeTotalCapacity
|
||||
}
|
||||
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
||||
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package utils provides utility functions for the agent.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
@@ -68,6 +70,9 @@ func ReadStringFileLimited(path string, maxSize int) (string, error) {
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
if n < 0 {
|
||||
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
|
||||
}
|
||||
return strings.TrimSpace(string(buf[:n])), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
||||
|
||||
const (
|
||||
// Version is the current version of the application.
|
||||
Version = "0.18.6"
|
||||
Version = "0.18.7"
|
||||
// AppName is the name of the application.
|
||||
AppName = "beszel"
|
||||
)
|
||||
|
||||
4
go.mod
4
go.mod
@@ -10,7 +10,7 @@ require (
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lxzan/gws v1.9.1
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.1
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.3
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/pocketbase v0.36.8
|
||||
github.com/shirou/gopsutil/v4 v4.26.3
|
||||
@@ -20,6 +20,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sys v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
howett.net/plist v1.0.1
|
||||
@@ -56,7 +57,6 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/image v0.38.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -85,8 +85,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.1 h1:6sx4cJNfNuUtD6ygGlB0dqcCQ+abfsUh+b+6jgujf6A=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.1/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
|
||||
@@ -302,21 +302,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
err := e.BindBody(&data)
|
||||
if err != nil || data.URL == "" {
|
||||
return e.BadRequestError("URL is required", err)
|
||||
}
|
||||
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||
if err != nil {
|
||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||
}
|
||||
return e.JSON(200, map[string]bool{"err": false})
|
||||
}
|
||||
|
||||
// setAlertTriggered updates the "triggered" status of an alert record in the database
|
||||
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
|
||||
|
||||
@@ -3,7 +3,11 @@ package alerts
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -117,3 +121,72 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||
}
|
||||
|
||||
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
|
||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
err := e.BindBody(&data)
|
||||
if err != nil || data.URL == "" {
|
||||
return e.BadRequestError("URL is required", err)
|
||||
}
|
||||
// Only allow admins to send test notifications to internal URLs
|
||||
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
||||
internalURL, err := isInternalURL(data.URL)
|
||||
if err != nil {
|
||||
return e.BadRequestError(err.Error(), nil)
|
||||
}
|
||||
if internalURL {
|
||||
return e.ForbiddenError("Only admins can send to internal destinations", nil)
|
||||
}
|
||||
}
|
||||
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||
if err != nil {
|
||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||
}
|
||||
return e.JSON(200, map[string]bool{"err": false})
|
||||
}
|
||||
|
||||
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
|
||||
func isInternalURL(rawURL string) (bool, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return isInternalIP(ip), nil
|
||||
}
|
||||
|
||||
// Some Shoutrrr URLs use the host position for service identifiers rather than a
|
||||
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
|
||||
// to names that look like actual hostnames so valid service URLs keep working.
|
||||
if !strings.Contains(host, ".") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(ips, isInternalIP) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isInternalIP(ip net.IP) bool {
|
||||
return ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
501
internal/alerts/alerts_api_test.go
Normal file
501
internal/alerts/alerts_api_test.go
Normal file
@@ -0,0 +1,501 @@
|
||||
//go:build testing
|
||||
|
||||
package alerts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||
func jsonReader(v any) io.Reader {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
func TestIsInternalURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
internal bool
|
||||
}{
|
||||
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
|
||||
{name: "localhost hostname", url: "generic://localhost", internal: true},
|
||||
{name: "localhost hostname", url: "generic+http://localhost/api/v1/postStuff", internal: true},
|
||||
{name: "localhost hostname", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
|
||||
{name: "localhost hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
|
||||
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
|
||||
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
|
||||
{name: "single label service url", url: "slack://token@team/channel", internal: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
internal, err := alerts.IsInternalURL(testCase.url)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.internal, internal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAlertsApi(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||
user1Token, _ := user1.NewAuthToken()
|
||||
|
||||
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||
user2Token, _ := user2.NewAuthToken()
|
||||
|
||||
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system1",
|
||||
"users": []string{user1.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
|
||||
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system2",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
"host": "127.0.0.2",
|
||||
})
|
||||
|
||||
userRecords, _ := hub.CountRecords("users")
|
||||
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||
|
||||
systemRecords, _ := hub.CountRecords("systems")
|
||||
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
// {
|
||||
// Name: "GET not implemented - returns index",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/beszel/user-alerts",
|
||||
// ExpectedStatus: 200,
|
||||
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||
// TestAppFactory: testAppFactory,
|
||||
// },
|
||||
{
|
||||
Name: "POST no auth",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST no body",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST bad data",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"invalidField": "this should cause validation error",
|
||||
"threshold": "not a number",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST malformed JSON",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data multiple systems",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 69,
|
||||
"min": 9,
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
// check total alerts
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
// check alert has correct values
|
||||
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data single system",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id},
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: false, should not overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system1.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: true, should overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system2.Id},
|
||||
"overwrite": true,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user2.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE no auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert multiple systems",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "Memory",
|
||||
"system": systemId,
|
||||
"user": user1.Id,
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
})
|
||||
assert.NoError(t, err, "should create alert")
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "User 2 should not be able to delete alert of user 1",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, user := range []string{user1.Id, user2.Id} {
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
func TestSendTestNotification(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
userToken, err := user.NewAuthToken()
|
||||
|
||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||
assert.NoError(t, err, "Failed to create admin user")
|
||||
adminUserToken, err := adminUser.NewAuthToken()
|
||||
|
||||
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
||||
assert.NoError(t, err, "Failed to create superuser")
|
||||
superuserToken, err := superuser.NewAuthToken()
|
||||
assert.NoError(t, err, "Failed to create superuser auth token")
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
{
|
||||
Name: "POST /test-notification - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - with external auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://8.8.8.8",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"err\":"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - local url with user auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://localhost:8010",
|
||||
}),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Only admins"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - internal url with user auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic+http://192.168.0.5",
|
||||
}),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Only admins"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - internal url with admin auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": adminUserToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"err\":"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": superuserToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"err\":"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,6 @@
|
||||
package alerts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
@@ -16,359 +11,9 @@ import (
|
||||
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||
func jsonReader(v any) io.Reader {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
func TestUserAlertsApi(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||
user1Token, _ := user1.NewAuthToken()
|
||||
|
||||
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||
user2Token, _ := user2.NewAuthToken()
|
||||
|
||||
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system1",
|
||||
"users": []string{user1.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
|
||||
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system2",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
"host": "127.0.0.2",
|
||||
})
|
||||
|
||||
userRecords, _ := hub.CountRecords("users")
|
||||
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||
|
||||
systemRecords, _ := hub.CountRecords("systems")
|
||||
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
// {
|
||||
// Name: "GET not implemented - returns index",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/beszel/user-alerts",
|
||||
// ExpectedStatus: 200,
|
||||
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||
// TestAppFactory: testAppFactory,
|
||||
// },
|
||||
{
|
||||
Name: "POST no auth",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST no body",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST bad data",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"invalidField": "this should cause validation error",
|
||||
"threshold": "not a number",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST malformed JSON",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data multiple systems",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 69,
|
||||
"min": 9,
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
// check total alerts
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
// check alert has correct values
|
||||
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data single system",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id},
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: false, should not overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system1.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: true, should overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system2.Id},
|
||||
"overwrite": true,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user2.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE no auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert multiple systems",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "Memory",
|
||||
"system": systemId,
|
||||
"user": user1.Id,
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
})
|
||||
assert.NoError(t, err, "should create alert")
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "User 2 should not be able to delete alert of user 1",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, user := range []string{user1.Id, user2.Id} {
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsHistory(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
@@ -95,3 +95,7 @@ func (am *AlertManager) RestorePendingStatusAlerts() error {
|
||||
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||
return am.setAlertTriggered(alert, triggered)
|
||||
}
|
||||
|
||||
func IsInternalURL(rawURL string) (bool, error) {
|
||||
return isInternalURL(rawURL)
|
||||
}
|
||||
|
||||
@@ -195,6 +195,6 @@ func main() {
|
||||
}
|
||||
|
||||
if err := a.Start(serverConfig); err != nil {
|
||||
log.Fatal("Failed to start server: ", err)
|
||||
log.Fatal("Failed to start: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const (
|
||||
GetSmartData
|
||||
// Request detailed systemd service info from agent
|
||||
GetSystemdInfo
|
||||
// Sync network probe configuration to agent
|
||||
SyncNetworkProbes
|
||||
// Add new actions here...
|
||||
)
|
||||
|
||||
|
||||
83
internal/entities/probe/probe.go
Normal file
83
internal/entities/probe/probe.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package probe
|
||||
|
||||
type SyncAction uint8
|
||||
|
||||
const (
|
||||
// SyncActionReplace indicates a full sync where the provided configs should replace all existing probes for the system.
|
||||
SyncActionReplace SyncAction = iota
|
||||
// SyncActionUpsert indicates an incremental sync where the provided config should be added or updated.
|
||||
SyncActionUpsert
|
||||
// SyncActionDelete indicates an incremental sync where the provided config should be removed.
|
||||
SyncActionDelete
|
||||
)
|
||||
|
||||
// Config defines a network probe task sent from hub to agent.
|
||||
type Config struct {
|
||||
// ID is the stable network_probes record ID generated by the hub.
|
||||
ID string `cbor:"0,keyasint"`
|
||||
Target string `cbor:"1,keyasint"`
|
||||
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", or "http"
|
||||
Port uint16 `cbor:"3,keyasint,omitempty"`
|
||||
Interval uint16 `cbor:"4,keyasint"` // seconds
|
||||
}
|
||||
|
||||
// SyncRequest defines an incremental or full probe sync request sent to the agent.
|
||||
type SyncRequest struct {
|
||||
Action SyncAction `cbor:"0,keyasint"`
|
||||
Config Config `cbor:"1,keyasint,omitempty"`
|
||||
Configs []Config `cbor:"2,keyasint,omitempty"`
|
||||
RunNow bool `cbor:"3,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// SyncResponse returns the immediate result for an upsert when requested.
|
||||
type SyncResponse struct {
|
||||
Result Result `cbor:"0,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
// Result holds aggregated probe results for a single target.
|
||||
//
|
||||
// 0: avg response in microseconds
|
||||
//
|
||||
// 1: 1h average response in microseconds
|
||||
//
|
||||
// 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)
|
||||
type Stats []float64
|
||||
|
||||
func (s Stats) FromResult(result Result) Stats {
|
||||
return Stats{
|
||||
float64(result.AvgResponse),
|
||||
float64(result.MinResponse),
|
||||
float64(result.MaxResponse),
|
||||
result.PacketLoss,
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
||||
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
|
||||
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
||||
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
||||
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
|
||||
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
||||
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||
)
|
||||
|
||||
@@ -174,9 +175,10 @@ type Details struct {
|
||||
|
||||
// Final data structure to return to the hub
|
||||
type CombinedData struct {
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
Probes map[string]probe.Result `cbor:"5,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/henrygd/beszel/internal/ghupdate"
|
||||
"github.com/henrygd/beszel/internal/hub/config"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -70,13 +71,13 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||
return e.Next()
|
||||
}
|
||||
// authenticate with trusted header
|
||||
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
||||
if autoLogin, _ := utils.GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
return authorizeRequestWithEmail(e, autoLogin)
|
||||
})
|
||||
}
|
||||
// authenticate with trusted header
|
||||
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
||||
})
|
||||
@@ -104,7 +105,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
apiAuth.GET("/info", h.getInfo)
|
||||
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
|
||||
// check for updates
|
||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
var updateInfo UpdateInfo
|
||||
apiAuth.GET("/update", updateInfo.getUpdate)
|
||||
}
|
||||
@@ -127,7 +128,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
// get systemd service details
|
||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||
// /containers routes
|
||||
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||
// get container logs
|
||||
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
||||
// get container info
|
||||
@@ -147,7 +148,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error {
|
||||
Key: h.pubKey,
|
||||
Version: beszel.Version,
|
||||
}
|
||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
info.CheckUpdate = true
|
||||
}
|
||||
return e.JSON(http.StatusOK, info)
|
||||
@@ -315,7 +316,7 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
|
||||
@@ -350,7 +351,7 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Invalid system or service parameter", nil)
|
||||
}
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
// verify service exists before fetching details
|
||||
@@ -378,7 +379,7 @@ func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,31 +66,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
// Auth Protected Routes - Should require authentication
|
||||
{
|
||||
Name: "POST /test-notification - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - with auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"sending message"},
|
||||
},
|
||||
{
|
||||
Name: "GET /config-yaml - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
@@ -369,6 +344,23 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - SHARE_ALL_SYSTEMS allows non-member user",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
t.Setenv("SHARE_ALL_SYSTEMS", "true")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
t.Setenv("SHARE_ALL_SYSTEMS", "")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - with auth but missing system param should fail",
|
||||
Method: http.MethodGet,
|
||||
@@ -630,13 +622,17 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"systems": []string{system.Id},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/update",
|
||||
ExpectedStatus: 502,
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
// this works but diff behavior on prod vs dev.
|
||||
// dev returns 502; prod returns 200 with static html page 404
|
||||
// TODO: align dev and prod behavior and re-enable this test
|
||||
// {
|
||||
// Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/beszel/update",
|
||||
// NotExpectedContent: []string{"v:", "\"v\":"},
|
||||
// ExpectedStatus: 502,
|
||||
// TestAppFactory: testAppFactory,
|
||||
// },
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package hub
|
||||
|
||||
import "github.com/pocketbase/pocketbase/core"
|
||||
import (
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
type collectionRules struct {
|
||||
list *string
|
||||
@@ -22,11 +25,11 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
}
|
||||
|
||||
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
||||
disablePasswordAuth, _ := utils.GetEnv("DISABLE_PASSWORD_AUTH")
|
||||
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
||||
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||
// allow oauth user creation if USER_CREATION is set
|
||||
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
||||
if userCreation, _ := utils.GetEnv("USER_CREATION"); userCreation == "true" {
|
||||
cr := "@request.context = 'oauth2'"
|
||||
usersCollection.CreateRule = &cr
|
||||
} else {
|
||||
@@ -34,7 +37,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
}
|
||||
|
||||
// enable mfaOtp mfa if MFA_OTP env var is set
|
||||
mfaOtp, _ := GetEnv("MFA_OTP")
|
||||
mfaOtp, _ := utils.GetEnv("MFA_OTP")
|
||||
usersCollection.OTP.Length = 6
|
||||
superusersCollection.OTP.Length = 6
|
||||
usersCollection.OTP.Enabled = mfaOtp == "true"
|
||||
@@ -50,7 +53,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
|
||||
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
|
||||
// system-scoped data. Write rules continue to block readonly users.
|
||||
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
||||
shareAllSystems, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
|
||||
|
||||
authenticatedRule := "@request.auth.id != \"\""
|
||||
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"
|
||||
@@ -75,7 +78,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{
|
||||
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services", "network_probe_stats"}, collectionRules{
|
||||
list: &systemScopedReadRule,
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -89,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
|
||||
if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{
|
||||
list: &systemScopedReadRule,
|
||||
view: &systemScopedReadRule,
|
||||
create: &systemScopedWriteRule,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/henrygd/beszel/internal/hub/config"
|
||||
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/users"
|
||||
|
||||
@@ -44,7 +45,7 @@ func NewHub(app core.App) *Hub {
|
||||
hub.um = users.NewUserManager(hub)
|
||||
hub.rm = records.NewRecordManager(hub)
|
||||
hub.sm = systems.NewSystemManager(hub)
|
||||
hub.hb = heartbeat.New(app, GetEnv)
|
||||
hub.hb = heartbeat.New(app, utils.GetEnv)
|
||||
if hub.hb != nil {
|
||||
hub.hbStop = make(chan struct{})
|
||||
}
|
||||
@@ -52,15 +53,6 @@ func NewHub(app core.App) *Hub {
|
||||
return hub
|
||||
}
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
// Fallback to the old unprefixed key
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
// onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied.
|
||||
// This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose.
|
||||
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
|
||||
@@ -89,6 +81,7 @@ func (h *Hub) StartHub() error {
|
||||
}
|
||||
// register middlewares
|
||||
h.registerMiddlewares(e)
|
||||
// bind events that aren't set up in different
|
||||
// register api routes
|
||||
if err := h.registerApiRoutes(e); err != nil {
|
||||
return err
|
||||
@@ -117,6 +110,8 @@ func (h *Hub) StartHub() error {
|
||||
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||
|
||||
bindNetworkProbesEvents(h)
|
||||
|
||||
pb, ok := h.App.(*pocketbase.PocketBase)
|
||||
if !ok {
|
||||
return errors.New("not a pocketbase app")
|
||||
@@ -131,7 +126,7 @@ func (h *Hub) initialize(app core.App) error {
|
||||
// batch requests (for alerts)
|
||||
settings.Batch.Enabled = true
|
||||
// set URL if APP_URL env is set
|
||||
if appURL, isSet := GetEnv("APP_URL"); isSet {
|
||||
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
|
||||
h.appURL = appURL
|
||||
settings.Meta.AppURL = appURL
|
||||
}
|
||||
|
||||
155
internal/hub/probes.go
Normal file
155
internal/hub/probes.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// generateProbeID creates a stable hash ID for a probe based on its configuration and the system it belongs to.
|
||||
func generateProbeID(systemId string, config probe.Config) string {
|
||||
args := []string{systemId, config.Target, config.Protocol}
|
||||
// only use port for TCP probes, since for other protocols it's not relevant as standalone value
|
||||
if config.Protocol == "tcp" {
|
||||
args = append(args, strconv.FormatUint(uint64(config.Port), 10))
|
||||
}
|
||||
return systems.MakeStableHashId(args...)
|
||||
}
|
||||
|
||||
// bindNetworkProbesEvents keeps probe records and agent probe state in sync.
|
||||
func bindNetworkProbesEvents(hub *Hub) {
|
||||
// on create, make sure the id is set to a stable hash
|
||||
hub.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||
systemID := e.Record.GetString("system")
|
||||
config := probeConfigFromRecord(e.Record)
|
||||
id := generateProbeID(systemID, *config)
|
||||
e.Record.Set("id", id)
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
// sync probe to agent on creation and persist the first result immediately when available
|
||||
hub.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
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
|
||||
hub.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||
if err := hub.deleteNetworkProbe(e.Record); err != nil {
|
||||
hub.Logger().Warn("failed to delete probe on agent", "system", e.Record.GetString("system"), "probe", e.Record.Id, "err", err)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
}
|
||||
|
||||
// probeConfigFromRecord builds a probe config from a network_probes record.
|
||||
func probeConfigFromRecord(record *core.Record) *probe.Config {
|
||||
return &probe.Config{
|
||||
ID: record.Id,
|
||||
Target: record.GetString("target"),
|
||||
Protocol: record.GetString("protocol"),
|
||||
Port: uint16(record.GetInt("port")),
|
||||
Interval: uint16(record.GetInt("interval")),
|
||||
}
|
||||
}
|
||||
|
||||
// setProbeResultFields stores the latest probe result values on the record.
|
||||
func setProbeResultFields(record *core.Record, result probe.Result) {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := system.UpsertNetworkProbe(*probeConfigFromRecord(record), runNow)
|
||||
if err != nil || result == nil {
|
||||
return 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"))
|
||||
}
|
||||
42
internal/hub/server.go
Normal file
42
internal/hub/server.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
)
|
||||
|
||||
// PublicAppInfo defines the structure of the public app information that will be injected into the HTML
|
||||
type PublicAppInfo struct {
|
||||
BASE_PATH string
|
||||
HUB_VERSION string
|
||||
HUB_URL string
|
||||
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"`
|
||||
}
|
||||
|
||||
// modifyIndexHTML injects the public app information into the index.html content
|
||||
func modifyIndexHTML(hub *Hub, html []byte) string {
|
||||
info := getPublicAppInfo(hub)
|
||||
content, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return string(html)
|
||||
}
|
||||
htmlContent := strings.ReplaceAll(string(html), "./", info.BASE_PATH)
|
||||
return strings.Replace(htmlContent, "\"{info}\"", string(content), 1)
|
||||
}
|
||||
|
||||
func getPublicAppInfo(hub *Hub) PublicAppInfo {
|
||||
parsedURL, _ := url.Parse(hub.appURL)
|
||||
info := PublicAppInfo{
|
||||
BASE_PATH: strings.TrimSuffix(parsedURL.Path, "/") + "/",
|
||||
HUB_VERSION: beszel.Version,
|
||||
HUB_URL: hub.appURL,
|
||||
}
|
||||
if val, _ := utils.GetEnv("OAUTH_DISABLE_POPUP"); val == "true" {
|
||||
info.OAUTH_DISABLE_POPUP = true
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
)
|
||||
@@ -38,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
resp.Body.Close()
|
||||
// Create a new response with the modified body
|
||||
modifiedBody := rm.modifyHTML(string(body))
|
||||
modifiedBody := modifyIndexHTML(rm.hub, body)
|
||||
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||
resp.ContentLength = int64(len(modifiedBody))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||
@@ -46,19 +44,6 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (rm *responseModifier) modifyHTML(html string) string {
|
||||
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||
if err != nil {
|
||||
return html
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
html = strings.ReplaceAll(html, "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||
return html
|
||||
}
|
||||
|
||||
// startServer sets up the development server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||
|
||||
@@ -5,10 +5,9 @@ package hub
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/henrygd/beszel/internal/site"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -17,22 +16,13 @@ import (
|
||||
|
||||
// startServer sets up the production server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
// parse app url
|
||||
parsedURL, err := url.Parse(h.appURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
||||
html := modifyIndexHTML(h, indexFile)
|
||||
// set up static asset serving
|
||||
staticPaths := [2]string{"/static/", "/assets/"}
|
||||
serveStatic := apis.Static(site.DistDirFS, false)
|
||||
// get CSP configuration
|
||||
csp, cspExists := GetEnv("CSP")
|
||||
csp, cspExists := utils.GetEnv("CSP")
|
||||
// add route
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
// serve static assets if path is in staticPaths
|
||||
|
||||
@@ -8,16 +8,17 @@ import (
|
||||
"hash/fnv"
|
||||
"math/rand"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/hub/transport"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/henrygd/beszel/internal/hub/ws"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||
@@ -29,6 +30,8 @@ import (
|
||||
"github.com/lxzan/gws"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -238,6 +241,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
||||
}
|
||||
}
|
||||
|
||||
if data.Probes != nil {
|
||||
if err := updateNetworkProbesRecords(txApp, data.Probes, sys.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||
systemRecord.Set("status", up)
|
||||
systemRecord.Set("info", data.Info)
|
||||
@@ -289,7 +298,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
||||
for i, service := range data {
|
||||
suffix := fmt.Sprintf("%d", i)
|
||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
||||
params["id"+suffix] = MakeStableHashId(systemId, service.Name)
|
||||
params["name"+suffix] = service.Name
|
||||
params["state"+suffix] = service.State
|
||||
params["sub"+suffix] = service.Sub
|
||||
@@ -306,6 +315,97 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
||||
return err
|
||||
}
|
||||
|
||||
func updateNetworkProbesRecords(app core.App, probeResults map[string]probe.Result, systemId string) error {
|
||||
if len(probeResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
const probeCollectionName = "network_probes"
|
||||
|
||||
// If realtime updates are active, we save via PocketBase records to trigger realtime events.
|
||||
// Otherwise we can do a more efficient direct update via SQL
|
||||
realtimeActive := utils.RealtimeActiveForCollection(app, probeCollectionName, func(filterQuery string) bool {
|
||||
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 updateQuery *dbx.Query
|
||||
if !realtimeActive {
|
||||
db = app.DB()
|
||||
probeFields := []string{"res", "resMin1h", "resMax1h", "resAvg1h", "loss1h", "updated"}
|
||||
setClauses := make([]string, len(probeFields))
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
case true:
|
||||
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.Load(statsRecordData)
|
||||
err = app.SaveNoValidate(record)
|
||||
default:
|
||||
statsRecordData["id"] = security.PseudorandomStringWithAlphabet(10, core.DefaultIdAlphabet)
|
||||
_, err = db.Insert(statsCollectionName, dbx.Params(statsRecordData)).Execute()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createContainerRecords creates container records
|
||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||
if len(data) == 0 {
|
||||
@@ -353,14 +453,25 @@ func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// HasUser checks if the given user ID is in the system's users list.
|
||||
func (sys *System) HasUser(app core.App, userID string) bool {
|
||||
record, err := sys.getRecord(app)
|
||||
if err != nil {
|
||||
// HasUser checks if the given user is in the system's users list.
|
||||
// Returns true if SHARE_ALL_SYSTEMS is enabled (any authenticated user can access any system).
|
||||
func (sys *System) HasUser(app core.App, user *core.Record) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
users := record.GetStringSlice("users")
|
||||
return slices.Contains(users, userID)
|
||||
if v, _ := utils.GetEnv("SHARE_ALL_SYSTEMS"); v == "true" {
|
||||
return true
|
||||
}
|
||||
var recordData = struct {
|
||||
Users string
|
||||
}{}
|
||||
err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
|
||||
Bind(dbx.Params{"id": sys.Id}).
|
||||
One(&recordData)
|
||||
if err != nil || recordData.Users == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(recordData.Users, user.Id)
|
||||
}
|
||||
|
||||
// setDown marks a system as down in the database.
|
||||
@@ -529,7 +640,7 @@ func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func makeStableHashId(strings ...string) string {
|
||||
func MakeStableHashId(strings ...string) string {
|
||||
hash := fnv.New32a()
|
||||
for _, str := range strings {
|
||||
hash.Write([]byte(str))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/henrygd/beszel/internal/hub/ws"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -317,6 +319,17 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
||||
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sync network probes to the newly connected agent
|
||||
go func() {
|
||||
configs := sm.GetProbeConfigsForSystem(systemId)
|
||||
if len(configs) > 0 {
|
||||
if err := system.SyncNetworkProbes(configs); err != nil {
|
||||
sm.hub.Logger().Warn("failed to sync probes to agent", "system", systemId, "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -329,6 +342,16 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
||||
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
||||
var configs []probe.Config
|
||||
_ = sm.hub.DB().
|
||||
NewQuery("SELECT id, target, protocol, port, interval FROM network_probes WHERE system = {:system} AND enabled = true").
|
||||
Bind(dbx.Params{"system": systemID}).
|
||||
All(&configs)
|
||||
return configs
|
||||
}
|
||||
|
||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||
func (sm *SystemManager) createSSHClientConfig() error {
|
||||
privateKey, err := sm.hub.GetSSHKey("")
|
||||
|
||||
48
internal/hub/systems/system_probes.go
Normal file
48
internal/hub/systems/system_probes.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
)
|
||||
|
||||
// SyncNetworkProbes sends probe configurations to the agent.
|
||||
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
||||
_, err := sys.syncNetworkProbes(probe.SyncRequest{Action: probe.SyncActionReplace, Configs: configs})
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertNetworkProbe sends a single probe configuration change to the agent.
|
||||
func (sys *System) UpsertNetworkProbe(config probe.Config, runNow bool) (*probe.Result, error) {
|
||||
resp, err := sys.syncNetworkProbes(probe.SyncRequest{
|
||||
Action: probe.SyncActionUpsert,
|
||||
Config: config,
|
||||
RunNow: runNow,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Result == (probe.Result{}) {
|
||||
return nil, nil
|
||||
}
|
||||
result := resp.Result
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteNetworkProbe removes a single probe task from the agent.
|
||||
func (sys *System) DeleteNetworkProbe(id string) error {
|
||||
_, err := sys.syncNetworkProbes(probe.SyncRequest{
|
||||
Action: probe.SyncActionDelete,
|
||||
Config: probe.Config{ID: id},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (sys *System) syncNetworkProbes(req probe.SyncRequest) (probe.SyncResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var result probe.SyncResponse
|
||||
return result, sys.request(ctx, common.SyncNetworkProbes, req, &result)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error
|
||||
|
||||
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
||||
hub := sys.manager.hub
|
||||
recordID := makeStableHashId(sys.Id, deviceKey)
|
||||
recordID := MakeStableHashId(sys.Id, deviceKey)
|
||||
|
||||
record, err := hub.FindRecordById(collection, recordID)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
serviceName := "nginx.service"
|
||||
|
||||
// Call multiple times and ensure same result
|
||||
id1 := makeStableHashId(systemId, serviceName)
|
||||
id2 := makeStableHashId(systemId, serviceName)
|
||||
id3 := makeStableHashId(systemId, serviceName)
|
||||
id1 := MakeStableHashId(systemId, serviceName)
|
||||
id2 := MakeStableHashId(systemId, serviceName)
|
||||
id3 := MakeStableHashId(systemId, serviceName)
|
||||
|
||||
assert.Equal(t, id1, id2)
|
||||
assert.Equal(t, id2, id3)
|
||||
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
serviceName1 := "nginx.service"
|
||||
serviceName2 := "apache.service"
|
||||
|
||||
id1 := makeStableHashId(systemId1, serviceName1)
|
||||
id2 := makeStableHashId(systemId2, serviceName1)
|
||||
id3 := makeStableHashId(systemId1, serviceName2)
|
||||
id4 := makeStableHashId(systemId2, serviceName2)
|
||||
id1 := MakeStableHashId(systemId1, serviceName1)
|
||||
id2 := MakeStableHashId(systemId2, serviceName1)
|
||||
id3 := MakeStableHashId(systemId1, serviceName2)
|
||||
id4 := MakeStableHashId(systemId2, serviceName2)
|
||||
|
||||
// All IDs should be different
|
||||
assert.NotEqual(t, id1, id2)
|
||||
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
id := makeStableHashId(tc.systemId, tc.serviceName)
|
||||
id := MakeStableHashId(tc.systemId, tc.serviceName)
|
||||
// FNV-32 produces 8 hex characters
|
||||
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hexadecimal output", func(t *testing.T) {
|
||||
id := makeStableHashId("test-system", "test-service")
|
||||
id := MakeStableHashId("test-system", "test-service")
|
||||
assert.NotEmpty(t, id)
|
||||
|
||||
// Should only contain hexadecimal characters
|
||||
|
||||
@@ -421,3 +421,60 @@ func testOld(t *testing.T, hub *tests.TestHub) {
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasUser(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
sm := hub.GetSystemManager()
|
||||
err = sm.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
user1, err := tests.CreateUser(hub, "user1@test.com", "password123")
|
||||
require.NoError(t, err)
|
||||
user2, err := tests.CreateUser(hub, "user2@test.com", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
systemRecord, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "has-user-test",
|
||||
"host": "127.0.0.1",
|
||||
"port": "33914",
|
||||
"users": []string{user1.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sys, err := sm.GetSystemFromStore(systemRecord.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("user in list returns true", func(t *testing.T) {
|
||||
assert.True(t, sys.HasUser(hub, user1))
|
||||
})
|
||||
|
||||
t.Run("user not in list returns false", func(t *testing.T) {
|
||||
assert.False(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
|
||||
t.Run("unknown user ID returns false", func(t *testing.T) {
|
||||
assert.False(t, sys.HasUser(hub, nil))
|
||||
})
|
||||
|
||||
t.Run("SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
|
||||
t.Setenv("SHARE_ALL_SYSTEMS", "true")
|
||||
assert.True(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
|
||||
t.Run("BESZEL_HUB_SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
|
||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||
assert.True(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
|
||||
t.Run("additional user works", func(t *testing.T) {
|
||||
assert.False(t, sys.HasUser(hub, user2))
|
||||
systemRecord.Set("users", []string{user1.Id, user2.Id})
|
||||
err = hub.Save(systemRecord)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sys.HasUser(hub, user1))
|
||||
assert.True(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
}
|
||||
|
||||
39
internal/hub/utils/utils.go
Normal file
39
internal/hub/utils/utils.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Package utils provides utility functions for the hub.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
// realtimeActiveForCollection checks if there are active WebSocket subscriptions for the given collection.
|
||||
func RealtimeActiveForCollection(app core.App, collectionName string, validateFn func(filterQuery string) bool) bool {
|
||||
broker := app.SubscriptionsBroker()
|
||||
if broker.TotalClients() == 0 {
|
||||
return false
|
||||
}
|
||||
for _, client := range broker.Clients() {
|
||||
subs := client.Subscriptions(collectionName)
|
||||
if len(subs) > 0 {
|
||||
if validateFn == nil {
|
||||
return true
|
||||
}
|
||||
for k := range subs {
|
||||
filter := subs[k].Query["filter"]
|
||||
if validateFn(filter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1699,6 +1699,288 @@ func init() {
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
},
|
||||
{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{10}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 10,
|
||||
"min": 6,
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "np_name",
|
||||
"max": 200,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"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": "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,
|
||||
"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": "date3332085495",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "updated",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
}
|
||||
],
|
||||
"id": "np_probes_001",
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "network_probes",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
},
|
||||
{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{10}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 10,
|
||||
"min": 10,
|
||||
"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": "number2990389176",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "created",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"id": "np_stats_001",
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "network_probe_stats",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
}
|
||||
]`
|
||||
|
||||
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
|
||||
}
|
||||
@@ -3,17 +3,17 @@ package records
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
type RecordManager struct {
|
||||
@@ -39,19 +39,9 @@ type StatsRecord struct {
|
||||
Stats []byte `db:"stats"`
|
||||
}
|
||||
|
||||
// global variables for reusing allocations
|
||||
var (
|
||||
statsRecord StatsRecord
|
||||
containerStats []container.Stats
|
||||
sumStats system.Stats
|
||||
tempStats system.Stats
|
||||
queryParams = make(dbx.Params, 1)
|
||||
containerSums = make(map[string]*container.Stats)
|
||||
)
|
||||
|
||||
// Create longer records by averaging shorter records
|
||||
func (rm *RecordManager) CreateLongerRecords() {
|
||||
// start := time.Now()
|
||||
now := time.Now().UTC()
|
||||
longerRecordData := []LongerRecordData{
|
||||
{
|
||||
shorterType: "1m",
|
||||
@@ -82,7 +72,8 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
// wrap the operations in a transaction
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
var err error
|
||||
collections := [2]*core.Collection{}
|
||||
|
||||
collections := [3]*core.Collection{}
|
||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -91,6 +82,10 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collections[2], err = txApp.FindCachedCollectionByNameOrId("network_probe_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var systems RecordIds
|
||||
db := txApp.DB()
|
||||
|
||||
@@ -103,55 +98,71 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
recordData := longerRecordData[i]
|
||||
// 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
|
||||
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
|
||||
shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration)
|
||||
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
|
||||
// loop through both collections
|
||||
for _, collection := range collections {
|
||||
// check creation time of last longer record if not 10m, since 10m is created every run
|
||||
if recordData.longerType != "10m" {
|
||||
count, err := txApp.CountRecords(
|
||||
collection.Id,
|
||||
dbx.NewExp(
|
||||
"system = {:system} AND type = {:type} AND created > {:created}",
|
||||
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
|
||||
),
|
||||
)
|
||||
var existingRecord struct {
|
||||
Id string
|
||||
}
|
||||
|
||||
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
|
||||
if err != nil || count > 0 {
|
||||
if existingRecord.Id != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// get shorter records from the past x minutes
|
||||
var recordIds RecordIds
|
||||
|
||||
err := txApp.DB().
|
||||
params := dbx.Params{
|
||||
"type": recordData.shorterType,
|
||||
"system": system.Id,
|
||||
"created": getCreatedTimeField(collection.Name, shorterRecordPeriod),
|
||||
}
|
||||
|
||||
_ = txApp.DB().
|
||||
Select("id").
|
||||
From(collection.Name).
|
||||
AndWhere(dbx.NewExp(
|
||||
Where(dbx.NewExp(
|
||||
"system={:system} AND type={:type} AND created > {:created}",
|
||||
dbx.Params{
|
||||
"type": recordData.shorterType,
|
||||
"system": system.Id,
|
||||
"created": shorterRecordPeriod,
|
||||
},
|
||||
params,
|
||||
)).
|
||||
All(&recordIds)
|
||||
|
||||
// continue if not enough shorter records
|
||||
if err != nil || len(recordIds) < recordData.minShorterRecords {
|
||||
if len(recordIds) < recordData.minShorterRecords {
|
||||
continue
|
||||
}
|
||||
// average the shorter records and create longer record
|
||||
longerRecord := core.NewRecord(collection)
|
||||
longerRecord.Set("system", system.Id)
|
||||
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 {
|
||||
case "system_stats":
|
||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||
case "container_stats":
|
||||
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||
case "network_probe_stats":
|
||||
longerRecord.Set("stats", rm.AverageProbeStats(db, recordIds))
|
||||
}
|
||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||
log.Println("failed to save longer record", "err", err)
|
||||
@@ -163,41 +174,54 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
return nil
|
||||
})
|
||||
|
||||
statsRecord.Stats = statsRecord.Stats[:0]
|
||||
// slog.Info("finished creating longer records", "time (ms)", time.Since(now).Milliseconds())
|
||||
}
|
||||
|
||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).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
|
||||
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
|
||||
// Clear/reset global structs for reuse
|
||||
sumStats = system.Stats{}
|
||||
tempStats = system.Stats{}
|
||||
sum := &sumStats
|
||||
stats := &tempStats
|
||||
stats := make([]system.Stats, 0, len(records))
|
||||
var row StatsRecord
|
||||
params := make(dbx.Params, 1)
|
||||
for _, rec := range records {
|
||||
row.Stats = row.Stats[:0]
|
||||
params["id"] = rec.Id
|
||||
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(params).One(&row)
|
||||
var s system.Stats
|
||||
if err := json.Unmarshal(row.Stats, &s); err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
result := AverageSystemStatsSlice(stats)
|
||||
return &result
|
||||
}
|
||||
|
||||
// AverageSystemStatsSlice computes the average of a slice of system stats.
|
||||
func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
var sum system.Stats
|
||||
count := float64(len(records))
|
||||
if count == 0 {
|
||||
return sum
|
||||
}
|
||||
|
||||
// necessary because uint8 is not big enough for the sum
|
||||
batterySum := 0
|
||||
// accumulate per-core usage across records
|
||||
var cpuCoresSums []uint64
|
||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||
var cpuBreakdownSums []float64
|
||||
|
||||
count := float64(len(records))
|
||||
tempCount := float64(0)
|
||||
|
||||
// Accumulate totals
|
||||
for _, record := range records {
|
||||
id := record.Id
|
||||
// clear global statsRecord for reuse
|
||||
statsRecord.Stats = statsRecord.Stats[:0]
|
||||
// reset tempStats each iteration to avoid omitzero fields retaining stale values
|
||||
*stats = system.Stats{}
|
||||
|
||||
queryParams["id"] = id
|
||||
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||
if err := json.Unmarshal(statsRecord.Stats, stats); err != nil {
|
||||
continue
|
||||
}
|
||||
for i := range records {
|
||||
stats := &records[i]
|
||||
|
||||
sum.Cpu += stats.Cpu
|
||||
// accumulate cpu time breakdowns if present
|
||||
@@ -205,8 +229,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
||||
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
||||
}
|
||||
for i, v := range stats.CpuBreakdown {
|
||||
cpuBreakdownSums[i] += v
|
||||
for j, v := range stats.CpuBreakdown {
|
||||
cpuBreakdownSums[j] += v
|
||||
}
|
||||
}
|
||||
sum.Mem += stats.Mem
|
||||
@@ -242,8 +266,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
// extend slices to accommodate core count
|
||||
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
||||
}
|
||||
for i, v := range stats.CpuCoresUsage {
|
||||
cpuCoresSums[i] += uint64(v)
|
||||
for j, v := range stats.CpuCoresUsage {
|
||||
cpuCoresSums[j] += uint64(v)
|
||||
}
|
||||
}
|
||||
// Set peak values
|
||||
@@ -343,109 +367,107 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
}
|
||||
}
|
||||
|
||||
// Compute averages in place
|
||||
if count > 0 {
|
||||
sum.Cpu = twoDecimals(sum.Cpu / count)
|
||||
sum.Mem = twoDecimals(sum.Mem / count)
|
||||
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
||||
sum.MemPct = twoDecimals(sum.MemPct / count)
|
||||
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
||||
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
||||
sum.Swap = twoDecimals(sum.Swap / count)
|
||||
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
||||
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
||||
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
||||
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
||||
for i := range sum.DiskIoStats {
|
||||
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
|
||||
}
|
||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||
sum.Battery[0] = uint8(batterySum / int(count))
|
||||
// Compute averages
|
||||
sum.Cpu = twoDecimals(sum.Cpu / count)
|
||||
sum.Mem = twoDecimals(sum.Mem / count)
|
||||
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
||||
sum.MemPct = twoDecimals(sum.MemPct / count)
|
||||
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
||||
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
||||
sum.Swap = twoDecimals(sum.Swap / count)
|
||||
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
||||
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
||||
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
||||
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
||||
for i := range sum.DiskIoStats {
|
||||
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
|
||||
}
|
||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||
sum.Battery[0] = uint8(batterySum / int(count))
|
||||
|
||||
// Average network interfaces
|
||||
if sum.NetworkInterfaces != nil {
|
||||
for key := range sum.NetworkInterfaces {
|
||||
sum.NetworkInterfaces[key] = [4]uint64{
|
||||
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||
sum.NetworkInterfaces[key][2],
|
||||
sum.NetworkInterfaces[key][3],
|
||||
// Average network interfaces
|
||||
if sum.NetworkInterfaces != nil {
|
||||
for key := range sum.NetworkInterfaces {
|
||||
sum.NetworkInterfaces[key] = [4]uint64{
|
||||
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||
sum.NetworkInterfaces[key][2],
|
||||
sum.NetworkInterfaces[key][3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average temperatures
|
||||
if sum.Temperatures != nil && tempCount > 0 {
|
||||
for key := range sum.Temperatures {
|
||||
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Average extra filesystem stats
|
||||
if sum.ExtraFs != nil {
|
||||
for key := range sum.ExtraFs {
|
||||
fs := sum.ExtraFs[key]
|
||||
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
|
||||
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
||||
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
||||
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
||||
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
||||
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
||||
for i := range fs.DiskIoStats {
|
||||
fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average GPU data
|
||||
if sum.GPUData != nil {
|
||||
for id := range sum.GPUData {
|
||||
gpu := sum.GPUData[id]
|
||||
gpu.Temperature = twoDecimals(gpu.Temperature / count)
|
||||
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
|
||||
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
|
||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||
gpu.Power = twoDecimals(gpu.Power / count)
|
||||
gpu.Count = twoDecimals(gpu.Count / count)
|
||||
|
||||
if gpu.Engines != nil {
|
||||
for engineKey := range gpu.Engines {
|
||||
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
||||
}
|
||||
}
|
||||
|
||||
sum.GPUData[id] = gpu
|
||||
}
|
||||
}
|
||||
|
||||
// Average temperatures
|
||||
if sum.Temperatures != nil && tempCount > 0 {
|
||||
for key := range sum.Temperatures {
|
||||
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
|
||||
}
|
||||
// Average per-core usage
|
||||
if len(cpuCoresSums) > 0 {
|
||||
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
||||
for i := range cpuCoresSums {
|
||||
v := math.Round(float64(cpuCoresSums[i]) / count)
|
||||
avg[i] = uint8(v)
|
||||
}
|
||||
sum.CpuCoresUsage = avg
|
||||
}
|
||||
|
||||
// Average extra filesystem stats
|
||||
if sum.ExtraFs != nil {
|
||||
for key := range sum.ExtraFs {
|
||||
fs := sum.ExtraFs[key]
|
||||
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
|
||||
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
||||
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
||||
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
||||
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
||||
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
||||
for i := range fs.DiskIoStats {
|
||||
fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average GPU data
|
||||
if sum.GPUData != nil {
|
||||
for id := range sum.GPUData {
|
||||
gpu := sum.GPUData[id]
|
||||
gpu.Temperature = twoDecimals(gpu.Temperature / count)
|
||||
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
|
||||
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
|
||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||
gpu.Power = twoDecimals(gpu.Power / count)
|
||||
gpu.Count = twoDecimals(gpu.Count / count)
|
||||
|
||||
if gpu.Engines != nil {
|
||||
for engineKey := range gpu.Engines {
|
||||
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
||||
}
|
||||
}
|
||||
|
||||
sum.GPUData[id] = gpu
|
||||
}
|
||||
}
|
||||
|
||||
// Average per-core usage
|
||||
if len(cpuCoresSums) > 0 {
|
||||
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
||||
for i := range cpuCoresSums {
|
||||
v := math.Round(float64(cpuCoresSums[i]) / count)
|
||||
avg[i] = uint8(v)
|
||||
}
|
||||
sum.CpuCoresUsage = avg
|
||||
}
|
||||
|
||||
// Average CPU breakdown
|
||||
if len(cpuBreakdownSums) > 0 {
|
||||
avg := make([]float64, len(cpuBreakdownSums))
|
||||
for i := range cpuBreakdownSums {
|
||||
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
||||
}
|
||||
sum.CpuBreakdown = avg
|
||||
// Average CPU breakdown
|
||||
if len(cpuBreakdownSums) > 0 {
|
||||
avg := make([]float64, len(cpuBreakdownSums))
|
||||
for i := range cpuBreakdownSums {
|
||||
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
||||
}
|
||||
sum.CpuBreakdown = avg
|
||||
}
|
||||
|
||||
return sum
|
||||
@@ -453,29 +475,33 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
|
||||
// Calculate the average stats of a list of container_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||
// Clear global map for reuse
|
||||
for k := range containerSums {
|
||||
delete(containerSums, k)
|
||||
}
|
||||
sums := containerSums
|
||||
count := float64(len(records))
|
||||
|
||||
for i := range records {
|
||||
id := records[i].Id
|
||||
// clear global statsRecord for reuse
|
||||
statsRecord.Stats = statsRecord.Stats[:0]
|
||||
// must set to nil (not [:0]) to avoid json.Unmarshal reusing backing array
|
||||
// which causes omitzero fields to inherit stale values from previous iterations
|
||||
containerStats = nil
|
||||
|
||||
queryParams["id"] = id
|
||||
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||
|
||||
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
|
||||
allStats := make([][]container.Stats, 0, len(records))
|
||||
var row StatsRecord
|
||||
params := make(dbx.Params, 1)
|
||||
for _, rec := range records {
|
||||
row.Stats = row.Stats[:0]
|
||||
params["id"] = rec.Id
|
||||
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(params).One(&row)
|
||||
var cs []container.Stats
|
||||
if err := json.Unmarshal(row.Stats, &cs); err != nil {
|
||||
return []container.Stats{}
|
||||
}
|
||||
allStats = append(allStats, cs)
|
||||
}
|
||||
return AverageContainerStatsSlice(allStats)
|
||||
}
|
||||
|
||||
// AverageContainerStatsSlice computes the average of container stats across multiple time periods.
|
||||
func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
||||
if len(records) == 0 {
|
||||
return []container.Stats{}
|
||||
}
|
||||
sums := make(map[string]*container.Stats)
|
||||
count := float64(len(records))
|
||||
|
||||
for _, containerStats := range records {
|
||||
for i := range containerStats {
|
||||
stat := containerStats[i]
|
||||
stat := &containerStats[i]
|
||||
if _, ok := sums[stat.Name]; !ok {
|
||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||
}
|
||||
@@ -504,131 +530,78 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
return result
|
||||
}
|
||||
|
||||
// Delete old records
|
||||
func (rm *RecordManager) DeleteOldRecords() {
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
err := deleteOldSystemStats(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldContainerRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldSystemdServiceRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldQuietHours(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// AverageProbeStats averages probe stats across multiple records.
|
||||
// For each probe key: avg of average fields, min of mins, and max of maxes.
|
||||
func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Stats {
|
||||
type probeValues struct {
|
||||
sums probe.Stats
|
||||
counts []int
|
||||
}
|
||||
|
||||
// Delete old alerts history records
|
||||
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||
db := app.DB()
|
||||
var users []struct {
|
||||
Id string `db:"user"`
|
||||
}
|
||||
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}")
|
||||
|
||||
// accumulate sums for each probe key across records
|
||||
sums := make(map[string]*probeValues)
|
||||
var row StatsRecord
|
||||
for _, rec := range records {
|
||||
row.Stats = row.Stats[:0]
|
||||
query.Bind(dbx.Params{"id": rec.Id}).One(&row)
|
||||
var rawStats map[string]probe.Stats
|
||||
if err := json.Unmarshal(row.Stats, &rawStats); err != nil {
|
||||
continue
|
||||
}
|
||||
for key, vals := range rawStats {
|
||||
s, ok := sums[key]
|
||||
if !ok {
|
||||
s = &probeValues{sums: make(probe.Stats, len(vals)), counts: make([]int, len(vals))}
|
||||
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 {
|
||||
switch i {
|
||||
case 1: // min fields
|
||||
if s.counts[i] == 0 || vals[i] < s.sums[i] {
|
||||
s.sums[i] = vals[i]
|
||||
}
|
||||
case 2: // max fields
|
||||
if s.counts[i] == 0 || vals[i] > s.sums[i] {
|
||||
s.sums[i] = vals[i]
|
||||
}
|
||||
default: // average fields
|
||||
s.sums[i] += vals[i]
|
||||
}
|
||||
s.counts[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes system_stats records older than what is displayed in the UI
|
||||
func deleteOldSystemStats(app core.App) error {
|
||||
// Collections to process
|
||||
collections := [2]string{"system_stats", "container_stats"}
|
||||
|
||||
// Record types and their retention periods
|
||||
type RecordDeletionData struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
}
|
||||
recordData := []RecordDeletionData{
|
||||
{recordType: "1m", retention: time.Hour}, // 1 hour
|
||||
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
|
||||
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
|
||||
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
|
||||
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
for _, collection := range collections {
|
||||
// Build the WHERE clause
|
||||
var conditionParts []string
|
||||
var params dbx.Params = make(map[string]any)
|
||||
for i := range recordData {
|
||||
rd := recordData[i]
|
||||
// Create parameterized condition for this record type
|
||||
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)
|
||||
// compute final averages
|
||||
result := make(map[string]probe.Stats, len(sums))
|
||||
for key, s := range sums {
|
||||
if len(s.counts) == 0 {
|
||||
continue
|
||||
}
|
||||
// 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)
|
||||
for i := range s.sums {
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
||||
func deleteOldSystemdServiceRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
||||
|
||||
// Delete systemd service records where updated < twentyMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes container records that haven't been updated in the last 10 minutes
|
||||
func deleteOldContainerRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
|
||||
// Delete container records where updated < tenMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old container records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes old quiet hours records where end date has passed
|
||||
func deleteOldQuietHours(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return result
|
||||
}
|
||||
|
||||
/* Round float to two decimals */
|
||||
|
||||
820
internal/records/records_averaging_test.go
Normal file
820
internal/records/records_averaging_test.go
Normal file
@@ -0,0 +1,820 @@
|
||||
//go:build testing
|
||||
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAverageSystemStatsSlice_Empty(t *testing.T) {
|
||||
result := records.AverageSystemStatsSlice(nil)
|
||||
assert.Equal(t, system.Stats{}, result)
|
||||
|
||||
result = records.AverageSystemStatsSlice([]system.Stats{})
|
||||
assert.Equal(t, system.Stats{}, result)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_SingleRecord(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 45.67,
|
||||
Mem: 16.0,
|
||||
MemUsed: 8.5,
|
||||
MemPct: 53.12,
|
||||
MemBuffCache: 2.0,
|
||||
Swap: 4.0,
|
||||
SwapUsed: 1.0,
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 250.0,
|
||||
DiskPct: 50.0,
|
||||
DiskReadPs: 100.5,
|
||||
DiskWritePs: 200.75,
|
||||
NetworkSent: 10.5,
|
||||
NetworkRecv: 20.25,
|
||||
LoadAvg: [3]float64{1.5, 2.0, 3.5},
|
||||
Bandwidth: [2]uint64{1000, 2000},
|
||||
DiskIO: [2]uint64{500, 600},
|
||||
Battery: [2]uint8{80, 1},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 45.67, result.Cpu)
|
||||
assert.Equal(t, 16.0, result.Mem)
|
||||
assert.Equal(t, 8.5, result.MemUsed)
|
||||
assert.Equal(t, 53.12, result.MemPct)
|
||||
assert.Equal(t, 2.0, result.MemBuffCache)
|
||||
assert.Equal(t, 4.0, result.Swap)
|
||||
assert.Equal(t, 1.0, result.SwapUsed)
|
||||
assert.Equal(t, 500.0, result.DiskTotal)
|
||||
assert.Equal(t, 250.0, result.DiskUsed)
|
||||
assert.Equal(t, 50.0, result.DiskPct)
|
||||
assert.Equal(t, 100.5, result.DiskReadPs)
|
||||
assert.Equal(t, 200.75, result.DiskWritePs)
|
||||
assert.Equal(t, 10.5, result.NetworkSent)
|
||||
assert.Equal(t, 20.25, result.NetworkRecv)
|
||||
assert.Equal(t, [3]float64{1.5, 2.0, 3.5}, result.LoadAvg)
|
||||
assert.Equal(t, [2]uint64{1000, 2000}, result.Bandwidth)
|
||||
assert.Equal(t, [2]uint64{500, 600}, result.DiskIO)
|
||||
assert.Equal(t, uint8(80), result.Battery[0])
|
||||
assert.Equal(t, uint8(1), result.Battery[1])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_BasicAveraging(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 20.0,
|
||||
Mem: 16.0,
|
||||
MemUsed: 6.0,
|
||||
MemPct: 37.5,
|
||||
MemBuffCache: 1.0,
|
||||
MemZfsArc: 0.5,
|
||||
Swap: 4.0,
|
||||
SwapUsed: 1.0,
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 200.0,
|
||||
DiskPct: 40.0,
|
||||
DiskReadPs: 100.0,
|
||||
DiskWritePs: 200.0,
|
||||
NetworkSent: 10.0,
|
||||
NetworkRecv: 20.0,
|
||||
LoadAvg: [3]float64{1.0, 2.0, 3.0},
|
||||
Bandwidth: [2]uint64{1000, 2000},
|
||||
DiskIO: [2]uint64{400, 600},
|
||||
Battery: [2]uint8{80, 1},
|
||||
},
|
||||
{
|
||||
Cpu: 40.0,
|
||||
Mem: 16.0,
|
||||
MemUsed: 10.0,
|
||||
MemPct: 62.5,
|
||||
MemBuffCache: 3.0,
|
||||
MemZfsArc: 1.5,
|
||||
Swap: 4.0,
|
||||
SwapUsed: 3.0,
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 300.0,
|
||||
DiskPct: 60.0,
|
||||
DiskReadPs: 200.0,
|
||||
DiskWritePs: 400.0,
|
||||
NetworkSent: 30.0,
|
||||
NetworkRecv: 40.0,
|
||||
LoadAvg: [3]float64{3.0, 4.0, 5.0},
|
||||
Bandwidth: [2]uint64{3000, 4000},
|
||||
DiskIO: [2]uint64{600, 800},
|
||||
Battery: [2]uint8{60, 1},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 30.0, result.Cpu)
|
||||
assert.Equal(t, 16.0, result.Mem)
|
||||
assert.Equal(t, 8.0, result.MemUsed)
|
||||
assert.Equal(t, 50.0, result.MemPct)
|
||||
assert.Equal(t, 2.0, result.MemBuffCache)
|
||||
assert.Equal(t, 1.0, result.MemZfsArc)
|
||||
assert.Equal(t, 4.0, result.Swap)
|
||||
assert.Equal(t, 2.0, result.SwapUsed)
|
||||
assert.Equal(t, 500.0, result.DiskTotal)
|
||||
assert.Equal(t, 250.0, result.DiskUsed)
|
||||
assert.Equal(t, 50.0, result.DiskPct)
|
||||
assert.Equal(t, 150.0, result.DiskReadPs)
|
||||
assert.Equal(t, 300.0, result.DiskWritePs)
|
||||
assert.Equal(t, 20.0, result.NetworkSent)
|
||||
assert.Equal(t, 30.0, result.NetworkRecv)
|
||||
assert.Equal(t, [3]float64{2.0, 3.0, 4.0}, result.LoadAvg)
|
||||
assert.Equal(t, [2]uint64{2000, 3000}, result.Bandwidth)
|
||||
assert.Equal(t, [2]uint64{500, 700}, result.DiskIO)
|
||||
assert.Equal(t, uint8(70), result.Battery[0])
|
||||
assert.Equal(t, uint8(1), result.Battery[1])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_PeakValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 20.0,
|
||||
MaxCpu: 25.0,
|
||||
MemUsed: 6.0,
|
||||
MaxMem: 7.0,
|
||||
NetworkSent: 10.0,
|
||||
MaxNetworkSent: 15.0,
|
||||
NetworkRecv: 20.0,
|
||||
MaxNetworkRecv: 25.0,
|
||||
DiskReadPs: 100.0,
|
||||
MaxDiskReadPs: 120.0,
|
||||
DiskWritePs: 200.0,
|
||||
MaxDiskWritePs: 220.0,
|
||||
Bandwidth: [2]uint64{1000, 2000},
|
||||
MaxBandwidth: [2]uint64{1500, 2500},
|
||||
DiskIO: [2]uint64{400, 600},
|
||||
MaxDiskIO: [2]uint64{500, 700},
|
||||
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||
MaxDiskIoStats: [6]float64{15.0, 25.0, 35.0, 6.0, 9.0, 14.0},
|
||||
},
|
||||
{
|
||||
Cpu: 40.0,
|
||||
MaxCpu: 50.0,
|
||||
MemUsed: 10.0,
|
||||
MaxMem: 12.0,
|
||||
NetworkSent: 30.0,
|
||||
MaxNetworkSent: 35.0,
|
||||
NetworkRecv: 40.0,
|
||||
MaxNetworkRecv: 45.0,
|
||||
DiskReadPs: 200.0,
|
||||
MaxDiskReadPs: 210.0,
|
||||
DiskWritePs: 400.0,
|
||||
MaxDiskWritePs: 410.0,
|
||||
Bandwidth: [2]uint64{3000, 4000},
|
||||
MaxBandwidth: [2]uint64{3500, 4500},
|
||||
DiskIO: [2]uint64{600, 800},
|
||||
MaxDiskIO: [2]uint64{650, 850},
|
||||
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
|
||||
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 50.0, result.MaxCpu)
|
||||
assert.Equal(t, 12.0, result.MaxMem)
|
||||
assert.Equal(t, 35.0, result.MaxNetworkSent)
|
||||
assert.Equal(t, 45.0, result.MaxNetworkRecv)
|
||||
assert.Equal(t, 210.0, result.MaxDiskReadPs)
|
||||
assert.Equal(t, 410.0, result.MaxDiskWritePs)
|
||||
assert.Equal(t, [2]uint64{3500, 4500}, result.MaxBandwidth)
|
||||
assert.Equal(t, [2]uint64{650, 850}, result.MaxDiskIO)
|
||||
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, result.DiskIoStats)
|
||||
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, result.MaxDiskIoStats)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_DiskIoStats(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
DiskIoStats: [6]float64{30.0, 40.0, 50.0, 15.0, 18.0, 22.0},
|
||||
MaxDiskIoStats: [6]float64{28.0, 38.0, 48.0, 14.0, 17.0, 21.0},
|
||||
},
|
||||
{
|
||||
Cpu: 30.0,
|
||||
DiskIoStats: [6]float64{20.0, 30.0, 40.0, 10.0, 12.0, 16.0},
|
||||
MaxDiskIoStats: [6]float64{25.0, 35.0, 45.0, 11.0, 13.0, 17.0},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
// Average: (10+30+20)/3=20, (20+40+30)/3=30, (30+50+40)/3=40, (5+15+10)/3=10, (8+18+12)/3≈12.67, (12+22+16)/3≈16.67
|
||||
assert.Equal(t, 20.0, result.DiskIoStats[0])
|
||||
assert.Equal(t, 30.0, result.DiskIoStats[1])
|
||||
assert.Equal(t, 40.0, result.DiskIoStats[2])
|
||||
assert.Equal(t, 10.0, result.DiskIoStats[3])
|
||||
assert.Equal(t, 12.67, result.DiskIoStats[4])
|
||||
assert.Equal(t, 16.67, result.DiskIoStats[5])
|
||||
// Max: current DiskIoStats[0] wins for record 2 (30 > MaxDiskIoStats 28)
|
||||
assert.Equal(t, 30.0, result.MaxDiskIoStats[0])
|
||||
assert.Equal(t, 40.0, result.MaxDiskIoStats[1])
|
||||
assert.Equal(t, 50.0, result.MaxDiskIoStats[2])
|
||||
assert.Equal(t, 15.0, result.MaxDiskIoStats[3])
|
||||
assert.Equal(t, 18.0, result.MaxDiskIoStats[4])
|
||||
assert.Equal(t, 22.0, result.MaxDiskIoStats[5])
|
||||
}
|
||||
|
||||
// Tests that current DiskIoStats values are considered when computing MaxDiskIoStats.
|
||||
func TestAverageSystemStatsSlice_DiskIoStatsPeakFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0}},
|
||||
{Cpu: 20.0, DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0}, MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
// Current value from first record (95, 90, 85, 50, 60, 80) beats MaxDiskIoStats in both records
|
||||
assert.Equal(t, 95.0, result.MaxDiskIoStats[0])
|
||||
assert.Equal(t, 90.0, result.MaxDiskIoStats[1])
|
||||
assert.Equal(t, 85.0, result.MaxDiskIoStats[2])
|
||||
assert.Equal(t, 50.0, result.MaxDiskIoStats[3])
|
||||
assert.Equal(t, 60.0, result.MaxDiskIoStats[4])
|
||||
assert.Equal(t, 80.0, result.MaxDiskIoStats[5])
|
||||
}
|
||||
|
||||
// Tests that current values are considered when computing peaks
|
||||
// (i.e., current cpu > MaxCpu should still win).
|
||||
func TestAverageSystemStatsSlice_PeakFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 95.0, MaxCpu: 80.0, MemUsed: 15.0, MaxMem: 10.0},
|
||||
{Cpu: 10.0, MaxCpu: 20.0, MemUsed: 5.0, MaxMem: 8.0},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 95.0, result.MaxCpu)
|
||||
assert.Equal(t, 15.0, result.MaxMem)
|
||||
}
|
||||
|
||||
// Tests that records without temperature data are excluded from the temperature average.
|
||||
func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
Temperatures: map[string]float64{"cpu": 60.0, "gpu": 70.0},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
Temperatures: map[string]float64{"cpu": 80.0, "gpu": 90.0},
|
||||
},
|
||||
{
|
||||
// No temperatures - should not affect temp averaging
|
||||
Cpu: 30.0,
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.Temperatures)
|
||||
// Average over 2 records that had temps, not 3
|
||||
assert.Equal(t, 70.0, result.Temperatures["cpu"])
|
||||
assert.Equal(t, 80.0, result.Temperatures["gpu"])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
NetworkInterfaces: map[string][4]uint64{
|
||||
"eth0": {100, 200, 150, 250},
|
||||
"eth1": {50, 60, 70, 80},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
NetworkInterfaces: map[string][4]uint64{
|
||||
"eth0": {200, 400, 300, 500},
|
||||
"eth1": {150, 160, 170, 180},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.NetworkInterfaces)
|
||||
// [0] and [1] are averaged, [2] and [3] are max
|
||||
assert.Equal(t, [4]uint64{150, 300, 300, 500}, result.NetworkInterfaces["eth0"])
|
||||
assert.Equal(t, [4]uint64{100, 110, 170, 180}, result.NetworkInterfaces["eth1"])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_ExtraFs(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskTotal: 1000.0,
|
||||
DiskUsed: 400.0,
|
||||
DiskReadPs: 50.0,
|
||||
DiskWritePs: 100.0,
|
||||
MaxDiskReadPS: 60.0,
|
||||
MaxDiskWritePS: 110.0,
|
||||
DiskReadBytes: 5000,
|
||||
DiskWriteBytes: 10000,
|
||||
MaxDiskReadBytes: 6000,
|
||||
MaxDiskWriteBytes: 11000,
|
||||
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskTotal: 1000.0,
|
||||
DiskUsed: 600.0,
|
||||
DiskReadPs: 150.0,
|
||||
DiskWritePs: 200.0,
|
||||
MaxDiskReadPS: 160.0,
|
||||
MaxDiskWritePS: 210.0,
|
||||
DiskReadBytes: 15000,
|
||||
DiskWriteBytes: 20000,
|
||||
MaxDiskReadBytes: 16000,
|
||||
MaxDiskWriteBytes: 21000,
|
||||
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
|
||||
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.ExtraFs)
|
||||
require.NotNil(t, result.ExtraFs["/data"])
|
||||
fs := result.ExtraFs["/data"]
|
||||
assert.Equal(t, 1000.0, fs.DiskTotal)
|
||||
assert.Equal(t, 500.0, fs.DiskUsed)
|
||||
assert.Equal(t, 100.0, fs.DiskReadPs)
|
||||
assert.Equal(t, 150.0, fs.DiskWritePs)
|
||||
assert.Equal(t, 160.0, fs.MaxDiskReadPS)
|
||||
assert.Equal(t, 210.0, fs.MaxDiskWritePS)
|
||||
assert.Equal(t, uint64(10000), fs.DiskReadBytes)
|
||||
assert.Equal(t, uint64(15000), fs.DiskWriteBytes)
|
||||
assert.Equal(t, uint64(16000), fs.MaxDiskReadBytes)
|
||||
assert.Equal(t, uint64(21000), fs.MaxDiskWriteBytes)
|
||||
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, fs.DiskIoStats)
|
||||
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, fs.MaxDiskIoStats)
|
||||
}
|
||||
|
||||
// Tests that ExtraFs DiskIoStats peak considers current values, not just previous peaks.
|
||||
func TestAverageSystemStatsSlice_ExtraFsDiskIoStatsPeakFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, // exceeds MaxDiskIoStats
|
||||
MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0},
|
||||
MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
fs := result.ExtraFs["/data"]
|
||||
assert.Equal(t, 95.0, fs.MaxDiskIoStats[0])
|
||||
assert.Equal(t, 90.0, fs.MaxDiskIoStats[1])
|
||||
assert.Equal(t, 85.0, fs.MaxDiskIoStats[2])
|
||||
assert.Equal(t, 50.0, fs.MaxDiskIoStats[3])
|
||||
assert.Equal(t, 60.0, fs.MaxDiskIoStats[4])
|
||||
assert.Equal(t, 80.0, fs.MaxDiskIoStats[5])
|
||||
}
|
||||
|
||||
// Tests that extra FS peak values consider current values, not just previous peaks.
|
||||
func TestAverageSystemStatsSlice_ExtraFsPeaksFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskReadPs: 500.0, // exceeds MaxDiskReadPS
|
||||
MaxDiskReadPS: 100.0,
|
||||
DiskReadBytes: 50000,
|
||||
MaxDiskReadBytes: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskReadPs: 50.0,
|
||||
MaxDiskReadPS: 200.0,
|
||||
DiskReadBytes: 5000,
|
||||
MaxDiskReadBytes: 20000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
fs := result.ExtraFs["/data"]
|
||||
assert.Equal(t, 500.0, fs.MaxDiskReadPS)
|
||||
assert.Equal(t, uint64(50000), fs.MaxDiskReadBytes)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_GPUData(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {
|
||||
Name: "RTX 4090",
|
||||
Temperature: 60.0,
|
||||
MemoryUsed: 4.0,
|
||||
MemoryTotal: 24.0,
|
||||
Usage: 30.0,
|
||||
Power: 200.0,
|
||||
Count: 1.0,
|
||||
Engines: map[string]float64{
|
||||
"3D": 50.0,
|
||||
"Video": 10.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {
|
||||
Name: "RTX 4090",
|
||||
Temperature: 80.0,
|
||||
MemoryUsed: 8.0,
|
||||
MemoryTotal: 24.0,
|
||||
Usage: 70.0,
|
||||
Power: 300.0,
|
||||
Count: 1.0,
|
||||
Engines: map[string]float64{
|
||||
"3D": 90.0,
|
||||
"Video": 30.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.GPUData)
|
||||
gpu := result.GPUData["gpu0"]
|
||||
assert.Equal(t, "RTX 4090", gpu.Name)
|
||||
assert.Equal(t, 70.0, gpu.Temperature)
|
||||
assert.Equal(t, 6.0, gpu.MemoryUsed)
|
||||
assert.Equal(t, 24.0, gpu.MemoryTotal)
|
||||
assert.Equal(t, 50.0, gpu.Usage)
|
||||
assert.Equal(t, 250.0, gpu.Power)
|
||||
assert.Equal(t, 1.0, gpu.Count)
|
||||
require.NotNil(t, gpu.Engines)
|
||||
assert.Equal(t, 70.0, gpu.Engines["3D"])
|
||||
assert.Equal(t, 20.0, gpu.Engines["Video"])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_MultipleGPUs(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {Name: "GPU A", Usage: 20.0, Temperature: 50.0},
|
||||
"gpu1": {Name: "GPU B", Usage: 60.0, Temperature: 70.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {Name: "GPU A", Usage: 40.0, Temperature: 60.0},
|
||||
"gpu1": {Name: "GPU B", Usage: 80.0, Temperature: 80.0},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.GPUData)
|
||||
assert.Equal(t, 30.0, result.GPUData["gpu0"].Usage)
|
||||
assert.Equal(t, 55.0, result.GPUData["gpu0"].Temperature)
|
||||
assert.Equal(t, 70.0, result.GPUData["gpu1"].Usage)
|
||||
assert.Equal(t, 75.0, result.GPUData["gpu1"].Temperature)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_CpuCoresUsage(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{10, 20, 30, 40}},
|
||||
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{30, 40, 50, 60}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.CpuCoresUsage)
|
||||
assert.Equal(t, system.Uint8Slice{20, 30, 40, 50}, result.CpuCoresUsage)
|
||||
}
|
||||
|
||||
// Tests that per-core usage rounds correctly (e.g., 15.5 -> 16 via math.Round).
|
||||
func TestAverageSystemStatsSlice_CpuCoresUsageRounding(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{11}},
|
||||
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{20}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.CpuCoresUsage)
|
||||
// (11+20)/2 = 15.5, rounds to 16
|
||||
assert.Equal(t, uint8(16), result.CpuCoresUsage[0])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_CpuBreakdown(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5}},
|
||||
{Cpu: 20.0, CpuBreakdown: []float64{15.0, 7.0, 3.0, 1.5, 73.5}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.CpuBreakdown)
|
||||
assert.Equal(t, []float64{10.0, 5.0, 2.0, 1.0, 82.0}, result.CpuBreakdown)
|
||||
}
|
||||
|
||||
// Tests that Battery[1] (charge state) uses the last record's value.
|
||||
func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, Battery: [2]uint8{100, 1}}, // charging
|
||||
{Cpu: 20.0, Battery: [2]uint8{90, 0}}, // not charging
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, uint8(95), result.Battery[0])
|
||||
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, Mem: 8.0},
|
||||
{Cpu: 20.0, Mem: 8.0},
|
||||
{Cpu: 30.0, Mem: 8.0},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 20.0, result.Cpu)
|
||||
assert.Equal(t, 8.0, result.Mem)
|
||||
}
|
||||
|
||||
// Tests records where some have optional fields and others don't.
|
||||
func TestAverageSystemStatsSlice_MixedOptionalFields(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
CpuCoresUsage: system.Uint8Slice{50, 60},
|
||||
CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5},
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {Name: "GPU", Usage: 40.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
// No CpuCoresUsage, CpuBreakdown, or GPUData
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 15.0, result.Cpu)
|
||||
// CpuCoresUsage: only 1 record had it, so sum/2
|
||||
require.NotNil(t, result.CpuCoresUsage)
|
||||
assert.Equal(t, uint8(25), result.CpuCoresUsage[0])
|
||||
assert.Equal(t, uint8(30), result.CpuCoresUsage[1])
|
||||
// CpuBreakdown: only 1 record had it, so sum/2
|
||||
require.NotNil(t, result.CpuBreakdown)
|
||||
assert.Equal(t, 2.5, result.CpuBreakdown[0])
|
||||
// GPUData: only 1 record had it, so sum/2
|
||||
require.NotNil(t, result.GPUData)
|
||||
assert.Equal(t, 20.0, result.GPUData["gpu0"].Usage)
|
||||
}
|
||||
|
||||
// Tests with 10 records matching the common real-world case (10 x 1m -> 1 x 10m).
|
||||
func TestAverageSystemStatsSlice_TenRecords(t *testing.T) {
|
||||
input := make([]system.Stats, 10)
|
||||
for i := range input {
|
||||
input[i] = system.Stats{
|
||||
Cpu: float64(i * 10), // 0, 10, 20, ..., 90
|
||||
Mem: 16.0,
|
||||
MemUsed: float64(4 + i), // 4, 5, 6, ..., 13
|
||||
MemPct: float64(25 + i), // 25, 26, ..., 34
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 250.0,
|
||||
DiskPct: 50.0,
|
||||
NetworkSent: float64(i),
|
||||
NetworkRecv: float64(i * 2),
|
||||
Bandwidth: [2]uint64{uint64(i * 1000), uint64(i * 2000)},
|
||||
LoadAvg: [3]float64{float64(i), float64(i) * 0.5, float64(i) * 0.25},
|
||||
}
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 45.0, result.Cpu) // avg of 0..90
|
||||
assert.Equal(t, 16.0, result.Mem) // constant
|
||||
assert.Equal(t, 8.5, result.MemUsed) // avg of 4..13
|
||||
assert.Equal(t, 29.5, result.MemPct) // avg of 25..34
|
||||
assert.Equal(t, 500.0, result.DiskTotal)
|
||||
assert.Equal(t, 250.0, result.DiskUsed)
|
||||
assert.Equal(t, 50.0, result.DiskPct)
|
||||
assert.Equal(t, 4.5, result.NetworkSent)
|
||||
assert.Equal(t, 9.0, result.NetworkRecv)
|
||||
assert.Equal(t, [2]uint64{4500, 9000}, result.Bandwidth)
|
||||
}
|
||||
|
||||
// --- Container Stats Tests ---
|
||||
|
||||
func TestAverageContainerStatsSlice_Empty(t *testing.T) {
|
||||
result := records.AverageContainerStatsSlice(nil)
|
||||
assert.Equal(t, []container.Stats{}, result)
|
||||
|
||||
result = records.AverageContainerStatsSlice([][]container.Stats{})
|
||||
assert.Equal(t, []container.Stats{}, result)
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_SingleRecord(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 5.0, Mem: 128.0, Bandwidth: [2]uint64{1000, 2000}},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
assert.Equal(t, 5.0, result[0].Cpu)
|
||||
assert.Equal(t, 128.0, result[0].Mem)
|
||||
assert.Equal(t, [2]uint64{1000, 2000}, result[0].Bandwidth)
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_BasicAveraging(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{1000, 2000}},
|
||||
{Name: "redis", Cpu: 5.0, Mem: 64.0, Bandwidth: [2]uint64{500, 1000}},
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{3000, 4000}},
|
||||
{Name: "redis", Cpu: 15.0, Mem: 128.0, Bandwidth: [2]uint64{1500, 2000}},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
|
||||
require.Len(t, result, 2)
|
||||
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
assert.Equal(t, 15.0, result[0].Cpu)
|
||||
assert.Equal(t, 150.0, result[0].Mem)
|
||||
assert.Equal(t, [2]uint64{2000, 3000}, result[0].Bandwidth)
|
||||
|
||||
assert.Equal(t, "redis", result[1].Name)
|
||||
assert.Equal(t, 10.0, result[1].Cpu)
|
||||
assert.Equal(t, 96.0, result[1].Mem)
|
||||
assert.Equal(t, [2]uint64{1000, 1500}, result[1].Bandwidth)
|
||||
}
|
||||
|
||||
// Tests containers that appear in some records but not all.
|
||||
func TestAverageContainerStatsSlice_ContainerAppearsInSomeRecords(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0},
|
||||
{Name: "redis", Cpu: 5.0, Mem: 64.0},
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0},
|
||||
// redis not present
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
|
||||
require.Len(t, result, 2)
|
||||
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
assert.Equal(t, 15.0, result[0].Cpu)
|
||||
assert.Equal(t, 150.0, result[0].Mem)
|
||||
|
||||
// redis: sum / count where count = total records (2), not records containing redis
|
||||
assert.Equal(t, "redis", result[1].Name)
|
||||
assert.Equal(t, 2.5, result[1].Cpu)
|
||||
assert.Equal(t, 32.0, result[1].Mem)
|
||||
}
|
||||
|
||||
// Tests backward compatibility with deprecated NetworkSent/NetworkRecv (MB) when Bandwidth is zero.
|
||||
func TestAverageContainerStatsSlice_DeprecatedNetworkFields(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0, NetworkSent: 1.0, NetworkRecv: 2.0}, // 1 MB, 2 MB
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0, NetworkSent: 3.0, NetworkRecv: 4.0}, // 3 MB, 4 MB
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
// avg sent = (1*1048576 + 3*1048576) / 2 = 2*1048576
|
||||
assert.Equal(t, uint64(2*1048576), result[0].Bandwidth[0])
|
||||
// avg recv = (2*1048576 + 4*1048576) / 2 = 3*1048576
|
||||
assert.Equal(t, uint64(3*1048576), result[0].Bandwidth[1])
|
||||
}
|
||||
|
||||
// Tests that when Bandwidth is set, deprecated NetworkSent/NetworkRecv are ignored.
|
||||
func TestAverageContainerStatsSlice_MixedBandwidthAndDeprecated(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{5000, 6000}, NetworkSent: 99.0, NetworkRecv: 99.0},
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{7000, 8000}},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, uint64(6000), result[0].Bandwidth[0])
|
||||
assert.Equal(t, uint64(7000), result[0].Bandwidth[1])
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_ThreeRecords(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{{Name: "app", Cpu: 1.0, Mem: 100.0}},
|
||||
{{Name: "app", Cpu: 2.0, Mem: 200.0}},
|
||||
{{Name: "app", Cpu: 3.0, Mem: 300.0}},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, 2.0, result[0].Cpu)
|
||||
assert.Equal(t, 200.0, result[0].Mem)
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_ManyContainers(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "a", Cpu: 10.0, Mem: 100.0},
|
||||
{Name: "b", Cpu: 20.0, Mem: 200.0},
|
||||
{Name: "c", Cpu: 30.0, Mem: 300.0},
|
||||
{Name: "d", Cpu: 40.0, Mem: 400.0},
|
||||
},
|
||||
{
|
||||
{Name: "a", Cpu: 20.0, Mem: 200.0},
|
||||
{Name: "b", Cpu: 30.0, Mem: 300.0},
|
||||
{Name: "c", Cpu: 40.0, Mem: 400.0},
|
||||
{Name: "d", Cpu: 50.0, Mem: 500.0},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
|
||||
require.Len(t, result, 4)
|
||||
assert.Equal(t, 15.0, result[0].Cpu)
|
||||
assert.Equal(t, 25.0, result[1].Cpu)
|
||||
assert.Equal(t, 35.0, result[2].Cpu)
|
||||
assert.Equal(t, 45.0, result[3].Cpu)
|
||||
}
|
||||
130
internal/records/records_deletion.go
Normal file
130
internal/records/records_deletion.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// Delete old records
|
||||
func (rm *RecordManager) DeleteOldRecords() {
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
err := deleteOldSystemStats(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old system stats", "err", err)
|
||||
}
|
||||
err = deleteOldContainerRecords(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old container records", "err", err)
|
||||
}
|
||||
err = deleteOldSystemdServiceRecords(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old systemd service records", "err", err)
|
||||
}
|
||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old alerts history", "err", err)
|
||||
}
|
||||
err = deleteOldQuietHours(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old quiet hours", "err", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Delete old alerts history records
|
||||
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||
db := app.DB()
|
||||
var users []struct {
|
||||
Id string `db:"user"`
|
||||
}
|
||||
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes system_stats records older than what is displayed in the UI
|
||||
func deleteOldSystemStats(app core.App) error {
|
||||
// Collections to process
|
||||
collections := [3]string{"system_stats", "container_stats", "network_probe_stats"}
|
||||
|
||||
// Record types and their retention periods
|
||||
type RecordDeletionData struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
}
|
||||
recordData := []RecordDeletionData{
|
||||
{recordType: "1m", retention: time.Hour}, // 1 hour
|
||||
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
|
||||
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
|
||||
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
|
||||
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
db := app.DB()
|
||||
|
||||
for _, collection := range collections {
|
||||
query := db.Delete(collection, dbx.NewExp("type={:type} AND created<{:created}"))
|
||||
for _, rd := range recordData {
|
||||
if _, err := query.Bind(dbx.Params{
|
||||
"type": rd.recordType,
|
||||
"created": getCreatedTimeField(collection, now.Add(-rd.retention)),
|
||||
}).Execute(); err != nil {
|
||||
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
||||
func deleteOldSystemdServiceRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
||||
|
||||
// Delete systemd service records where updated < twentyMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes container records that haven't been updated in the last 10 minutes
|
||||
func deleteOldContainerRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
|
||||
// Delete container records where updated < tenMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old container records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes old quiet hours records where end date has passed
|
||||
func deleteOldQuietHours(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
428
internal/records/records_deletion_test.go
Normal file
428
internal/records/records_deletion_test.go
Normal file
@@ -0,0 +1,428 @@
|
||||
//go:build testing
|
||||
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||
func TestDeleteOldRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user for alerts history
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test system
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create old system_stats records that should be deleted
|
||||
var record *core.Record
|
||||
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// created is autodate field, so we need to set it manually
|
||||
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record)
|
||||
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||
require.Equal(t, record.Get("system"), system.Id)
|
||||
require.Equal(t, record.Get("type"), "1m")
|
||||
|
||||
// Create recent system_stats record that should be kept
|
||||
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create many alerts history records to trigger deletion
|
||||
for i := range 260 { // More than countBeforeDeletion (250)
|
||||
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count records before deletion
|
||||
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run deletion
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify old system stats were deleted
|
||||
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||
|
||||
// Verify alerts history was trimmed
|
||||
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||
func TestDeleteOldSystemStats(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Test data for different record types and their retention periods
|
||||
testCases := []struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
shouldBeKept bool
|
||||
ageFromNow time.Duration
|
||||
description string
|
||||
}{
|
||||
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||
}
|
||||
|
||||
// Create test records for both system_stats and container_stats
|
||||
collections := []string{"system_stats", "container_stats"}
|
||||
recordIds := make(map[string][]string)
|
||||
|
||||
for _, collection := range collections {
|
||||
recordIds[collection] = make([]string, 0)
|
||||
|
||||
for i, tc := range testCases {
|
||||
recordTime := now.Add(-tc.ageFromNow)
|
||||
|
||||
var stats string
|
||||
if collection == "system_stats" {
|
||||
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||
} else {
|
||||
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||
}
|
||||
|
||||
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||
"system": system.Id,
|
||||
"type": tc.recordType,
|
||||
"stats": stats,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldSystemStats(hub)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify results
|
||||
for _, collection := range collections {
|
||||
for i, tc := range testCases {
|
||||
recordId := recordIds[collection][i]
|
||||
|
||||
// Try to find the record
|
||||
_, err := hub.FindRecordById(collection, recordId)
|
||||
|
||||
if tc.shouldBeKept {
|
||||
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||
} else {
|
||||
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test users
|
||||
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
now := time.Now().UTC()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *core.Record
|
||||
alertCount int
|
||||
countToKeep int
|
||||
countBeforeDeletion int
|
||||
expectedAfterDeletion int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "User with few alerts (below threshold)",
|
||||
user: user1,
|
||||
alertCount: 100,
|
||||
countToKeep: 50,
|
||||
countBeforeDeletion: 150,
|
||||
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||
},
|
||||
{
|
||||
name: "User with many alerts (above threshold)",
|
||||
user: user2,
|
||||
alertCount: 300,
|
||||
countToKeep: 100,
|
||||
countBeforeDeletion: 200,
|
||||
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create alerts for this user
|
||||
for i := 0; i < tc.alertCount; i++ {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": tc.user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count before deletion
|
||||
countBefore, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count after deletion
|
||||
countAfter, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||
|
||||
// If deletion occurred, verify the most recent records were kept
|
||||
if tc.expectedAfterDeletion < tc.alertCount {
|
||||
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||
"user = {:user}",
|
||||
"-created", // Order by created DESC
|
||||
tc.countToKeep,
|
||||
0,
|
||||
map[string]any{"user": tc.user.Id})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||
|
||||
// Verify records are in descending order by created time
|
||||
for i := 1; i < len(records); i++ {
|
||||
prev := records[i-1].GetDateTime("created").Time()
|
||||
curr := records[i].GetDateTime("created").Time()
|
||||
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||
"Records should be ordered by created time (newest first)")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||
// Create user with few alerts
|
||||
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
|
||||
// Create only 5 alerts (well below threshold)
|
||||
for i := range 5 {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should not error and should not delete anything
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||
})
|
||||
|
||||
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||
// Clear any existing alerts
|
||||
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not error with empty table
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
||||
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user and system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create old systemd service records that should be deleted (older than 20 minutes)
|
||||
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "nginx.service",
|
||||
"state": 0, // Active
|
||||
"sub": 1, // Running
|
||||
"cpu": 5.0,
|
||||
"cpuPeak": 10.0,
|
||||
"memory": 1024000,
|
||||
"memPeak": 2048000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 25 minutes ago (should be deleted)
|
||||
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(oldRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create recent systemd service record that should be kept (within 20 minutes)
|
||||
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "apache.service",
|
||||
"state": 1, // Inactive
|
||||
"sub": 0, // Dead
|
||||
"cpu": 2.0,
|
||||
"cpuPeak": 3.0,
|
||||
"memory": 512000,
|
||||
"memPeak": 1024000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 10 minutes ago (should be kept)
|
||||
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(recentRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count records before deletion
|
||||
countBefore, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
||||
|
||||
// Run deletion via RecordManager
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
countAfter, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
||||
|
||||
// Verify the correct record was kept
|
||||
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
||||
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
||||
}
|
||||
@@ -3,430 +3,15 @@
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||
func TestDeleteOldRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user for alerts history
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test system
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create old system_stats records that should be deleted
|
||||
var record *core.Record
|
||||
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// created is autodate field, so we need to set it manually
|
||||
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record)
|
||||
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||
require.Equal(t, record.Get("system"), system.Id)
|
||||
require.Equal(t, record.Get("type"), "1m")
|
||||
|
||||
// Create recent system_stats record that should be kept
|
||||
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create many alerts history records to trigger deletion
|
||||
for i := range 260 { // More than countBeforeDeletion (250)
|
||||
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count records before deletion
|
||||
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run deletion
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify old system stats were deleted
|
||||
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||
|
||||
// Verify alerts history was trimmed
|
||||
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||
func TestDeleteOldSystemStats(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Test data for different record types and their retention periods
|
||||
testCases := []struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
shouldBeKept bool
|
||||
ageFromNow time.Duration
|
||||
description string
|
||||
}{
|
||||
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||
}
|
||||
|
||||
// Create test records for both system_stats and container_stats
|
||||
collections := []string{"system_stats", "container_stats"}
|
||||
recordIds := make(map[string][]string)
|
||||
|
||||
for _, collection := range collections {
|
||||
recordIds[collection] = make([]string, 0)
|
||||
|
||||
for i, tc := range testCases {
|
||||
recordTime := now.Add(-tc.ageFromNow)
|
||||
|
||||
var stats string
|
||||
if collection == "system_stats" {
|
||||
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||
} else {
|
||||
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||
}
|
||||
|
||||
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||
"system": system.Id,
|
||||
"type": tc.recordType,
|
||||
"stats": stats,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldSystemStats(hub)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify results
|
||||
for _, collection := range collections {
|
||||
for i, tc := range testCases {
|
||||
recordId := recordIds[collection][i]
|
||||
|
||||
// Try to find the record
|
||||
_, err := hub.FindRecordById(collection, recordId)
|
||||
|
||||
if tc.shouldBeKept {
|
||||
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||
} else {
|
||||
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test users
|
||||
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
now := time.Now().UTC()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *core.Record
|
||||
alertCount int
|
||||
countToKeep int
|
||||
countBeforeDeletion int
|
||||
expectedAfterDeletion int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "User with few alerts (below threshold)",
|
||||
user: user1,
|
||||
alertCount: 100,
|
||||
countToKeep: 50,
|
||||
countBeforeDeletion: 150,
|
||||
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||
},
|
||||
{
|
||||
name: "User with many alerts (above threshold)",
|
||||
user: user2,
|
||||
alertCount: 300,
|
||||
countToKeep: 100,
|
||||
countBeforeDeletion: 200,
|
||||
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create alerts for this user
|
||||
for i := 0; i < tc.alertCount; i++ {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": tc.user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count before deletion
|
||||
countBefore, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count after deletion
|
||||
countAfter, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||
|
||||
// If deletion occurred, verify the most recent records were kept
|
||||
if tc.expectedAfterDeletion < tc.alertCount {
|
||||
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||
"user = {:user}",
|
||||
"-created", // Order by created DESC
|
||||
tc.countToKeep,
|
||||
0,
|
||||
map[string]any{"user": tc.user.Id})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||
|
||||
// Verify records are in descending order by created time
|
||||
for i := 1; i < len(records); i++ {
|
||||
prev := records[i-1].GetDateTime("created").Time()
|
||||
curr := records[i].GetDateTime("created").Time()
|
||||
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||
"Records should be ordered by created time (newest first)")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||
// Create user with few alerts
|
||||
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
|
||||
// Create only 5 alerts (well below threshold)
|
||||
for i := range 5 {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should not error and should not delete anything
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||
})
|
||||
|
||||
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||
// Clear any existing alerts
|
||||
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not error with empty table
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
||||
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user and system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create old systemd service records that should be deleted (older than 20 minutes)
|
||||
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "nginx.service",
|
||||
"state": 0, // Active
|
||||
"sub": 1, // Running
|
||||
"cpu": 5.0,
|
||||
"cpuPeak": 10.0,
|
||||
"memory": 1024000,
|
||||
"memPeak": 2048000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 25 minutes ago (should be deleted)
|
||||
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(oldRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create recent systemd service record that should be kept (within 20 minutes)
|
||||
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "apache.service",
|
||||
"state": 1, // Inactive
|
||||
"sub": 0, // Dead
|
||||
"cpu": 2.0,
|
||||
"cpuPeak": 3.0,
|
||||
"memory": 512000,
|
||||
"memPeak": 1024000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 10 minutes ago (should be kept)
|
||||
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(recentRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count records before deletion
|
||||
countBefore, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
||||
|
||||
// Run deletion via RecordManager
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
countAfter, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
||||
|
||||
// Verify the correct record was kept
|
||||
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
||||
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
||||
}
|
||||
|
||||
// TestRecordManagerCreation tests RecordManager creation
|
||||
func TestRecordManagerCreation(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
|
||||
@@ -22,11 +22,7 @@
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
globalThis.BESZEL = {
|
||||
BASE_PATH: "%BASE_URL%",
|
||||
HUB_VERSION: "{{V}}",
|
||||
HUB_URL: "{{HUB_URL}}"
|
||||
}
|
||||
globalThis.BESZEL = "{info}"
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
4
internal/site/package-lock.json
generated
4
internal/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "beszel",
|
||||
"version": "0.18.6",
|
||||
"version": "0.18.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "beszel",
|
||||
"version": "0.18.6",
|
||||
"version": "0.18.7",
|
||||
"dependencies": {
|
||||
"@henrygd/queue": "^1.0.7",
|
||||
"@henrygd/semaphore": "^0.0.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "beszel",
|
||||
"private": true,
|
||||
"version": "0.18.6",
|
||||
"version": "0.18.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function AreaChartDefault({
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
const sourceData = customData ?? chartData.systemStats
|
||||
const sourceData = customData ?? chartData.systemStats ?? []
|
||||
const [displayData, setDisplayData] = useState(sourceData)
|
||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||
|
||||
@@ -111,6 +111,8 @@ export default function AreaChartDefault({
|
||||
})
|
||||
}, [areasKey, displayMaxToggled])
|
||||
|
||||
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
|
||||
|
||||
return useMemo(() => {
|
||||
if (displayData.length === 0) {
|
||||
return null
|
||||
@@ -146,7 +148,7 @@ export default function AreaChartDefault({
|
||||
axisLine={false}
|
||||
/>
|
||||
)}
|
||||
{xAxis(chartData)}
|
||||
{XAxis}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
@@ -167,5 +169,5 @@ export default function AreaChartDefault({
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}, [displayData, yAxisWidth, filter, Areas])
|
||||
}, [displayData, yAxisWidth, filter, Areas, XAxis])
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export type DataPoint<T = SystemStatsRecord> = {
|
||||
order?: number
|
||||
strokeOpacity?: number
|
||||
activeDot?: boolean
|
||||
dot?: boolean
|
||||
}
|
||||
|
||||
export default function LineChartDefault({
|
||||
@@ -41,6 +42,7 @@ export default function LineChartDefault({
|
||||
filter,
|
||||
truncate = false,
|
||||
chartProps,
|
||||
connectNulls,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||
@@ -62,10 +64,11 @@ export default function LineChartDefault({
|
||||
filter?: string
|
||||
truncate?: boolean
|
||||
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||
connectNulls?: boolean
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
const sourceData = customData ?? chartData.systemStats
|
||||
const sourceData = customData ?? chartData.systemStats ?? []
|
||||
const [displayData, setDisplayData] = useState(sourceData)
|
||||
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||
|
||||
@@ -83,7 +86,9 @@ export default function LineChartDefault({
|
||||
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||
|
||||
// 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(() => {
|
||||
return dataPoints?.map((dataPoint, i) => {
|
||||
@@ -97,14 +102,15 @@ export default function LineChartDefault({
|
||||
dataKey={dataPoint.dataKey}
|
||||
name={dataPoint.label}
|
||||
type="monotoneX"
|
||||
dot={false}
|
||||
dot={dataPoint.dot || false}
|
||||
strokeWidth={1.5}
|
||||
stroke={color}
|
||||
strokeOpacity={dataPoint.strokeOpacity}
|
||||
isAnimationActive={false}
|
||||
// stackId={dataPoint.stackId}
|
||||
order={dataPoint.order || i}
|
||||
// activeDot={dataPoint.activeDot ?? true}
|
||||
activeDot={dataPoint.activeDot ?? true}
|
||||
connectNulls={connectNulls}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -145,7 +151,7 @@ export default function LineChartDefault({
|
||||
axisLine={false}
|
||||
/>
|
||||
)}
|
||||
{xAxis(chartData)}
|
||||
{XAxis}
|
||||
<ChartTooltip
|
||||
animationEasing="ease-out"
|
||||
animationDuration={150}
|
||||
@@ -166,5 +172,5 @@ export default function LineChartDefault({
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}, [displayData, yAxisWidth, filter, Lines])
|
||||
}, [displayData, yAxisWidth, filter, Lines, XAxis])
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { $allSystemsById, $longestSystemNameLen } from "@/lib/stores"
|
||||
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
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} />,
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
const longestName = useStore($longestSystemNameLen)
|
||||
const longestName = useStore($longestSystemName)
|
||||
return (
|
||||
<div className="ms-1 max-w-40 truncate" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{allSystems[getValue() as string]?.name ?? ""}
|
||||
<div className="ms-1 relative w-fit max-w-40">
|
||||
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||
{longestName}
|
||||
</span>
|
||||
<span className="absolute inset-0 truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $authenticated } from "@/lib/stores"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { $router, Link, prependBasePath } from "../router"
|
||||
import { $router, Link, basePath, prependBasePath } from "../router"
|
||||
import { toast } from "../ui/use-toast"
|
||||
import { OtpInputForm } from "./otp-forms"
|
||||
|
||||
@@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({
|
||||
passwordConfirm: passwordSchema,
|
||||
})
|
||||
|
||||
export const showLoginFaliedToast = (description?: string) => {
|
||||
description ||= t`Please check your credentials and try again`
|
||||
export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
|
||||
toast({
|
||||
title: t`Login attempt failed`,
|
||||
description,
|
||||
@@ -130,10 +129,6 @@ export function UserAuthForm({
|
||||
[isFirstRun]
|
||||
)
|
||||
|
||||
if (!authMethods) {
|
||||
return null
|
||||
}
|
||||
|
||||
const authProviders = authMethods.oauth2.providers ?? []
|
||||
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
||||
const passwordEnabled = authMethods.password.enabled
|
||||
@@ -142,6 +137,12 @@ export function UserAuthForm({
|
||||
|
||||
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
||||
setIsOauthLoading(true)
|
||||
|
||||
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
|
||||
redirectToOauthProvider(provider)
|
||||
return
|
||||
}
|
||||
|
||||
const oAuthOpts: OAuth2AuthConfig = {
|
||||
provider: provider.name,
|
||||
}
|
||||
@@ -150,10 +151,7 @@ export function UserAuthForm({
|
||||
const authWindow = window.open()
|
||||
if (!authWindow) {
|
||||
setIsOauthLoading(false)
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Please enable pop-ups for this site`,
|
||||
})
|
||||
showLoginFaliedToast(t`Please enable pop-ups for this site`)
|
||||
return
|
||||
}
|
||||
oAuthOpts.urlCallback = (url) => {
|
||||
@@ -171,16 +169,57 @@ export function UserAuthForm({
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user to the OAuth provider's authentication page in the same window.
|
||||
* Requires the app's base URL to be registered as a redirect URI with the OAuth provider.
|
||||
*/
|
||||
function redirectToOauthProvider(provider: AuthProviderInfo) {
|
||||
const url = new URL(provider.authURL)
|
||||
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
|
||||
sessionStorage.setItem("provider", JSON.stringify(provider))
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// auto login if password disabled and only one auth provider
|
||||
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
||||
// Add a small timeout to ensure browser is ready to handle popups
|
||||
setTimeout(() => {
|
||||
loginWithOauth(authProviders[0], true)
|
||||
}, 300)
|
||||
// handle redirect-based OAuth callback if we have a code
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get("code")
|
||||
if (code) {
|
||||
const state = params.get("state")
|
||||
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
|
||||
if (!state || provider.state !== state) {
|
||||
showLoginFaliedToast()
|
||||
} else {
|
||||
setIsOauthLoading(true)
|
||||
window.history.replaceState({}, "", window.location.pathname)
|
||||
pb.collection("users")
|
||||
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
|
||||
.then(() => $authenticated.set(pb.authStore.isValid))
|
||||
.catch((e: unknown) => showLoginFaliedToast((e as Error).message))
|
||||
.finally(() => setIsOauthLoading(false))
|
||||
}
|
||||
}
|
||||
|
||||
// auto login if password disabled and only one auth provider
|
||||
if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
||||
// Add a small timeout to ensure browser is ready to handle popups
|
||||
setTimeout(() => loginWithOauth(authProviders[0], false), 300)
|
||||
return
|
||||
}
|
||||
|
||||
// refresh auth if not in above states (required for trusted auth header)
|
||||
pb.collection("users")
|
||||
.authRefresh()
|
||||
.then((res) => {
|
||||
pb.authStore.save(res.token, res.record)
|
||||
$authenticated.set(!!pb.authStore.isValid)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!authMethods) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (otpId && mfaId) {
|
||||
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
||||
}
|
||||
@@ -248,7 +287,7 @@ export function UserAuthForm({
|
||||
)}
|
||||
<div className="sr-only">
|
||||
{/* honeypot */}
|
||||
<label htmlFor="website"></label>
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="text"
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { MoonStarIcon, SunIcon } from "lucide-react"
|
||||
import { MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const themes = ["light", "dark", "system"] as const
|
||||
const icons = [SunIcon, MoonStarIcon, SunMoonIcon] as const
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const currentIndex = themes.indexOf(theme)
|
||||
const Icon = icons[currentIndex]
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size="icon"
|
||||
aria-label={t`Toggle theme`}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
aria-label={t`Switch theme`}
|
||||
onClick={() => setTheme(themes[(currentIndex + 1) % themes.length])}
|
||||
>
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
|
||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
|
||||
<Icon
|
||||
className={cn(
|
||||
"animate-in fade-in spin-in-[-30deg] duration-200",
|
||||
currentIndex === 2 ? "size-[1.35rem]" : "size-[1.2rem]"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Toggle theme</Trans>
|
||||
<Trans>Switch theme</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LogOutIcon,
|
||||
LogsIcon,
|
||||
MenuIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
@@ -109,6 +110,10 @@ export default function Navbar() {
|
||||
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<span>S.M.A.R.T.</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "probes"))} className="flex items-center">
|
||||
<NetworkIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<Trans>Network Probes</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
|
||||
className="flex items-center"
|
||||
@@ -180,6 +185,21 @@ export default function Navbar() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "probes")}
|
||||
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Network Probes"
|
||||
onMouseEnter={() => import("@/components/routes/probes")}
|
||||
>
|
||||
<NetworkIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Network Probes</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
<Tooltip>
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, copyToClipboard, decimalString, formatMicroseconds, hourWithSeconds } from "@/lib/utils"
|
||||
import {
|
||||
GlobeIcon,
|
||||
TimerIcon,
|
||||
WifiOffIcon,
|
||||
Trash2Icon,
|
||||
ArrowLeftRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
ServerIcon,
|
||||
ClockIcon,
|
||||
NetworkIcon,
|
||||
RefreshCwIcon,
|
||||
PenBoxIcon,
|
||||
PauseCircleIcon,
|
||||
PlayCircleIcon,
|
||||
CopyIcon,
|
||||
} from "lucide-react"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { NetworkProbeRecord, SystemRecord } from "@/types"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
|
||||
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> = {
|
||||
icmp: "bg-blue-500/15! text-blue-600 dark:text-blue-400",
|
||||
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
|
||||
http: "bg-green-500/15! text-green-700 dark:text-green-400",
|
||||
}
|
||||
|
||||
const SYSTEM_STATUS_COLORS = {
|
||||
[SystemStatus.Up]: "bg-green-500",
|
||||
[SystemStatus.Down]: "bg-red-500",
|
||||
[SystemStatus.Paused]: "bg-primary/40",
|
||||
[SystemStatus.Pending]: "bg-yellow-500",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 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 [
|
||||
{
|
||||
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",
|
||||
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
||||
accessorFn: (record) => record.name || record.target,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
||||
cell: ({ row, getValue }) => {
|
||||
const probe = row.original
|
||||
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>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
accessorFn: (record) => record.system,
|
||||
sortingFn: (a, b) => {
|
||||
const allSystems = $allSystemsById.get()
|
||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||
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} />,
|
||||
cell: ({ getValue }) => {
|
||||
const system = useStore($allSystemsById)[getValue() as string] as SystemRecord | undefined
|
||||
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]
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
|
||||
accessorFn: (record) => record.target,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5 relative w-fit max-w-44 tabular-nums">
|
||||
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||
{longestTarget}
|
||||
</span>
|
||||
<span className="absolute inset-0 truncate">{getValue() as string}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
accessorFn: (record) => record.protocol,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const protocol = getValue() as string
|
||||
return <Badge className={cn("uppercase", protocolColors[protocol])}>{protocol}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "interval",
|
||||
accessorFn: (record) => record.interval,
|
||||
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>,
|
||||
},
|
||||
{
|
||||
id: "res",
|
||||
accessorFn: (record) => record.res,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
|
||||
cell: responseTimeCell,
|
||||
},
|
||||
{
|
||||
id: "res1h",
|
||||
accessorFn: (record) => record.resAvg1h,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
|
||||
cell: responseTimeCell,
|
||||
},
|
||||
{
|
||||
id: "max1h",
|
||||
accessorFn: (record) => record.resMax1h,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
|
||||
cell: responseTimeCell,
|
||||
},
|
||||
{
|
||||
id: "min1h",
|
||||
accessorFn: (record) => record.resMin1h,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
|
||||
cell: responseTimeCell,
|
||||
},
|
||||
{
|
||||
id: "loss",
|
||||
accessorFn: (record) => record.loss1h,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
|
||||
cell: ({ row }) => {
|
||||
const { loss1h, res, system } = row.original
|
||||
const systemRecord = useStore($allSystemsById)[system]
|
||||
|
||||
if (loss1h === undefined || (!res && !loss1h)) {
|
||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||
}
|
||||
|
||||
const muted = isMuted(row.original, systemRecord)
|
||||
let color = "bg-green-500"
|
||||
if (muted) {
|
||||
color = "bg-muted-foreground/50"
|
||||
} else if (loss1h) {
|
||||
color = loss1h > 20 ? "bg-red-500" : "bg-yellow-500"
|
||||
}
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||
{loss1h === 100 ? loss1h : decimalString(loss1h, loss1h >= 10 ? 1 : 2)}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as number
|
||||
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",
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => null,
|
||||
size: 40,
|
||||
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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-10">
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||
{!isBulkAction && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onEdit?.(row.original)
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</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({
|
||||
column,
|
||||
name,
|
||||
Icon,
|
||||
}: {
|
||||
column: Column<NetworkProbeRecord>
|
||||
name: string
|
||||
Icon: React.ElementType
|
||||
}) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type Row,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} 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 { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { isReadOnlyUser } from "@/lib/api"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $allSystemsById, $chartTime, $direction } from "@/lib/stores"
|
||||
import { cn, isVisuallyLonger, useBrowserStorage } from "@/lib/utils"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
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({
|
||||
systemId,
|
||||
probes,
|
||||
}: {
|
||||
systemId?: string
|
||||
probes: NetworkProbeRecord[]
|
||||
}) {
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-np-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([])
|
||||
const [editingProbe, setEditingProbe] = useState<NetworkProbeRecord>()
|
||||
const { toast } = useToast()
|
||||
const canManageProbes = !isReadOnlyUser()
|
||||
|
||||
const [longestName, longestTarget] = useMemo(() => {
|
||||
let longestName = ""
|
||||
let longestTarget = ""
|
||||
for (const p of probes) {
|
||||
const name = p.name || p.target
|
||||
if (isVisuallyLonger(name, longestName)) {
|
||||
longestName = name
|
||||
}
|
||||
if (isVisuallyLonger(p.target, longestTarget)) {
|
||||
longestTarget = p.target
|
||||
}
|
||||
}
|
||||
return [longestName, longestTarget]
|
||||
}, [probes])
|
||||
|
||||
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(() => {
|
||||
let columns = getProbeColumns(longestName, longestTarget, {
|
||||
onEdit: setEditingProbe,
|
||||
onDelete: handleDeleteRequest,
|
||||
onSetEnabled: handleSetEnabled,
|
||||
})
|
||||
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||
columns = canManageProbes ? columns : columns.filter((col) => col.id !== "actions")
|
||||
return columns
|
||||
}, [canManageProbes, handleDeleteRequest, handleSetEnabled, longestName, systemId, longestTarget])
|
||||
|
||||
const table = useReactTable({
|
||||
data: probes,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
defaultColumn: {
|
||||
sortUndefined: "last",
|
||||
size: 900,
|
||||
minSize: 0,
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const probe = row.original
|
||||
const systemName = $allSystemsById.get()[probe.system]?.name ?? ""
|
||||
const searchString = `${probe.name}${probe.target}${probe.protocol}${systemName}`.toLocaleLowerCase()
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
return (
|
||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>Network Probes</Trans>
|
||||
</CardTitle>
|
||||
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||
<Trans>Response time monitoring from agents.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:ms-auto flex items-center gap-2">
|
||||
{probes.length > 0 && (
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="ms-auto px-4 w-full max-w-full md:w-50"
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} rowSelection={rowSelection} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkProbesTable = memo(function NetworkProbeTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
rowSelection: _rowSelection,
|
||||
}: {
|
||||
table: TableType<NetworkProbeRecord>
|
||||
rows: Row<NetworkProbeRecord>[]
|
||||
colLength: number
|
||||
rowSelection: RowSelectionState
|
||||
}) {
|
||||
// The virtualizer will need a reference to the scrollable container element
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [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>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 54,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full text-nowrap">
|
||||
<NetworkProbeTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return (
|
||||
<NetworkProbeTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
virtualRow={virtualRow}
|
||||
isSelected={row.getIsSelected()}
|
||||
openSheet={openSheet}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
<Trans>No results.</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<NetworkProbeSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setSheetOpen(nextOpen)
|
||||
}}
|
||||
probe={activeProbe}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
isSelected,
|
||||
openSheet,
|
||||
}: {
|
||||
row: Row<NetworkProbeRecord>
|
||||
virtualRow: VirtualItem
|
||||
isSelected: boolean
|
||||
openSheet: (probe: NetworkProbeRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={isSelected && "selected"}
|
||||
className="cursor-pointer transition-opacity"
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-0"
|
||||
style={{
|
||||
width: `${cell.column.getSize()}px`,
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { pb } from "@/lib/api"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ChevronDownIcon, ListIcon, ServerIcon } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { $systems } from "@/lib/stores"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
import * as v from "valibot"
|
||||
|
||||
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 [bulkOpen, setBulkOpen] = useState(false)
|
||||
const [bulkInput, setBulkInput] = useState("")
|
||||
const [bulkLoading, setBulkLoading] = useState(false)
|
||||
const [bulkSelectedSystemId, setBulkSelectedSystemId] = 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 [selectedSystemId, setSelectedSystemId] = useState(probe?.system ?? "")
|
||||
const systems = useStore($systems)
|
||||
const { toast } = useToast()
|
||||
const { t } = useLingui()
|
||||
const isEditing = !!probe
|
||||
const targetName = target.replace(/^https?:\/\//, "")
|
||||
|
||||
// When the dialog is opened, initialize form fields with probe values (if editing) or defaults (if adding).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const selectedSystem = systemId ?? selectedSystemId
|
||||
if (!selectedSystem) {
|
||||
throw new Error("Select a system.")
|
||||
}
|
||||
const payload = buildProbePayload(
|
||||
{
|
||||
system: selectedSystem,
|
||||
target,
|
||||
protocol,
|
||||
port: protocol === "tcp" ? Number(port) : 0,
|
||||
interval: probeInterval,
|
||||
name,
|
||||
},
|
||||
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)
|
||||
} catch (err: unknown) {
|
||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? <Trans>Edit {{ foo: t`Network Probe` }}</Trans> : <Trans>Add {{ foo: t`Network Probe` }}</Trans>}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Configure response monitoring from this agent.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
|
||||
{!systemId && (
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>System</Trans>
|
||||
</Label>
|
||||
<Select value={selectedSystemId} onValueChange={setSelectedSystemId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select a system`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{systems.map((sys) => (
|
||||
<SelectItem key={sys.id} value={sys.id}>
|
||||
{sys.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Target</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder={protocol === "http" ? "http://localhost:8090" : "1.1.1.1"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Protocol</Trans>
|
||||
</Label>
|
||||
|
||||
<Select value={protocol} onValueChange={(value) => setProtocol(value as ProbeProtocol)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="icmp">ICMP</SelectItem>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{protocol === "tcp" && (
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Port</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="443"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Interval (seconds)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={probeInterval}
|
||||
onChange={(e) => setProbeInterval(e.target.value)}
|
||||
min={1}
|
||||
max={3600}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Name (optional)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={targetName || t`e.g. Cloudflare DNS`}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{!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)}>
|
||||
{loading ? (
|
||||
isEditing ? (
|
||||
<Trans>Saving...</Trans>
|
||||
) : (
|
||||
<Trans>Creating...</Trans>
|
||||
)
|
||||
) : isEditing ? (
|
||||
<Trans>Save {{ foo: t`Probe` }}</Trans>
|
||||
) : (
|
||||
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
smart: "/smart",
|
||||
probes: "/probes",
|
||||
system: `/system/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
forgot_password: `/forgot-password`,
|
||||
|
||||
25
internal/site/src/components/routes/probes.tsx
Normal file
25
internal/site/src/components/routes/probes.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect } from "react"
|
||||
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
import { useNetworkProbes } from "@/lib/use-network-probes"
|
||||
|
||||
export default memo(() => {
|
||||
const { t } = useLingui()
|
||||
const probes = useNetworkProbes({})
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t`Network Probes`} / Beszel`
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<NetworkProbesTableNew probes={probes} />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import { isAdmin, pb } from "@/lib/api"
|
||||
import type { UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
import { QuietHours } from "./quiet-hours"
|
||||
import type { ClientResponseError } from "pocketbase"
|
||||
|
||||
interface ShoutrrrUrlCardProps {
|
||||
url: string
|
||||
@@ -59,10 +60,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
try {
|
||||
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
||||
await saveSettings(parsedData)
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: t`Failed to save settings`,
|
||||
description: e.message,
|
||||
description: (e as Error).message,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
@@ -136,12 +137,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 shrink-0"
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<Button type="button" variant="outline" className="h-10 shrink-0" onClick={addWebhook}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add URL</Trans>
|
||||
@@ -180,25 +176,34 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
)
|
||||
}
|
||||
|
||||
function showTestNotificationError(msg: string) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: msg ?? t`Failed to send test notification`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const sendTestNotification = async () => {
|
||||
setIsLoading(true)
|
||||
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t`Test notification sent`,
|
||||
description: t`Check your notification service`,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: res.err ?? t`Failed to send test notification`,
|
||||
variant: "destructive",
|
||||
})
|
||||
try {
|
||||
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t`Test notification sent`,
|
||||
description: t`Check your notification service`,
|
||||
})
|
||||
} else {
|
||||
showTestNotificationError(res.err)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showTestNotificationError((e as ClientResponseError).data?.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,9 +11,9 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTable } from "./system/lazy-tables"
|
||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||
import { ContainerIcon, CpuIcon, HardDriveIcon, NetworkIcon, TerminalSquareIcon } from "lucide-react"
|
||||
import { GpuIcon } from "../ui/icons"
|
||||
import SystemdTable from "../systemd-table/systemd-table"
|
||||
import ContainersTable from "../containers-table/containers-table"
|
||||
@@ -65,7 +65,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const hasGpu = hasGpuData || hasGpuPowerData
|
||||
|
||||
// keep tabsRef in sync for keyboard navigation
|
||||
const tabs = ["core", "disk"]
|
||||
const tabs = ["core", "network", "disk"]
|
||||
if (hasGpu) tabs.push("gpu")
|
||||
if (hasContainers) tabs.push("containers")
|
||||
if (hasSystemd) tabs.push("services")
|
||||
@@ -145,6 +145,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
|
||||
|
||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||
|
||||
<LazyNetworkProbesTable systemId={system.id} systemData={systemData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -157,6 +159,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<CpuIcon className="size-3.5" />
|
||||
<Trans context="Core system metrics">Core</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="network" className="w-full flex items-center gap-1.5">
|
||||
<NetworkIcon className="size-3.5" />
|
||||
<Trans>Network</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="disk" className="w-full flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5" />
|
||||
<Trans>Disk</Trans>
|
||||
@@ -184,16 +190,26 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<TabsContent value="core" forceMount className={activeTab === "core" ? "contents" : "hidden"}>
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<CpuChart {...coreProps} />
|
||||
<MemoryChart {...coreProps} />
|
||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
<MemoryChart {...coreProps} />
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||
<BatteryChart {...coreProps} />
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" forceMount className={activeTab === "network" ? "contents" : "hidden"}>
|
||||
{mountedTabs.has("network") && (
|
||||
<>
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
</div>
|
||||
<LazyNetworkProbesTable systemId={system.id} systemData={systemData} />
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
||||
{mountedTabs.has("disk") && (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { timeTicks } from "d3-time"
|
||||
import { getPbTimestamp, pb } from "@/lib/api"
|
||||
import { chartTimeData } from "@/lib/utils"
|
||||
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
|
||||
import type {
|
||||
ChartData,
|
||||
ChartDataContainer,
|
||||
ChartTimes,
|
||||
ContainerStatsRecord,
|
||||
NetworkProbeStatsRecord,
|
||||
SystemStatsRecord,
|
||||
} from "@/types"
|
||||
|
||||
type ChartTimeData = {
|
||||
time: number
|
||||
@@ -17,31 +23,10 @@ export const cache = new Map<
|
||||
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
|
||||
>()
|
||||
|
||||
// create ticks and domain for charts
|
||||
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
||||
const cached = cache.get("td") as ChartTimeData | undefined
|
||||
if (cached && cached.chartTime === chartTime) {
|
||||
if (!lastCreated || cached.time >= lastCreated) {
|
||||
return cached.data
|
||||
}
|
||||
}
|
||||
|
||||
// const buffer = chartTime === "1m" ? 400 : 20_000
|
||||
const now = new Date(Date.now())
|
||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||
const data = {
|
||||
ticks,
|
||||
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
|
||||
}
|
||||
cache.set("td", { time: now.getTime(), data, chartTime })
|
||||
return data
|
||||
}
|
||||
|
||||
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
|
||||
* 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 }>(
|
||||
prev: T[],
|
||||
prev: T[] = [],
|
||||
newRecords: T[],
|
||||
expectedInterval: number,
|
||||
maxLen?: number
|
||||
@@ -66,17 +51,18 @@ export function appendData<T extends { created: string | number | null }>(
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkProbeStatsRecord>(
|
||||
collection: string,
|
||||
systemId: string,
|
||||
chartTime: ChartTimes
|
||||
chartTime: ChartTimes,
|
||||
cachedStats?: { created: string | number | null }[],
|
||||
createdIsNumber?: boolean
|
||||
): Promise<T[]> {
|
||||
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
|
||||
const lastCached = cachedStats?.at(-1)?.created as number
|
||||
return await pb.collection<T>(collection).getFullList({
|
||||
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||
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,
|
||||
}),
|
||||
fields: "created,stats",
|
||||
@@ -84,11 +70,11 @@ export async function getStats<T extends SystemStatsRecord | ContainerStatsRecor
|
||||
})
|
||||
}
|
||||
|
||||
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
|
||||
const result = [] as ChartData["containerData"]
|
||||
export function makeContainerData(containers: ContainerStatsRecord[]): ChartDataContainer[] {
|
||||
const result = [] as ChartDataContainer[]
|
||||
for (const { created, stats } of containers) {
|
||||
if (!created) {
|
||||
result.push({ created: null } as ChartData["containerData"][0])
|
||||
result.push({ created: null } as ChartDataContainer)
|
||||
continue
|
||||
}
|
||||
result.push(makeContainerPoint(new Date(created).getTime(), stats))
|
||||
@@ -97,11 +83,8 @@ export function makeContainerData(containers: ContainerStatsRecord[]): ChartData
|
||||
}
|
||||
|
||||
/** Transform a single realtime container stats message into a ChartDataContainer point. */
|
||||
export function makeContainerPoint(
|
||||
created: number,
|
||||
stats: ContainerStatsRecord["stats"]
|
||||
): ChartData["containerData"][0] {
|
||||
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
|
||||
export function makeContainerPoint(created: number, stats: ContainerStatsRecord["stats"]): ChartDataContainer {
|
||||
const point: ChartDataContainer = { created } as ChartDataContainer
|
||||
for (const container of stats) {
|
||||
;(point as Record<string, unknown>)[container.n] = container
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemDat
|
||||
return null
|
||||
}
|
||||
|
||||
const title = extraFsName ? t`${extraFsName} I/O` : t`Disk I/O`
|
||||
const title = extraFsName ? `${extraFsName} I/O` : t`Disk I/O`
|
||||
const description = extraFsName ? t`Throughput of ${extraFsName}` : t`Throughput of root filesystem`
|
||||
|
||||
const hasMoreIOMetrics = chartData.systemStats?.some((record) => record.stats?.dios?.at(0))
|
||||
@@ -225,7 +225,10 @@ export function DiskUtilizationChart({ systemData, extraFsName }: { systemData:
|
||||
cornerEl={maxValSelect}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`I/O Utilization`}
|
||||
title={t({
|
||||
message: `I/O Utilization`,
|
||||
context: "Percent of time the disk is busy with I/O",
|
||||
})}
|
||||
description={t`Percent of time the disk is busy with I/O`}
|
||||
// legend={true}
|
||||
className="min-h-auto"
|
||||
@@ -239,7 +242,7 @@ export function DiskUtilizationChart({ systemData, extraFsName }: { systemData:
|
||||
chartProps={{ syncId: "io" }}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Utilization`,
|
||||
label: t({ message: "Utilization", context: "Disk I/O utilization" }),
|
||||
dataKey: utilFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import LineChartDefault from "@/components/charts/line-chart"
|
||||
import type { DataPoint } from "@/components/charts/line-chart"
|
||||
import { decimalString, formatMicroseconds, toFixedFloat } from "@/lib/utils"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { ChartCard, FilterBar } from "../chart-card"
|
||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
||||
import { useMemo } from "react"
|
||||
import { atom } from "nanostores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
const $filter = atom("")
|
||||
|
||||
type ProbeChartProps = {
|
||||
probeStats: NetworkProbeStatsRecord[]
|
||||
grid?: boolean
|
||||
probes: NetworkProbeRecord[]
|
||||
chartData: ChartData
|
||||
empty: boolean
|
||||
showFilter?: boolean
|
||||
}
|
||||
|
||||
type ProbeChartBaseProps = ProbeChartProps & {
|
||||
valueIndex: number
|
||||
title: string
|
||||
description: string
|
||||
tickFormatter: (value: number) => string
|
||||
contentFormatter: ({ value }: { value: number | string }) => string | number
|
||||
domain?: [number | "auto", number | "auto"]
|
||||
}
|
||||
|
||||
function ProbeChart({
|
||||
probeStats,
|
||||
grid,
|
||||
probes,
|
||||
chartData,
|
||||
empty,
|
||||
valueIndex,
|
||||
title,
|
||||
description,
|
||||
tickFormatter,
|
||||
contentFormatter,
|
||||
domain,
|
||||
showFilter = probes.length > 1,
|
||||
}: ProbeChartBaseProps) {
|
||||
const storedFilter = useStore($filter)
|
||||
const filter = showFilter ? storedFilter : ""
|
||||
|
||||
const { dataPoints, visibleKeys } = useMemo(() => {
|
||||
const sortedProbes = [...probes].sort((a, b) => b.resAvg1h - a.resAvg1h)
|
||||
const count = sortedProbes.length
|
||||
const points: DataPoint<NetworkProbeStatsRecord>[] = []
|
||||
const visibleIDs: string[] = []
|
||||
const filterTerms = filter
|
||||
? filter
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter((term) => term.length > 0)
|
||||
: []
|
||||
const dot = chartData.chartTime === "1m"
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = sortedProbes[i]
|
||||
const label = p.name || p.target
|
||||
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => label.toLowerCase().includes(term))
|
||||
if (filtered) {
|
||||
continue
|
||||
}
|
||||
visibleIDs.push(p.id)
|
||||
points.push({
|
||||
order: i,
|
||||
label,
|
||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[p.id]?.[valueIndex] ?? "-",
|
||||
dot,
|
||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||
})
|
||||
}
|
||||
return { dataPoints: points, visibleKeys: visibleIDs }
|
||||
}, [probes, filter, valueIndex, chartData.chartTime])
|
||||
|
||||
const filteredProbeStats = useMemo(() => {
|
||||
if (!visibleKeys.length) return probeStats
|
||||
return probeStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
|
||||
}, [probeStats, visibleKeys])
|
||||
|
||||
const legend = dataPoints.length < 10 && showFilter
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
legend={legend || !showFilter}
|
||||
cornerEl={showFilter ? <FilterBar store={$filter} /> : undefined}
|
||||
empty={empty}
|
||||
title={title}
|
||||
description={description}
|
||||
grid={grid}
|
||||
>
|
||||
<LineChartDefault
|
||||
truncate
|
||||
chartData={chartData}
|
||||
customData={filteredProbeStats}
|
||||
dataPoints={dataPoints}
|
||||
domain={domain ?? ["auto", "auto"]}
|
||||
connectNulls
|
||||
tickFormatter={tickFormatter}
|
||||
contentFormatter={contentFormatter}
|
||||
legend={legend}
|
||||
filter={filter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResponseChart({ probeStats, grid, probes, chartData, empty }: ProbeChartProps) {
|
||||
const { t } = useLingui()
|
||||
|
||||
return (
|
||||
<ProbeChart
|
||||
probeStats={probeStats}
|
||||
grid={grid}
|
||||
probes={probes}
|
||||
chartData={chartData}
|
||||
empty={empty}
|
||||
valueIndex={0}
|
||||
title={t`Response`}
|
||||
description={t`Average response time`}
|
||||
tickFormatter={(value) => formatMicroseconds(value, false)}
|
||||
contentFormatter={({ value }) => {
|
||||
if (typeof value !== "number") {
|
||||
return value
|
||||
}
|
||||
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) {
|
||||
const { t } = useLingui()
|
||||
|
||||
return (
|
||||
<ProbeChart
|
||||
probeStats={probeStats}
|
||||
grid={grid}
|
||||
probes={probes}
|
||||
chartData={chartData}
|
||||
empty={empty}
|
||||
valueIndex={3}
|
||||
title={t`Loss`}
|
||||
description={t`Packet loss (%)`}
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
|
||||
contentFormatter={({ value }) => {
|
||||
if (typeof value !== "number") {
|
||||
return value
|
||||
}
|
||||
return `${decimalString(value, 2)}%`
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -84,6 +84,8 @@ export default memo(function DiskIOSheet({
|
||||
|
||||
const chartProps = { syncId: "io" }
|
||||
|
||||
const queueDepthTranslation = t({ message: "Queue Depth", context: "Disk I/O average queue depth" })
|
||||
|
||||
return (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
@@ -120,7 +122,7 @@ export default memo(function DiskIOSheet({
|
||||
reverseStackOrder={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t({ message: "Write", comment: "Disk write" }),
|
||||
label: t`Write`,
|
||||
dataKey: writeFn,
|
||||
color: 3,
|
||||
opacity: 0.4,
|
||||
@@ -128,7 +130,7 @@ export default memo(function DiskIOSheet({
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
label: t({ message: "Read", comment: "Disk read" }),
|
||||
label: t`Read`,
|
||||
dataKey: readFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
@@ -152,8 +154,11 @@ export default memo(function DiskIOSheet({
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`I/O Time`}
|
||||
description={t`Total time spent on read/write (can exceed 100%)`}
|
||||
title={t({ message: "I/O Time", context: "Disk I/O total time spent on read/write" })}
|
||||
description={t({
|
||||
message: "Total time spent on read/write (can exceed 100%)",
|
||||
context: "Disk I/O",
|
||||
})}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
@@ -192,7 +197,7 @@ export default memo(function DiskIOSheet({
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Average Queue Depth`}
|
||||
title={queueDepthTranslation}
|
||||
description={t`Average number of I/O operations waiting to be serviced`}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
@@ -206,7 +211,7 @@ export default memo(function DiskIOSheet({
|
||||
chartProps={chartProps}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Queue Depth`,
|
||||
label: queueDepthTranslation,
|
||||
dataKey: weightedIOFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
@@ -220,8 +225,11 @@ export default memo(function DiskIOSheet({
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`I/O Await`}
|
||||
description={t`Average service time per operation`}
|
||||
title={t({ message: "I/O Await", context: "Disk I/O average operation time (iostat await)" })}
|
||||
description={t({
|
||||
message: "Average queue to completion time per operation",
|
||||
context: "Disk I/O average operation time (iostat await)",
|
||||
})}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
// legend={true}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { lazy } from "react"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ResponseChart, LossChart } from "./charts/probes-charts"
|
||||
import type { SystemData } from "./use-system-data"
|
||||
import { $chartTime } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { useNetworkProbes, useNetworkProbeStats } from "@/lib/use-network-probes"
|
||||
|
||||
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
||||
|
||||
@@ -34,3 +39,47 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkProbesTable = lazy(() => import("@/components/network-probes-table/network-probes-table"))
|
||||
|
||||
export function LazyNetworkProbesTable({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && <ProbesTable systemId={systemId} systemData={systemData} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbesTable({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
|
||||
const { grid, chartData } = systemData ?? {}
|
||||
const chartTime = useStore($chartTime)
|
||||
|
||||
const probes = useNetworkProbes({ systemId })
|
||||
const probeStats = useNetworkProbeStats({ systemId, chartTime })
|
||||
|
||||
return (
|
||||
<>
|
||||
<NetworkProbesTable systemId={systemId} probes={probes} />
|
||||
{!!chartData && !!probes.length && (
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<ResponseChart
|
||||
probeStats={probeStats}
|
||||
grid={grid}
|
||||
probes={probes}
|
||||
chartData={chartData}
|
||||
empty={!probeStats.length}
|
||||
/>
|
||||
<LossChart
|
||||
probeStats={probeStats}
|
||||
grid={grid}
|
||||
probes={probes}
|
||||
chartData={chartData}
|
||||
empty={!probeStats.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
toFixedFloat,
|
||||
formatTemperature,
|
||||
cn,
|
||||
getVisualStringWidth,
|
||||
secondsToString,
|
||||
hourWithSeconds,
|
||||
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"
|
||||
|
||||
export const createColumns = (
|
||||
longestName: number,
|
||||
longestModel: number,
|
||||
longestDevice: number
|
||||
longestName: string,
|
||||
longestModel: string,
|
||||
longestDevice: string
|
||||
): ColumnDef<SmartDeviceRecord>[] => [
|
||||
{
|
||||
id: "system",
|
||||
@@ -123,8 +122,11 @@ export const createColumns = (
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
return (
|
||||
<div className="ms-1.5 max-w-40 block truncate" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{allSystems[getValue() as string]?.name ?? ""}
|
||||
<div className="ms-1.5 relative w-fit max-w-44">
|
||||
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||
{longestName}
|
||||
</span>
|
||||
<span className="absolute inset-0 truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -134,12 +136,11 @@ export const createColumns = (
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div
|
||||
className="font-medium max-w-40 truncate ms-1"
|
||||
title={getValue() as string}
|
||||
style={{ width: `${longestDevice / 1.05}ch` }}
|
||||
>
|
||||
{getValue() as string}
|
||||
<div className="font-medium ms-1 relative w-fit max-w-44" title={getValue() as string}>
|
||||
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||
{longestDevice}
|
||||
</span>
|
||||
<span className="absolute inset-0 truncate">{getValue() as string}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -150,12 +151,11 @@ export const createColumns = (
|
||||
<HeaderButton column={column} name={t({ message: "Model", comment: "Device model" })} Icon={Box} />
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<div
|
||||
className="max-w-48 truncate ms-1"
|
||||
title={getValue() as string}
|
||||
style={{ width: `${longestModel / 1.05}ch` }}
|
||||
>
|
||||
{getValue() as string}
|
||||
<div className="ms-1 relative w-fit max-w-44" title={getValue() as string}>
|
||||
<span className="invisible block whitespace-nowrap" aria-hidden="true">
|
||||
{longestModel}
|
||||
</span>
|
||||
<span className="absolute inset-0 truncate">{getValue() as string}</span>
|
||||
</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
|
||||
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) {
|
||||
return result
|
||||
}
|
||||
@@ -318,10 +318,16 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
if (!systemId && !seenSystems.has(device.system)) {
|
||||
seenSystems.add(device.system)
|
||||
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
|
||||
}, [smartDevices, systemId, allSystems])
|
||||
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
SystemStatsRecord,
|
||||
} from "@/types"
|
||||
import { $router, navigate } from "../../router"
|
||||
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
|
||||
import { appendData, cache, getStats, makeContainerData, makeContainerPoint } from "./chart-data"
|
||||
|
||||
export type SystemData = ReturnType<typeof useSystemData>
|
||||
|
||||
@@ -151,16 +151,11 @@ export function useSystemData(id: string) {
|
||||
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
|
||||
|
||||
const chartData: ChartData = useMemo(() => {
|
||||
const lastCreated = Math.max(
|
||||
(systemStats.at(-1)?.created as number) ?? 0,
|
||||
(containerData.at(-1)?.created as number) ?? 0
|
||||
)
|
||||
return {
|
||||
systemStats,
|
||||
containerData,
|
||||
chartTime,
|
||||
orientation: direction === "rtl" ? "right" : "left",
|
||||
...getTimeData(chartTime, lastCreated),
|
||||
agentVersion,
|
||||
}
|
||||
}, [systemStats, containerData, direction])
|
||||
@@ -192,7 +187,7 @@ export function useSystemData(id: string) {
|
||||
|
||||
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
||||
const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined
|
||||
if (lastCreated && Date.now() - lastCreated < expectedInterval) {
|
||||
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -200,8 +195,8 @@ export function useSystemData(id: string) {
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
// If another request has been made since this one, ignore the results
|
||||
if (requestId !== statsRequestId.current) {
|
||||
@@ -293,7 +288,7 @@ export function useSystemData(id: string) {
|
||||
// derived values
|
||||
const isLongerChart = !["1m", "1h"].includes(chartTime)
|
||||
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 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 { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||
import { $longestSystemName, $userSettings } from "@/lib/stores"
|
||||
import {
|
||||
cn,
|
||||
copyToClipboard,
|
||||
@@ -110,20 +110,23 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
|
||||
// match filter value against name or translated status
|
||||
return (row, _, newFilterInput) => {
|
||||
const { name, status } = row.original
|
||||
const sys = row.original
|
||||
if (sys.host.includes(newFilterInput) || sys.info.v?.includes(newFilterInput)) {
|
||||
return true
|
||||
}
|
||||
if (newFilterInput !== filterInput) {
|
||||
filterInput = newFilterInput
|
||||
filterInputLower = newFilterInput.toLowerCase()
|
||||
}
|
||||
let nameLower = nameCache.get(name)
|
||||
let nameLower = nameCache.get(sys.name)
|
||||
if (nameLower === undefined) {
|
||||
nameLower = name.toLowerCase()
|
||||
nameCache.set(name, nameLower)
|
||||
nameLower = sys.name.toLowerCase()
|
||||
nameCache.set(sys.name, nameLower)
|
||||
}
|
||||
if (nameLower.includes(filterInputLower)) {
|
||||
return true
|
||||
}
|
||||
const statusLower = statusTranslations[status as keyof typeof statusTranslations]
|
||||
const statusLower = statusTranslations[sys.status as keyof typeof statusTranslations]
|
||||
return statusLower?.includes(filterInputLower) || false
|
||||
}
|
||||
})(),
|
||||
@@ -132,7 +135,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
Icon: ServerIcon,
|
||||
cell: (info) => {
|
||||
const { name, id } = info.row.original
|
||||
const longestName = useStore($longestSystemNameLen)
|
||||
const longestName = useStore($longestSystemName)
|
||||
const linkUrl = getPagePath($router, "system", { id })
|
||||
|
||||
return (
|
||||
@@ -142,8 +145,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
<Link
|
||||
href={linkUrl}
|
||||
tabIndex={-1}
|
||||
className="truncate z-10 relative"
|
||||
style={{ width: `${longestName / 1.05}ch` }}
|
||||
className="relative w-fit max-w-48 z-10"
|
||||
onMouseEnter={(e) => {
|
||||
// set title on hover if text is truncated to show full name
|
||||
const a = e.currentTarget
|
||||
@@ -154,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>
|
||||
</span>
|
||||
<Link href={linkUrl} className="inset-0 absolute size-full" aria-label={name}></Link>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { JSX } from "react"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import { chartTimeData, cn } from "@/lib/utils"
|
||||
import type { ChartData } from "@/types"
|
||||
import type { ChartTimes } from "@/types"
|
||||
import { Separator } from "./separator"
|
||||
import { AxisDomain } from "recharts/types/util/types"
|
||||
import type { AxisDomain } from "recharts/types/util/types"
|
||||
import { timeTicks } from "d3-time"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
@@ -101,7 +101,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
labelKey?: string
|
||||
unit?: string
|
||||
filter?: string
|
||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||
contentFormatter?: (item: unknown, key: string) => React.ReactNode | string
|
||||
truncate?: boolean
|
||||
showTotal?: boolean
|
||||
totalLabel?: React.ReactNode
|
||||
@@ -175,7 +175,13 @@ const ChartTooltipContent = React.forwardRef<
|
||||
}
|
||||
|
||||
const totalKey = "__total__"
|
||||
const totalItem: any = {
|
||||
const totalItem: {
|
||||
value: number
|
||||
name: string
|
||||
dataKey: string
|
||||
color: string | undefined
|
||||
payload?: unknown
|
||||
} = {
|
||||
value: totalValue,
|
||||
name: totalName,
|
||||
dataKey: totalKey,
|
||||
@@ -400,26 +406,57 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
let cachedAxis: JSX.Element
|
||||
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
||||
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
|
||||
return cachedAxis
|
||||
interface XAxisData {
|
||||
el: React.ReactElement
|
||||
domain: [number, number]
|
||||
}
|
||||
|
||||
const xAxisCache = new Map<ChartTimes, XAxisData>()
|
||||
|
||||
function createXAxisData(chartTime: ChartTimes): XAxisData {
|
||||
// console.log("Creating XAxis for", chartTime, new Date())
|
||||
const axisEndTime = Date.now() + 500
|
||||
const axisEndDate = new Date(axisEndTime)
|
||||
const startTime = chartTimeData[chartTime].getOffset(axisEndDate)
|
||||
const ticks = timeTicks(startTime, axisEndDate, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||
const domain: [number, number] = [startTime.getTime(), axisEndTime]
|
||||
|
||||
return {
|
||||
domain,
|
||||
el: (
|
||||
<RechartsPrimitive.XAxis
|
||||
dataKey="created"
|
||||
domain={domain}
|
||||
ticks={ticks}
|
||||
allowDataOverflow
|
||||
type="number"
|
||||
scale="time"
|
||||
minTickGap={12}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
),
|
||||
}
|
||||
cachedAxis = (
|
||||
<RechartsPrimitive.XAxis
|
||||
dataKey="created"
|
||||
domain={domain}
|
||||
ticks={ticks}
|
||||
allowDataOverflow
|
||||
type="number"
|
||||
scale="time"
|
||||
minTickGap={12}
|
||||
tickMargin={8}
|
||||
axisLine={false}
|
||||
tickFormatter={chartTimeData[chartTime].format}
|
||||
/>
|
||||
)
|
||||
return cachedAxis
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const axisData = createXAxisData(chartTime)
|
||||
xAxisCache.set(chartTime, axisData)
|
||||
return axisData.el
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -41,7 +41,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
||||
<tr
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...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())
|
||||
if (createdIsNumber) {
|
||||
return d.getTime()
|
||||
}
|
||||
const year = d.getUTCFullYear()
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getUTCDate()).padStart(2, "0")
|
||||
|
||||
@@ -70,7 +70,5 @@ export const $copyContent = atom("")
|
||||
/** Direction for localization */
|
||||
export const $direction = atom<"ltr" | "rtl">("ltr")
|
||||
|
||||
/** Longest system name length. Used to set table column width. I know this
|
||||
* is stupid but the table is virtualized and I know this will work.
|
||||
*/
|
||||
export const $longestSystemNameLen = atom(8)
|
||||
/** Longest system name string. Used to reserve width in virtualized tables. */
|
||||
export const $longestSystemName = atom("")
|
||||
|
||||
@@ -5,20 +5,17 @@ import {
|
||||
$allSystemsById,
|
||||
$allSystemsByName,
|
||||
$downSystems,
|
||||
$longestSystemNameLen,
|
||||
$longestSystemName,
|
||||
$pausedSystems,
|
||||
$upSystems,
|
||||
} from "@/lib/stores"
|
||||
import { getVisualStringWidth, updateFavicon } from "@/lib/utils"
|
||||
import { isVisuallyLonger, updateFavicon } from "@/lib/utils"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import { SystemStatus } from "./enums"
|
||||
|
||||
const COLLECTION = pb.collection<SystemRecord>("systems")
|
||||
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
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: typescript rocks
|
||||
let unsub: (() => void) | undefined | void
|
||||
@@ -44,7 +41,7 @@ export function init() {
|
||||
}
|
||||
|
||||
if (!newSystem) {
|
||||
onSystemsChanged(newSystems, undefined)
|
||||
onSystemsChanged(newSystems, newSystem, oldSystem)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,20 +65,28 @@ export function init() {
|
||||
}
|
||||
|
||||
// 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 */
|
||||
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
|
||||
/** Update the longest system name string and favicon based on system status */
|
||||
function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: SystemRecord, oldSystem?: SystemRecord) {
|
||||
const downSystemsStore = $downSystems.get()
|
||||
const downSystems = Object.values(downSystemsStore)
|
||||
|
||||
// Update longest system name length
|
||||
const longestName = $longestSystemNameLen.get()
|
||||
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, getVisualStringWidth(changedSystem?.name || ""))
|
||||
if (nameLen > longestName) {
|
||||
$longestSystemNameLen.set(nameLen)
|
||||
// if the old system's old name was the longest, we need to find the new longest name
|
||||
// otherwise, if the changed system's new name is longer than the current longest, update it
|
||||
const longestName = $longestSystemName.get()
|
||||
if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) {
|
||||
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)
|
||||
|
||||
289
internal/site/src/lib/use-network-probes.ts
Normal file
289
internal/site/src/lib/use-network-probes.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { chartTimeData } from "@/lib/utils"
|
||||
import type { ChartTimes, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { getStats, appendData } from "@/components/routes/system/chart-data"
|
||||
import { pb } from "@/lib/api"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { RecordListOptions, RecordSubscription } from "pocketbase"
|
||||
|
||||
const cache = new Map<string, NetworkProbeStatsRecord[]>()
|
||||
|
||||
function getCacheValue(systemId: string, chartTime: ChartTimes | "rt") {
|
||||
return cache.get(`${systemId}${chartTime}`) || []
|
||||
}
|
||||
|
||||
function appendCacheValue(
|
||||
systemId: string,
|
||||
chartTime: ChartTimes | "rt",
|
||||
newStats: NetworkProbeStatsRecord[],
|
||||
maxPoints = 100
|
||||
) {
|
||||
const cache_key = `${systemId}${chartTime}`
|
||||
const existingStats = getCacheValue(systemId, chartTime)
|
||||
if (existingStats) {
|
||||
const { expectedInterval } = chartTimeData[chartTime]
|
||||
const updatedStats = appendData(existingStats, newStats, expectedInterval, maxPoints)
|
||||
cache.set(cache_key, updatedStats)
|
||||
return updatedStats
|
||||
} else {
|
||||
cache.set(cache_key, newStats)
|
||||
return newStats
|
||||
}
|
||||
}
|
||||
|
||||
const NETWORK_PROBE_FIELDS =
|
||||
"id,name,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
|
||||
|
||||
interface UseNetworkProbesProps {
|
||||
systemId?: string
|
||||
}
|
||||
|
||||
export function useNetworkProbes(props: UseNetworkProbesProps) {
|
||||
const { systemId } = props
|
||||
|
||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
||||
const pendingProbeEvents = useRef(new Map<string, RecordSubscription<NetworkProbeRecord>>())
|
||||
const probeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// clear old data when systemId changes
|
||||
// useEffect(() => {
|
||||
// return setProbes([])
|
||||
// }, [systemId])
|
||||
|
||||
// initial load - fetch probes if not provided by caller
|
||||
useEffect(() => {
|
||||
fetchProbes(systemId).then((probes) => setProbes(probes))
|
||||
}, [systemId])
|
||||
|
||||
// Subscribe to updates if probes not provided by caller
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
function flushPendingProbeEvents() {
|
||||
probeBatchTimeout.current = null
|
||||
if (!pendingProbeEvents.current.size) {
|
||||
return
|
||||
}
|
||||
const events = pendingProbeEvents.current
|
||||
pendingProbeEvents.current = new Map()
|
||||
setProbes((currentProbes) => {
|
||||
return applyProbeEvents(currentProbes ?? [], events.values(), systemId)
|
||||
})
|
||||
}
|
||||
|
||||
const pbOptions: RecordListOptions = { fields: NETWORK_PROBE_FIELDS }
|
||||
if (systemId) {
|
||||
pbOptions.filter = pb.filter("system = {:system}", { system: systemId })
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
unsubscribe = await pb.collection<NetworkProbeRecord>("network_probes").subscribe(
|
||||
"*",
|
||||
(event) => {
|
||||
pendingProbeEvents.current.set(event.record.id, event)
|
||||
if (!probeBatchTimeout.current) {
|
||||
probeBatchTimeout.current = setTimeout(flushPendingProbeEvents, 50)
|
||||
}
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to probes", error)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
if (probeBatchTimeout.current !== null) {
|
||||
clearTimeout(probeBatchTimeout.current)
|
||||
probeBatchTimeout.current = null
|
||||
}
|
||||
pendingProbeEvents.current.clear()
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [systemId])
|
||||
|
||||
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(() => {
|
||||
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
|
||||
}
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = {
|
||||
fields: "stats,created,type",
|
||||
filter: pb.filter("system={:system} && type={:type}", { system: systemId, type: chartTimeData[chartTime].type }),
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
unsubscribe = await pb.collection<NetworkProbeStatsRecord>("network_probe_stats").subscribe(
|
||||
"*",
|
||||
(event) => {
|
||||
if (event.action !== "create") {
|
||||
return
|
||||
}
|
||||
// console.log("Appending new probe stats to chart:", event.record)
|
||||
const newStats = appendCacheValue(systemId, chartTime, [event.record])
|
||||
setProbeStats(newStats)
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to probe stats:", error)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [systemId, chartTime])
|
||||
|
||||
// subscribe to realtime metrics if chart time is 1m
|
||||
useEffect(() => {
|
||||
if (!systemId || chartTime !== "1m") {
|
||||
return
|
||||
}
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const cache_key = `${systemId}rt`
|
||||
pb.realtime
|
||||
.subscribe(
|
||||
`rt_metrics`,
|
||||
(data: { Probes: NetworkProbeStatsRecord["stats"] }) => {
|
||||
const prev = getCacheValue(systemId, "rt")
|
||||
const now = Date.now()
|
||||
// if no previous data or the last data point is older than 1min,
|
||||
// create a new data set starting with a point 1 second ago to seed the chart data
|
||||
// if (!prev || (prev.at(-1)?.created ?? 0) < now - 60_000) {
|
||||
// prev = [{ created: now - 30_000, stats: probesToStats(probes) }]
|
||||
// }
|
||||
const stats = { created: now, stats: data.Probes } as NetworkProbeStatsRecord
|
||||
const newStats = appendData(prev, [stats], 1000, 120)
|
||||
setProbeStats(() => newStats)
|
||||
cache.set(cache_key, newStats)
|
||||
},
|
||||
{ query: { system: systemId } }
|
||||
)
|
||||
.then((us) => {
|
||||
unsubscribe = us
|
||||
})
|
||||
return () => unsubscribe?.()
|
||||
}, [chartTime, systemId])
|
||||
|
||||
return probeStats
|
||||
}
|
||||
async function fetchProbes(system?: string) {
|
||||
try {
|
||||
const res = await pb.collection<NetworkProbeRecord>("network_probes").getList(0, 2000, {
|
||||
fields: NETWORK_PROBE_FIELDS,
|
||||
filter: system ? pb.filter("system={:system}", { system }) : undefined,
|
||||
})
|
||||
return res.items
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: (error as Error)?.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function applyProbeEvents(
|
||||
probes: NetworkProbeRecord[],
|
||||
events: Iterable<RecordSubscription<NetworkProbeRecord>>,
|
||||
systemId?: string
|
||||
) {
|
||||
// Use a map to handle updates/deletes in constant time
|
||||
const probeById = new Map(probes.map((probe) => [probe.id, probe]))
|
||||
const createdProbes: NetworkProbeRecord[] = []
|
||||
|
||||
for (const { action, record } of events) {
|
||||
const matchesSystemScope = !systemId || record.system === systemId
|
||||
|
||||
if (action === "delete" || !matchesSystemScope) {
|
||||
probeById.delete(record.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!probeById.has(record.id)) {
|
||||
createdProbes.push(record)
|
||||
}
|
||||
|
||||
probeById.set(record.id, record)
|
||||
}
|
||||
|
||||
const nextProbes: NetworkProbeRecord[] = []
|
||||
// Prepend brand new probes (matching previous behavior)
|
||||
for (let index = createdProbes.length - 1; index >= 0; index -= 1) {
|
||||
nextProbes.push(createdProbes[index])
|
||||
}
|
||||
|
||||
// Rebuild the final list while preserving original order for existing probes
|
||||
for (const probe of probes) {
|
||||
const nextProbe = probeById.get(probe.id)
|
||||
if (!nextProbe) {
|
||||
continue
|
||||
}
|
||||
nextProbes.push(nextProbe)
|
||||
probeById.delete(probe.id)
|
||||
}
|
||||
|
||||
return nextProbes
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export const formatShortDate = (timestamp: string) => {
|
||||
return shortDateFormatter.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
export const hourWithSeconds = (timestamp: string) => {
|
||||
export const hourWithSeconds = (timestamp: string | number) => {
|
||||
return hourWithSecondsFormatter.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
@@ -111,17 +111,18 @@ export const updateFavicon = (() => {
|
||||
</linearGradient>
|
||||
</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"/>
|
||||
${downCount > 0 &&
|
||||
`
|
||||
${
|
||||
downCount > 0 &&
|
||||
`
|
||||
<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>
|
||||
`
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
`
|
||||
const blob = new Blob([svg], { type: "image/svg+xml" })
|
||||
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)
|
||||
}
|
||||
|
||||
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 */
|
||||
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
|
||||
const saved = storageInterface?.getItem(key)
|
||||
@@ -365,12 +386,12 @@ export function formatDuration(
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/** Parse semver string into major, minor, and patch numbers
|
||||
/** Parse semver string into major, minor, and patch numbers
|
||||
* @example
|
||||
* const semVer = "1.2.3"
|
||||
* const { major, minor, patch } = parseSemVer(semVer)
|
||||
* console.log(major, minor, patch) // 1, 2, 3
|
||||
*/
|
||||
*/
|
||||
export const parseSemVer = (semVer = ""): SemVer => {
|
||||
// if (semVer.startsWith("v")) {
|
||||
// semVer = semVer.slice(1)
|
||||
@@ -422,10 +443,22 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
|
||||
}) as T
|
||||
}
|
||||
|
||||
/** Get the visual width of a string, accounting for full-width characters */
|
||||
export function getVisualStringWidth(str: string): number {
|
||||
const visualWidthCache = new Map<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
|
||||
for (const char of str) {
|
||||
if (char === ".") {
|
||||
width += 0.7
|
||||
continue
|
||||
}
|
||||
const code = char.codePointAt(0) || 0
|
||||
// Hangul Jamo and Syllables are often slightly thinner than Hanzi/Kanji
|
||||
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
|
||||
width += isFullWidth ? 2 : 1
|
||||
}
|
||||
visualWidthCache.set(str, 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 */
|
||||
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
|
||||
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))
|
||||
const countString = count.toLocaleString()
|
||||
switch (unit) {
|
||||
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":
|
||||
return plural(count, { one: `${countString} hour`, other: `${countString} hours` })
|
||||
case "day":
|
||||
@@ -469,4 +513,4 @@ export function secondsToUptimeString(seconds: number): string {
|
||||
} else {
|
||||
return secondsToString(seconds, "day")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ar\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Arabic\n"
|
||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} متاح"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "المتوسط ينخفض أقل من <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "المتوسط يتجاوز <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "متوسط عدد عمليات الإدخال والإخراج التي تنتظر خدمتها"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "متوسط استهلاك طاقة وحدة معالجة الرسوميات"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "متوسط الوقت من الانتظار في الدور حتى الإتمام لكل عملية"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "متوسط استخدام وحدة المعالجة المركزية على مستوى النظام"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "نسخ متغيرات البيئة"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "نسخ من"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "نسخ المضيف"
|
||||
@@ -476,7 +490,7 @@ msgstr "نسخ YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "النواة"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "يوميًا"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "افتراضي"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "وحدة القرص"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "استخدام القرص"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "استخدام القرص لـ {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "عرض"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "طريقة HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "طريقة HTTP: POST، GET، أو HEAD (الافتراضي: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "انتظار الإدخال والإخراج"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "وقت الإدخال والإخراج"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "استخدام الإدخال والإخراج"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "تنسيق الحمولة"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "متوسط الاستخدام لكل نواة"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "النسبة المئوية للوقت الذي يكون فيه القرص مشغولاً بالإدخال والإخراج"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "المنفذ"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "المنافذ"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "تم بدء العملية"
|
||||
msgid "Public Key"
|
||||
msgstr "المفتاح العام"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "عمق الدور"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "ساعات الهدوء"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "قراءة"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "جدول"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "تبويبات"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذل
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة من قاعدة البيانات بشكل دائم."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "معدل نقل {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "إجمالي البيانات المرسلة لكل واجهة"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "رفع"
|
||||
msgid "Uptime"
|
||||
msgstr "مدة التشغيل"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "مستخدم"
|
||||
msgid "Users"
|
||||
msgstr "المستخدمون"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "الاستخدام"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "القيمة"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "عرض"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "عرض المزيد"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "أمر ويندوز"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "كتابة"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: bg\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-03-28 09:32\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Bulgarian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "Версия {0} е налична"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Средната стойност пада под <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Средната стойност надхвърля <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Среден брой I/O операции, чакащи обслужване"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Средна консумация на ток от графични карти"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Средно време в опашката до приключване на операция"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Средно използване на процесора на цялата система"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Копирай еnv"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Копиране от"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Копирай хоста"
|
||||
@@ -476,7 +490,7 @@ msgstr "Копирай YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Основни"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Дневно"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Подредба"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Единица за диск"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Използване на диск"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Изполване на диск от {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Показване"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP метод"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP метод: POST, GET или HEAD (по подразбиране: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O изчакване"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O време"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O натоварване"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Формат на полезния товар"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Средно използване на ядро"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Процент от времето, в което дискът е зает с I/O операции"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Процент време, прекарано във всяко състояние"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "Порт"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Портове"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Процесът стартира"
|
||||
msgid "Public Key"
|
||||
msgstr "Публичен ключ"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Дълбочина на опашката"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Тихи часове"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Прочети"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Таблица"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Табове"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Това действие не може да бъде отменено.
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Това ще доведе до трайно изтриване на всички избрани записи от базата данни."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Пропускателна способност на {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Общо получени данни за всеки интерфейс"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Общо изпратени данни за всеки интерфейс"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Качване"
|
||||
msgid "Uptime"
|
||||
msgstr "Време на работа"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Използвани"
|
||||
msgid "Users"
|
||||
msgstr "Потребители"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Натоварване"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Стойност"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Изглед"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Виж повече"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Команда Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Запиши"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: cs\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Czech\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} k dispozici"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Průměr klesne pod <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Průměr je vyšší než <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Průměrný počet I/O operací čekajících na vyřízení"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Průměrná spotřeba energie GPU"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Průměrná doba od zařazení do fronty po dokončení operace"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Průměrné využití CPU v celém systému"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Kopírovat env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Kopírovat z"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Kopírovat hostitele"
|
||||
@@ -476,7 +490,7 @@ msgstr "Kopírovat YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Jádro"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Denně"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Výchozí"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Disková jednotka"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Využití disku"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Využití disku {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Zobrazení"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP metoda"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP metoda: POST, GET nebo HEAD (výchozí: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O Čekání"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O Čas"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O Využití"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Formát payloadu"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Průměrné využití na jádro"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Procento času, po který je disk zaneprázdněn I/O operacemi"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Procento času strávěného v každém stavu"
|
||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Porty"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Proces spuštěn"
|
||||
msgid "Public Key"
|
||||
msgstr "Veřejný klíč"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Hloubka fronty"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Tiché hodiny"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Číst"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabulka"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Karty"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Tuto akci nelze vzít zpět. Tím se z databáze trvale odstraní všech
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Tímto trvale odstraníte všechny vybrané záznamy z databáze."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Propustnost {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Celkový přijatý objem dat pro každé rozhraní"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Celkový odeslaný objem dat pro každé rozhraní"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Odeslání"
|
||||
msgid "Uptime"
|
||||
msgstr "Doba provozu"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Využito"
|
||||
msgid "Users"
|
||||
msgstr "Uživatelé"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Využití"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Hodnota"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Zobrazení"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Zobrazit více"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows příkaz"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Psát"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: da\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Danish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} tilgængelig"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Gennemsnit falder under <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Gennemsnittet overstiger <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Gennemsnitligt antal I/O-operationer, der venter på at blive betjent"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Gennemsnitligt strømforbrug for GPU'er"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Gennemsnitlig tid fra kø til færdiggørelse pr. operation"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Gennemsnitlig systembaseret CPU-udnyttelse"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Kopier miljø"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Kopier fra"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Kopier vært"
|
||||
@@ -476,7 +490,7 @@ msgstr "Kopier YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Kerne"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Dagligt"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Standard"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Diskenhed"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Diskforbrug"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Diskforbrug af {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Visning"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -870,7 +883,7 @@ msgstr "Sundhed"
|
||||
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Heartbeat"
|
||||
msgstr ""
|
||||
msgstr "Hjerteslag"
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "Heartbeat Monitoring"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP-metode"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP-metode: POST, GET eller HEAD (standard: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O Vent"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O Tid"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O Udnyttelse"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Payload-format"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Gennemsnitlig udnyttelse pr. kerne"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Procentdel af tiden disk-en er optaget af I/O"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Procentdel af tid brugt i hver tilstand"
|
||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Porte"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Proces startet"
|
||||
msgid "Public Key"
|
||||
msgstr "Offentlig nøgle"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Kødybde"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Stille timer"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Læs"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabel"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Faner"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Denne handling kan ikke fortrydes. Dette vil permanent slette alle aktue
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Dette vil permanent slette alle poster fra databasen."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Gennemløb af {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Samlet modtaget data for hver interface"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Samlet sendt data for hver interface"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Overfør"
|
||||
msgid "Uptime"
|
||||
msgstr "Oppetid"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Brugt"
|
||||
msgid "Users"
|
||||
msgstr "Brugere"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Udnyttelse"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Værdi"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Vis"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Se mere"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows-kommando"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Skriv"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: de\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-14 20:37\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} verfügbar"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Durchschnitt unterschreitet <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Durchschnitt überschreitet <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Durchschnittliche Anzahl der auf Bearbeitung wartenden I/O-Operationen"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Durchschnittlicher Stromverbrauch der GPUs"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Durchschnittliche Warteschlangen- bis Abschlusszeit pro Operation"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Durchschnittliche systemweite CPU-Auslastung"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Umgebungsvariablen kopieren"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Kopieren von"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Host kopieren"
|
||||
@@ -476,7 +490,7 @@ msgstr "YAML kopieren"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Kern"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Täglich"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Standard"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Festplatteneinheit"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Festplattennutzung"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Festplattennutzung von {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Anzeige"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP-Methode"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP-Methode: POST, GET oder HEAD (Standard: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O-Wartezeit"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O-Zeit"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O-Auslastung"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Payload-Format"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Durchschnittliche Auslastung pro Kern"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Prozentsatz der Zeit, in der die Festplatte mit I/O beschäftigt ist"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Prozentsatz der Zeit in jedem Zustand"
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Prozess gestartet"
|
||||
msgid "Public Key"
|
||||
msgstr "Öffentlicher Schlüssel"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Warteschlangentiefe"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Ruhezeiten"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Lesen"
|
||||
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Dadurch werden alle ausgewählten Datensätze dauerhaft aus der Datenbank gelöscht."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Durchsatz von {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Empfangene Gesamtdatenmenge je Schnittstelle "
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Gesendete Gesamtdatenmenge je Schnittstelle"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr "Gesamtzeit für Lese-/Schreibvorgänge (kann 100% überschreiten)"
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Hochladen"
|
||||
msgid "Uptime"
|
||||
msgstr "Betriebszeit"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Verwendet"
|
||||
msgid "Users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Auslastung"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Wert"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Ansicht"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Mehr anzeigen"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows-Befehl"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Schreiben"
|
||||
|
||||
|
||||
@@ -204,10 +204,19 @@ msgstr "Average drops below <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Average exceeds <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Average number of I/O operations waiting to be serviced"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Average power consumption of GPUs"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Average queue to completion time per operation"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Average system-wide CPU utilization"
|
||||
@@ -439,6 +448,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Copy env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Copy from"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Copy host"
|
||||
@@ -594,12 +608,11 @@ msgstr "Disk unit"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Disk Usage"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Disk usage of {extraFsName}"
|
||||
|
||||
@@ -893,6 +906,21 @@ msgstr "HTTP Method"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O Await"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O Time"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O Utilization"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1202,6 +1230,10 @@ msgstr "Payload format"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Per-core average utilization"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Percent of time the disk is busy with I/O"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Percentage of time spent in each state"
|
||||
@@ -1279,13 +1311,20 @@ msgstr "Process started"
|
||||
msgid "Public Key"
|
||||
msgstr "Public Key"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Queue Depth"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Quiet Hours"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Read"
|
||||
|
||||
@@ -1597,7 +1636,7 @@ msgstr "This action cannot be undone. This will permanently delete all current r
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "This will permanently delete all selected records from the database."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Throughput of {extraFsName}"
|
||||
|
||||
@@ -1650,6 +1689,11 @@ msgstr "Total data received for each interface"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Total data sent for each interface"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr "Total time spent on read/write (can exceed 100%)"
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1770,7 +1814,7 @@ msgstr "Upload"
|
||||
msgid "Uptime"
|
||||
msgstr "Uptime"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1792,6 +1836,11 @@ msgstr "Used"
|
||||
msgid "Users"
|
||||
msgstr "Users"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Utilization"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Value"
|
||||
@@ -1801,6 +1850,7 @@ msgid "View"
|
||||
msgstr "View"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "View more"
|
||||
@@ -1853,7 +1903,9 @@ msgstr "Windows command"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Write"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: es\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} disponible"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "El promedio cae por debajo de <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "El promedio excede <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Número medio de operaciones de E/S en espera de ser atendidas"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Consumo de energía promedio de GPUs"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Tiempo medio de cola hasta la finalización por operación"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Utilización promedio de CPU del sistema"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Copiar env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Copiar de"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Copiar host"
|
||||
@@ -476,7 +490,7 @@ msgstr "Copiar YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Núcleo"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Diariamente"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Predeterminado"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Unidad de disco"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Uso de disco"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Uso de disco de {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Pantalla"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -870,7 +883,7 @@ msgstr "Estado"
|
||||
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Heartbeat"
|
||||
msgstr ""
|
||||
msgstr "Latido"
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "Heartbeat Monitoring"
|
||||
@@ -898,6 +911,21 @@ msgstr "Método HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "Método HTTP: POST, GET o HEAD (predeterminado: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "Espera de E/S"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "Tiempo de E/S"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "Utilización de E/S"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Formato de carga útil (payload)"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Uso promedio por núcleo"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Porcentaje de tiempo que el disco está ocupado con E/S"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Porcentaje de tiempo dedicado a cada estado"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "Puerto"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Puertos"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Proceso iniciado"
|
||||
msgid "Public Key"
|
||||
msgstr "Clave pública"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Profundidad de cola"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Horas de silencio"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Lectura"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabla"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Pestañas"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Esta acción no se puede deshacer. Esto eliminará permanentemente todos
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Esto eliminará permanentemente todos los registros seleccionados de la base de datos."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Rendimiento de {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Datos totales recibidos por cada interfaz"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Datos totales enviados por cada interfaz"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Cargar"
|
||||
msgid "Uptime"
|
||||
msgstr "Tiempo de actividad"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Usado"
|
||||
msgid "Users"
|
||||
msgstr "Usuarios"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Utilización"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Valor"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Vista"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Ver más"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Comando Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Escritura"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: fa\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:28\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Persian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} در دسترس است"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "میانگین به زیر <0>{value}{0}</0> میافتد"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "میانگین از <0>{value}{0}</0> فراتر رفته است"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "میانگین تعداد عملیات ورودی/خروجی در انتظار سرویس"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "میانگین مصرف برق پردازندههای گرافیکی"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "میانگین زمان صف تا تکمیل به ازای هر عملیات"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "میانگین استفاده از CPU در کل سیستم"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "کپی متغیرهای محیط"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "کپی از"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "کپی میزبان"
|
||||
@@ -476,7 +490,7 @@ msgstr "کپی YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "هسته"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "روزانه"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "پیشفرض"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "واحد دیسک"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "میزان استفاده از دیسک"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "میزان استفاده از دیسک {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "نمایش"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "متد HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "متد HTTP: POST، GET، یا HEAD (پیشفرض: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "انتظار ورودی/خروجی"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "زمان ورودی/خروجی"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "استفاده از ورودی/خروجی"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "فرمت پیلود"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "میانگین استفاده در هر هسته"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "درصد زمانی که دیسک مشغول عملیات ورودی/خروجی است"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "درصد زمان صرف شده در هر حالت"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "پورت"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "پورتها"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "فرآیند شروع شد"
|
||||
msgid "Public Key"
|
||||
msgstr "کلید عمومی"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "عمق صف"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "ساعات آرام"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "خواندن"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "جدول"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "تبها"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "این عمل قابل برگشت نیست. این کار تمام رک
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "این کار تمام رکوردهای انتخاب شده را برای همیشه از پایگاه داده حذف خواهد کرد."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "توان عملیاتی {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "دادههای کل دریافت شده برای هر رابط"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "دادههای کل ارسال شده برای هر رابط"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "آپلود"
|
||||
msgid "Uptime"
|
||||
msgstr "آپتایم"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "استفاده شده"
|
||||
msgid "Users"
|
||||
msgstr "کاربران"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "بهرهوری"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "مقدار"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "مشاهده"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "مشاهده بیشتر"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "دستور Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "نوشتن"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: fr\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} disponible"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "La moyenne descend en dessous de <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "La moyenne dépasse <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Nombre moyen d'opérations d'E/S en attente d'être traitées"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Consommation d'énergie moyenne des GPUs"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Temps moyen de file d'attente jusqu'à l'achèvement par opération"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Utilisation moyenne du CPU à l'échelle du système"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Copier env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Copier depuis"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Copier l'hôte"
|
||||
@@ -476,7 +490,7 @@ msgstr "Copier YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Cœur"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Quotidien"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Par défaut"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Unité disque"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Utilisation du disque"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Utilisation du disque de {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Affichage"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -870,7 +883,7 @@ msgstr "Santé"
|
||||
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Heartbeat"
|
||||
msgstr ""
|
||||
msgstr "Pulsation"
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "Heartbeat Monitoring"
|
||||
@@ -898,6 +911,21 @@ msgstr "Méthode HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "Méthode HTTP : POST, GET ou HEAD (par défaut : POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "Attente E/S"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "Temps E/S"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "Utilisation E/S"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Format de la charge utile"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Utilisation moyenne par cœur"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Pourcentage de temps pendant lequel le disque est occupé par des E/S"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Pourcentage de temps passé dans chaque état"
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Processus démarré"
|
||||
msgid "Public Key"
|
||||
msgstr "Clé publique"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Profondeur de file d'attente"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Heures calmes"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Lecture"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tableau"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Onglets"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Cette action ne peut pas être annulée. Cela supprimera définitivement
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Ceci supprimera définitivement tous les enregistrements sélectionnés de la base de données."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Débit de {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Données totales reçues pour chaque interface"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Données totales envoyées pour chaque interface"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Téléverser"
|
||||
msgid "Uptime"
|
||||
msgstr "Temps de fonctionnement"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Utilisé"
|
||||
msgid "Users"
|
||||
msgstr "Utilisateurs"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Utilisation"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Valeur"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Vue"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Voir plus"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Commande Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Écriture"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: he\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Hebrew\n"
|
||||
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} זמין"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "הממוצע יורד מתחת ל-<0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "הממוצע עולה על <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "מספר ממוצע של פעולות קלט/פלט הממתינות לטיפול"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "צריכת חשמל ממוצעת של GPUs"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "זמן ממוצע מהתור ועד להשלמה לכל פעולה"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "ניצול ממוצע כלל-מערכתי של CPU"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "העתק env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "העתק מ-"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "העתק מארח"
|
||||
@@ -476,13 +490,13 @@ msgstr "העתק YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "ליבה"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "CPU"
|
||||
msgstr "CPU"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "CPU Cores"
|
||||
@@ -550,7 +564,7 @@ msgstr "יומי"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "ברירת מחדל"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "יחידת דיסק"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "שימוש בדיסק"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "שימוש בדיסק של {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "תצוגה"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "שיטת HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "שיטת HTTP: POST, GET, או HEAD (ברירת מחדל: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "המתנת קלט/פלט"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "זמן קלט/פלט"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "ניצול קלט/פלט"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "פורמט מטען (Payload)"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "ניצול ממוצע לליבה"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "אחוז הזמן שבו הדיסק עסוק בפעולות קלט/פלט"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "אחוז הזמן המוקדש לכל מצב"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "פורט"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "פורטים"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "תהליך התחיל"
|
||||
msgid "Public Key"
|
||||
msgstr "מפתח ציבורי"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "עומק תור"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "שעות שקט"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "קריאה"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "טבלה"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "לשוניות"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "פעולה זו לא ניתנת לביטול. פעולה זו תמחק
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "פעולה זו תמחק לצמיתות את כל הרשומות שנבחרו ממסד הנתונים."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "תפוקה של {extraFsName}"
|
||||
|
||||
@@ -1626,7 +1665,7 @@ msgstr "החלף ערכת נושא"
|
||||
#: src/components/add-system.tsx
|
||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||
msgid "Token"
|
||||
msgstr "Token"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/command-palette.tsx
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
@@ -1655,6 +1694,11 @@ msgstr "סך נתונים שהתקבלו עבור כל ממשק"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "סך נתונים שנשלחו עבור כל ממשק"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "העלאה"
|
||||
msgid "Uptime"
|
||||
msgstr "זמן פעילות"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "בשימוש"
|
||||
msgid "Users"
|
||||
msgstr "משתמשים"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "ניצולת"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "ערך"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "צפה"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "צפה בעוד"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "פקודת Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "כתיבה"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: hr\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:28\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Croatian\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} dostupno"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Prosjek pada ispod <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Prosjek premašuje <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Prosječan broj I/O operacija koje čekaju na obradu"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Prosječna potrošnja energije grafičkog procesora"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Prosječno vrijeme od čekanja do završetka po operaciji"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Prosječna iskorištenost procesora u cijelom sustavu"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Kopiraj env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Kopiraj iz"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Kopiraj hosta"
|
||||
@@ -476,7 +490,7 @@ msgstr "Kopiraj YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Jezgra"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Dnevno"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Zadano"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Mjerna jedinica za disk"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Iskorištenost Diska"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Iskorištenost diska od {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Prikaz"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP metoda"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP metoda: POST, GET ili HEAD (zadano: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O čekanje"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O vrijeme"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O iskorištenost"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Format korisnog tereta (Payload)"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Prosječna iskorištenost po jezgri"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Postotak vremena u kojem je disk zauzet I/O operacijama"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Postotak vremena provedenog u svakom stanju"
|
||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Portovi"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Proces pokrenut"
|
||||
msgid "Public Key"
|
||||
msgstr "Javni Ključ"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Dubina reda"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Tihi sati"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Pročitaj"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tablica"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Kartice"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Ova radnja ne može se poništiti. Svi trenutni zapisi za {name} bit će
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Ovom radnjom će se trajno izbrisati svi odabrani zapisi iz baze podataka."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Protok {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Ukupni podaci primljeni za svako sučelje"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Ukupni podaci poslani za svako sučelje"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Otpremi"
|
||||
msgid "Uptime"
|
||||
msgstr "Vrijeme rada"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Iskorišteno"
|
||||
msgid "Users"
|
||||
msgstr "Korisnici"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Iskorištenost"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Vrijednost"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Prikaz"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Prikaži više"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows naredba"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Piši"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: hu\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Hungarian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} elérhető"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Az átlag esik <0>{value}{0}</0> alá"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Az átlag meghaladja a <0>{value}{0}</0> értéket"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Kiszolgálásra váró I/O műveletek átlagos száma"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "GPU-k átlagos energiafogyasztása"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Műveletenkénti átlagos várakozási és befejezési idő"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Rendszerszintű CPU átlagos kihasználtság"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Környezet másolása"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Másolás innen:"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Hoszt másolása"
|
||||
@@ -476,7 +490,7 @@ msgstr "YAML másolása"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Mag"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Napi"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Alapértelmezett"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Lemez mértékegysége"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Lemezhasználat"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Lemezhasználat a {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Megjelenítés"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP metódus"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP metódus: POST, GET vagy HEAD (alapértelmezett: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O várakozás"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O idő"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O kihasználtság"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Payload formátum"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Átlagos kihasználtság magonként"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Az az időtartam százalékban, amíg a lemez I/O műveletekkel van elfoglalva"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Az idő százalékos aránya minden állapotban"
|
||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Portok"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Folyamat elindítva"
|
||||
msgid "Public Key"
|
||||
msgstr "Nyilvános kulcs"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Várakozási sor mélysége"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Csendes órák"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Olvasás"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tábla"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Lapok"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Ezt a műveletet nem lehet visszavonni! Véglegesen törli a {name} öss
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Ez véglegesen törli az összes kijelölt bejegyzést az adatbázisból."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "A {extraFsName} átviteli teljesítménye"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Összes fogadott adat minden interfészenként"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Összes elküldött adat minden interfészenként"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Feltöltés"
|
||||
msgid "Uptime"
|
||||
msgstr "Üzemidő"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Felhasznált"
|
||||
msgid "Users"
|
||||
msgstr "Felhasználók"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Kihasználtság"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Érték"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Nézet"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Továbbiak megjelenítése"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows parancs"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Írás"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: id\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:28\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Indonesian\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} tersedia"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Rata-rata turun di bawah <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Rata-rata melebihi <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Jumlah rata-rata operasi I/O yang menunggu untuk dilayani"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Rata-rata konsumsi daya GPU"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Waktu rata-rata antrian hingga selesai per operasi"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Rata-rata utilisasi CPU seluruh sistem"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Salin env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Salin dari"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Salin host"
|
||||
@@ -476,7 +490,7 @@ msgstr "Salin YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Inti"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -599,19 +613,18 @@ msgstr "Unit disk"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Penggunaan Disk"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Penggunaan disk dari {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Tampilan"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "Metode HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "Metode HTTP: POST, GET, atau HEAD (default: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "Waktu I/O"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "Utilisasi I/O"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -910,7 +938,7 @@ msgstr "Jika anda kehilangan kata sandi untuk akun admin anda, anda dapat merese
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Docker image"
|
||||
msgid "Image"
|
||||
msgstr ""
|
||||
msgstr "Gambar"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Inactive"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Format payload"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Rata-rata utilisasi per-inti"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Persentase waktu disk sibuk dengan I/O"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Persentase waktu yang dihabiskan di setiap status"
|
||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Port"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Proses dimulai"
|
||||
msgid "Public Key"
|
||||
msgstr "Kunci Publik"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Kedalaman Antrian"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Jam Tenang"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Baca"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabel"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Tab"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Aksi ini tidak dapat di kembalikan. ini akan menghapus permanen semua re
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Ini akan menghapus secara permanen semua record yang dipilih dari database."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Throughput dari {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Total data yang diterima untuk setiap antarmuka"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Total data yang dikirim untuk setiap antarmuka"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Unggah"
|
||||
msgid "Uptime"
|
||||
msgstr "Waktu aktif"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Digunakan"
|
||||
msgid "Users"
|
||||
msgstr "Pengguna"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Utilisasi"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Nilai"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Lihat"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Lihat lebih banyak"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Perintah Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Tulis"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: it\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} disponibile"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "La media scende sotto <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "La media supera <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Numero medio di operazioni di I/O in attesa di essere servite"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Consumo energetico medio delle GPU"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Tempo medio dalla coda al completamento per operazione"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Utilizzo medio della CPU a livello di sistema"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Copia env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Copia da"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Copia host"
|
||||
@@ -550,7 +564,7 @@ msgstr "Giornaliero"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Predefinito"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,12 +613,11 @@ msgstr "Unità disco"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Utilizzo Disco"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Utilizzo del disco di {extraFsName}"
|
||||
|
||||
@@ -898,6 +911,21 @@ msgstr "Metodo HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "Metodo HTTP: POST, GET o HEAD (predefinito: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "Attesa I/O"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "Tempo I/O"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "Utilizzo I/O"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Formato del payload"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Utilizzo medio per core"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Percentuale di tempo in cui il disco è occupato con l'I/O"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Percentuale di tempo trascorso in ogni stato"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "Porta"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Porte"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Processo avviato"
|
||||
msgid "Public Key"
|
||||
msgstr "Chiave Pub"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Profondità coda"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Ore silenziose"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Lettura"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabella"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Schede"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Questa azione non può essere annullata. Questo eliminerà permanentemen
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Questo eliminerà permanentemente tutti i record selezionati dal database."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Throughput di {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Dati totali ricevuti per ogni interfaccia"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Dati totali inviati per ogni interfaccia"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Carica"
|
||||
msgid "Uptime"
|
||||
msgstr "Tempo di attività"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Utilizzato"
|
||||
msgid "Users"
|
||||
msgstr "Utenti"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Utilizzo"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Valore"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Vista"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Visualizza altro"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Comando Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Scrittura"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ja\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Japanese\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} が利用可能"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "平均が<0>{value}{0}</0>を下回っています"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "平均が<0>{value}{0}</0>を超えています"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "サービスを待機している I/O 操作の平均数"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "GPUの平均消費電力"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "操作ごとの平均キューから完了までの時間"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "システム全体の平均CPU使用率"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "環境変数をコピー"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "コピー元"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "ホストをコピー"
|
||||
@@ -476,7 +490,7 @@ msgstr "YAMLをコピー"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "コア"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "毎日"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "デフォルト"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "ディスク単位"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "ディスク使用率"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "{extraFsName}のディスク使用率"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "表示"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP メソッド"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP メソッド: POST、GET、または HEAD (デフォルト: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O 待機"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O 時間"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O 利用率"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "ペイロード形式"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "コアごとの平均使用率"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "ディスクが I/O でビジー状態である時間の割合"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "各状態で費やした時間の割合"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "ポート"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "ポート"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "プロセス開始"
|
||||
msgid "Public Key"
|
||||
msgstr "公開鍵"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "キューの深さ"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "サイレント時間"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "読み取り"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "テーブル"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "タブ"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "この操作は元に戻せません。これにより、データベー
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "これにより、選択したすべてのレコードがデータベースから完全に削除されます。"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "{extraFsName}のスループット"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "各インターフェースの総受信データ量"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "各インターフェースの総送信データ量"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "アップロード"
|
||||
msgid "Uptime"
|
||||
msgstr "稼働時間"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "使用中"
|
||||
msgid "Users"
|
||||
msgstr "ユーザー"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "利用率"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "値"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "表示"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "もっと見る"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows コマンド"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "書き込み"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ko\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Korean\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} 사용 가능"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "평균이 <0>{value}{0}</0> 아래로 떨어집니다"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "평균이 <0>{value}{0}</0>을(를) 초과합니다"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "서비스를 기다리는 평균 I/O 작업 수"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "GPU들의 평균 전원 사용량"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "작업당 평균 대기 후 완료 시간"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "시스템 전체의 평균 CPU 사용량"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "환경 복사"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "다음에서 복사"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "호스트 복사"
|
||||
@@ -476,7 +490,7 @@ msgstr "YAML 복사"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "코어"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "매일"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "기본값"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "디스크 단위"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "디스크 사용량"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "{extraFsName}의 디스크 사용량"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "표시"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP 메서드"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP 메서드: POST, GET 또는 HEAD (기본값: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O 대기"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O 시간"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O 사용률"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "페이로드 형식"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "코어별 평균 사용률"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "디스크가 I/O로 바쁜 시간의 비율"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "각 상태에서 보낸 시간의 백분율"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "포트"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "포트"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "프로세스 시작됨"
|
||||
msgid "Public Key"
|
||||
msgstr "공개 키"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "대기열 깊이"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "조용한 시간"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "읽기"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "표"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "탭"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "이 작업은 되돌릴 수 없습니다. 데이터베이스에서 {name
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "선택한 모든 레코드를 데이터베이스에서 영구적으로 삭제합니다."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "{extraFsName}의 처리량"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "각 인터페이스별 총합 다운로드 데이터량"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "각 인터페이스별 총합 업로드 데이터량"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "업로드"
|
||||
msgid "Uptime"
|
||||
msgstr "가동 시간"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "사용됨"
|
||||
msgid "Users"
|
||||
msgstr "사용자"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "사용률"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "값"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "보기"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "더 보기"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows 명령어"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "쓰기"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: nl\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} beschikbaar"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Gemiddelde daalt onder <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Gemiddelde overschrijdt <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Gemiddeld aantal I/O-bewerkingen dat wacht op afhandeling"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Gemiddeld stroomverbruik van GPU's"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Gemiddelde tijd van wachtrij tot voltooiing per bewerking"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Gemiddeld systeembrede CPU-gebruik"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Env kopiëren"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Kopiëren van"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Kopieer host"
|
||||
@@ -476,7 +490,7 @@ msgstr "YAML kopiëren"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Kern"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Dagelijks"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Standaard"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Schijf eenheid"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Schijfgebruik"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Schijfgebruik van {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Weergave"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP-methode"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP-methode: POST, GET of HEAD (standaard: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O Wachten"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O Tijd"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O Gebruik"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Payload-indeling"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Gemiddeld gebruik per kern"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Percentage van de tijd dat de schijf bezig is met I/O"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Percentage tijd besteed in elke status"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "Poort"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Poorten"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Proces gestart"
|
||||
msgid "Public Key"
|
||||
msgstr "Publieke sleutel"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Wachtrijdiepte"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Stille uren"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Lezen"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabel"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Tabbladen"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Deze actie kan niet ongedaan worden gemaakt. Dit zal alle huidige record
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Dit zal alle geselecteerde records verwijderen uit de database."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Doorvoer van {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Totaal ontvangen gegevens per interface"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Totaal verzonden gegevens per interface"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Uploaden"
|
||||
msgid "Uptime"
|
||||
msgstr "Actief"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Gebruikt"
|
||||
msgid "Users"
|
||||
msgstr "Gebruikers"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Gebruik"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Waarde"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Weergave"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Meer weergeven"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows-commando"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Schrijven"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: no\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-03-31 07:42\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Norwegian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} tilgjengelig"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "Gjennomsnittet faller under <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Gjennomsnittet overstiger <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Gjennomsnittlig antall I/O-operasjoner som venter på behandling"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Gjennomsnittlig strømforbruk for GPU-er"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Gjennomsnittlig tid fra kø til ferdigstillelse per operasjon"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Gjennomsnittlig CPU-utnyttelse for hele systemet"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Kopier env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Kopier fra"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Kopier vert"
|
||||
@@ -476,7 +490,7 @@ msgstr "Kopier YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Kjerne"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Daglig"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Standard"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Diskenhet"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Diskbruk"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Diskbruk av {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Vis"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -870,7 +883,7 @@ msgstr "Helse"
|
||||
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Heartbeat"
|
||||
msgstr ""
|
||||
msgstr "Hjerteslag"
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "Heartbeat Monitoring"
|
||||
@@ -898,6 +911,21 @@ msgstr "HTTP-metode"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "HTTP-metode: POST, GET eller HEAD (standard: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "I/O-ventetid"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "I/O-tid"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "I/O-utnyttelse"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Nyttelastformat"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Gjennomsnittlig utnyttelse per kjerne"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Prosentandel av tiden disken er opptatt med I/O"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Prosentandel av tid brukt i hver tilstand"
|
||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Porter"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Prosess startet"
|
||||
msgid "Public Key"
|
||||
msgstr "Offentlig Nøkkel"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Kødybde"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Stille timer"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Lesing"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabell"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Faner"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Denne handlingen kan ikke omgjøres. Dette vil slette alle poster for {n
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Dette vil permanent slette alle valgte oppføringer fra databasen."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Gjennomstrømning av {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Totalt mottatt data for hvert grensesnitt"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Totalt sendt data for hvert grensesnitt"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Last opp"
|
||||
msgid "Uptime"
|
||||
msgstr "Oppetid"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Brukt"
|
||||
msgid "Users"
|
||||
msgstr "Brukere"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Utnyttelse"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Verdi"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Visning"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Se mer"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Windows-kommando"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Skriving"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: pl\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Polish\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} jest dostępna"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -40,7 +40,7 @@ msgstr "{count, plural, one {{countString} dzień} few {{countString} dni} many
|
||||
|
||||
#: src/lib/utils.ts
|
||||
msgid "{count, plural, one {{countString} hour} other {{countString} hours}}"
|
||||
msgstr "{count, plural, one {godzinę} few {{countString} godziny} many {{countString} godzin} other {{countString} godziny}}"
|
||||
msgstr "{count, plural, one {godzina} few {{countString} godziny} many {{countString} godzin} other {{countString} godziny}}"
|
||||
|
||||
#: src/lib/utils.ts
|
||||
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
|
||||
@@ -110,7 +110,7 @@ msgstr "Aktywne alerty"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Active state"
|
||||
msgstr "Stan aktywny"
|
||||
msgstr "Status aktywny"
|
||||
|
||||
#: src/components/add-system.tsx
|
||||
#: src/components/add-system.tsx
|
||||
@@ -145,7 +145,7 @@ msgstr "Po"
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
|
||||
msgstr "Po ustawieniu zmiennych środowiskowych zrestartuj hub Beszel, aby zmiany weszły w życie."
|
||||
msgstr "Po ustawieniu zmiennych środowiskowych zrestartuj Beszel hub, aby zmiany zostały zaaplikowane."
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Agent"
|
||||
@@ -209,10 +209,19 @@ msgstr "Średnia spada poniżej <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Średnia przekracza <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Średnia liczba operacji I/O oczekujących na obsłużenie"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Średnie zużycie energii przez GPU"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Średni czas od kolejki do zakończenia operacji"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Średnie wykorzystanie CPU w całym systemie"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Kopiuj env"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Kopiuj z"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Kopiuj host"
|
||||
@@ -476,7 +490,7 @@ msgstr "Kopiuj YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "Główne"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "Codziennie"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Domyślne"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Jednostka dysku"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Użycie dysku"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Wykorzystanie dysku {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Widok"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "Metoda HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "Metoda HTTP: POST, GET lub HEAD (domyślnie: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "Oczekiwanie I/O"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "Czas I/O"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "Użycie I/O"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1200,13 +1228,17 @@ msgstr "Wstrzymane ({pausedSystemsLength})"
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "Payload format"
|
||||
msgstr "Format ładunku"
|
||||
msgstr "Format payload'u"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Średnie wykorzystanie na rdzeń"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Procent czasu, w którym dysk jest zajęty operacjami I/O"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Procent czasu spędzonego w każdym stanie"
|
||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Porty"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Proces uruchomiony"
|
||||
msgid "Public Key"
|
||||
msgstr "Klucz publiczny"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Głębokość kolejki"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Godziny ciszy"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Odczyt"
|
||||
|
||||
@@ -1422,7 +1461,7 @@ msgstr "Wyślij pojedynczy ping heartbeat, aby sprawdzić, czy punkt końcowy dz
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
|
||||
msgstr "Wysyłaj okresowe pingi wychodzące do zewnętrznej usługi monitorowania, dzięki czemu możesz monitorować Beszel bez wystawiania go na działanie Internetu."
|
||||
msgstr "Wysyłaj cyklicznie pingi wychodzące do zewnętrznej usługi monitorowania, co pozwala monitorować Beszel bez potrzeby udostępniania go publicznie w sieci."
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "Send test heartbeat"
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabela"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Karty"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1588,7 +1627,7 @@ msgstr "Testowe powiadomienie wysłane."
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
|
||||
msgstr "Ogólny status to <0>ok</0>, gdy wszystkie systemy działają, <1>ostrzeżenie</1>, gdy wyzwalane są alerty, oraz <2>błąd</2>, gdy którykolwiek system nie działa."
|
||||
msgstr "Ogólny status to <0>ok</0>, gdy wszystkie systemy działają, <1>ostrzeżenie</1>, gdy wyzwalane są alerty, oraz <2>błąd</2>, gdy którykolwiek z systemów nie działa."
|
||||
|
||||
#: src/components/login/forgot-pass-form.tsx
|
||||
msgid "Then log into the backend and reset your user account password in the users table."
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Tej akcji nie można cofnąć. Spowoduje to trwałe usunięcie wszystkic
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Spowoduje to trwałe usunięcie wszystkich zaznaczonych rekordów z bazy danych."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Przepustowość {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Całkowita ilość danych odebranych dla każdego interfejsu"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Całkowita ilość danych wysłanych dla każdego interfejsu"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Wysyłanie"
|
||||
msgid "Uptime"
|
||||
msgstr "Czas pracy"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Używane"
|
||||
msgid "Users"
|
||||
msgstr "Użytkownicy"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Użycie"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Wartość"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Widok"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Zobacz więcej"
|
||||
@@ -1848,7 +1898,7 @@ msgstr "Gdy jest włączony, ten token pozwala agentom na samodzielną rejestrac
|
||||
|
||||
#: src/components/routes/settings/heartbeat.tsx
|
||||
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
|
||||
msgstr "W przypadku korzystania z POST każdy heartbeat zawiera ładunek JSON z podsumowaniem statusu systemu, listą wyłączonych systemów i wyzwolonymi alertami."
|
||||
msgstr "Przy użyciu metody POST każdy heartbeat zawiera payload JSON z podsumowaniem statusu systemu, listą niedostępnych systemów i wywołanymi alertami."
|
||||
|
||||
#: src/components/add-system.tsx
|
||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Polecenie Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Zapis"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: pt\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} disponível"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "A média cai abaixo de <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "A média excede <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "Número médio de operações de E/S à espera de serem atendidas"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Consumo médio de energia pelas GPU's"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "Tempo médio de fila até à conclusão por operação"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Utilização média de CPU em todo o sistema"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "Copiar variáveis de ambiente"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "Copiar de"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "Copiar host"
|
||||
@@ -550,7 +564,7 @@ msgstr "Diariamente"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "Predefinido"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "Unidade de disco"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "Uso de Disco"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Uso de disco de {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "Ecrã"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "Método HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "Método HTTP: POST, GET ou HEAD (predefinido: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "Espera de E/S"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "Tempo de E/S"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "Utilização de E/S"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "Formato do payload"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "Utilização média por núcleo"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "Percentagem de tempo em que o disco está ocupado com E/S"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "Percentagem de tempo gasto em cada estado"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "Porta"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "Portas"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "Processo iniciado"
|
||||
msgid "Public Key"
|
||||
msgstr "Chave Pública"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "Profundidade da fila"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "Horas Silenciosas"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "Ler"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "Tabela"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "Separadores"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "Esta ação não pode ser desfeita. Isso excluirá permanentemente todos
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "Isso excluirá permanentemente todos os registros selecionados do banco de dados."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Taxa de transferência de {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "Dados totais recebidos para cada interface"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "Dados totais enviados para cada interface"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "Carregar"
|
||||
msgid "Uptime"
|
||||
msgstr "Tempo de Atividade"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "Usado"
|
||||
msgid "Users"
|
||||
msgstr "Usuários"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "Utilização"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "Valor"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "Visual"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "Ver mais"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "Comando Windows"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "Escrever"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user