Compare commits

...
Author SHA1 Message Date
hank bc69c16331 New translations en.po (Uyghur)
[ci skip]
2026-09-18 15:22:30 -04:00
hank 88e0674ca6 New translations en.po (Thai)
[ci skip]
2026-09-18 15:22:28 -04:00
hank 7ca3c79dbd New translations en.po (German)
[ci skip]
2026-09-18 15:22:26 -04:00
hank ec5f3850e7 New translations en.po (Arabic)
[ci skip]
2026-09-18 15:22:24 -04:00
hank 1742f180fb New translations en.po (Romanian)
[ci skip]
2026-09-18 15:22:22 -04:00
hank 45f3d3efcc New translations en.po (Uzbek)
[ci skip]
2026-09-18 15:22:21 -04:00
hank c5f9a89064 New translations en.po (Chinese Traditional, Hong Kong)
[ci skip]
2026-09-18 15:22:19 -04:00
hank 2be0f899f3 New translations en.po (Croatian)
[ci skip]
2026-09-18 15:22:18 -04:00
hank 35d318128b New translations en.po (Persian)
[ci skip]
2026-09-18 15:22:16 -04:00
hank 1ce6e154cf New translations en.po (Indonesian)
[ci skip]
2026-09-18 15:22:15 -04:00
hank 10f5d906d2 New translations en.po (Vietnamese)
[ci skip]
2026-09-18 15:22:13 -04:00
hank 56d1196870 New translations en.po (Chinese Traditional)
[ci skip]
2026-09-18 15:22:11 -04:00
hank 3f948f6015 New translations en.po (Chinese Simplified)
[ci skip]
2026-09-18 15:22:09 -04:00
hank 87937391f9 New translations en.po (Ukrainian)
[ci skip]
2026-09-18 15:22:07 -04:00
hank 78c81e823a New translations en.po (Turkish)
[ci skip]
2026-09-18 15:22:06 -04:00
hank 214ba53172 New translations en.po (Swedish)
[ci skip]
2026-09-18 15:22:04 -04:00
hank a819d4b46d New translations en.po (Serbian (Cyrillic))
[ci skip]
2026-09-18 15:22:02 -04:00
hank ef73e1bde8 New translations en.po (Slovenian)
[ci skip]
2026-09-18 15:22:01 -04:00
hank 45ba11a2ba New translations en.po (Russian)
[ci skip]
2026-09-18 15:21:59 -04:00
hank 2ae58d174c New translations en.po (Portuguese)
[ci skip]
2026-09-18 15:21:58 -04:00
hank 74ad9e1f0e New translations en.po (Polish)
[ci skip]
2026-09-18 15:21:56 -04:00
hank 7655d6d0e9 New translations en.po (Norwegian)
[ci skip]
2026-09-18 15:21:55 -04:00
hank 7b0764dbe1 New translations en.po (Dutch)
[ci skip]
2026-09-18 15:21:53 -04:00
hank 5abc647626 New translations en.po (Korean)
[ci skip]
2026-09-18 15:21:52 -04:00
hank e75f31b18b New translations en.po (Japanese)
[ci skip]
2026-09-18 15:21:50 -04:00
hank f8e09d1405 New translations en.po (Italian)
[ci skip]
2026-09-18 15:21:49 -04:00
hank 45c215e125 New translations en.po (Hungarian)
[ci skip]
2026-09-18 15:21:47 -04:00
hank 71638a2f47 New translations en.po (Hebrew)
[ci skip]
2026-09-18 15:21:45 -04:00
hank 3413262750 New translations en.po (Greek)
[ci skip]
2026-09-18 15:21:43 -04:00
hank 6a7b7bd68e New translations en.po (Danish)
[ci skip]
2026-09-18 15:21:41 -04:00
hank 6182074747 New translations en.po (Czech)
[ci skip]
2026-09-18 15:21:39 -04:00
hank fec4288ff6 New translations en.po (Bulgarian)
[ci skip]
2026-09-18 15:21:37 -04:00
hank d70de0a351 New translations en.po (Spanish)
[ci skip]
2026-09-18 15:21:36 -04:00
hank cc2262bf70 New translations en.po (French)
[ci skip]
2026-09-18 15:21:34 -04:00
hank 006d552c21 New translations en.po (Vietnamese)
[ci skip]
2026-09-18 14:21:07 -04:00
hankandGitHub 6e5440c21f New Crowdin updates (#2323) 2026-09-18 13:40:00 -04:00
bb1b39928e feat: network monitoring from agents (#2266, #1911)
Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
Co-authored-by: xiaomiku01 <xiaomiku01@outlook.com>
2026-09-18 13:22:50 -04:00
123 changed files with 15997 additions and 856 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Node.js dependencies
node_modules
internalsite/node_modules
node_modules/
**/node_modules/
# Go build artifacts and binaries
build
+9
View File
@@ -48,6 +48,7 @@ type Agent struct {
keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services
monitorManager *MonitorManager // Manages network monitors
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
}
@@ -122,6 +123,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize handler registry
agent.handlerRegistry = NewHandlerRegistry()
// initialize monitor manager
agent.monitorManager = newMonitorManager()
agent.storagePoolManager = newStoragePoolManager()
// Retain ZFS_INTERVAL for the shared storage pool detail refresh interval.
@@ -192,6 +196,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
}
}
if a.monitorManager != nil {
data.Monitors = a.monitorManager.GetResults(cacheTimeMs)
slog.Debug("Monitors", "data", data.Monitors)
}
// skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
totalCount := uint16(a.systemdManager.getServiceStatsCount())
+1
View File
@@ -153,6 +153,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
// }
func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer()
c.agent.monitorManager.Stop()
c.closeWebSocket()
return health.CleanUp()
}
+1 -2
View File
@@ -1119,7 +1119,6 @@ func TestCalculateGPUAverage(t *testing.T) {
}
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
// Save original PATH
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
tests := []struct {
@@ -1213,7 +1212,7 @@ echo "[]"`
{
name: "no gpu tools available",
setupCommands: func(_ string) error {
t.Setenv("PATH", "")
// The subtest already restricts PATH to its empty temporary directory.
return nil
},
wantErr: true,
+20
View File
@@ -7,6 +7,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/monitor"
"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.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
return registry
@@ -223,3 +225,21 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
return hctx.SendResponse(details, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
type SyncNetworkMonitorsHandler struct{}
func (h *SyncNetworkMonitorsHandler) Handle(hctx *HandlerContext) error {
var req monitor.SyncRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
resp, err := hctx.Agent.monitorManager.HandleSyncRequest(req)
if err != nil {
return err
}
return hctx.SendResponse(resp, hctx.RequestID)
}
+176
View File
@@ -0,0 +1,176 @@
package agent
import (
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
// MonitorManager manages network monitor configurations and task lifetimes.
type MonitorManager struct {
mu sync.RWMutex
monitors map[string]*monitorTask // keyed by monitor ID
probe monitorProbe
resumeGuard monitorResumeGuard
}
func newMonitorManager() *MonitorManager {
return newMonitorManagerWithProbe(networkMonitorProbe(&http.Client{Timeout: monitor.MaxProbeTimeout}))
}
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe}
}
// SyncMonitors replaces all monitor tasks with the given configs.
func (pm *MonitorManager) SyncMonitors(configs []monitor.Config) {
pm.mu.Lock()
defer pm.mu.Unlock()
// Build set of new keys
newKeys := make(map[string]monitor.Config, len(configs))
for _, cfg := range configs {
if cfg.ID == "" {
continue
}
newKeys[cfg.ID] = cfg
}
// Stop removed monitors
for key, task := range pm.monitors {
if _, exists := newKeys[key]; !exists {
task.cancel()
delete(pm.monitors, key)
}
}
// Start new monitors and restart tasks whose config changed.
for key, cfg := range newKeys {
task, exists := pm.monitors[key]
if exists && task.config == cfg {
continue
}
if exists {
task.cancel()
}
task = newMonitorTaskFromExisting(cfg, task)
task.resumeGuard = &pm.resumeGuard
pm.resumeGuard.start()
pm.monitors[key] = task
pm.startMonitor(task)
}
if len(pm.monitors) == 0 {
pm.resumeGuard.shutdown()
}
}
// HandleSyncRequest applies a full or incremental monitor sync request.
func (pm *MonitorManager) HandleSyncRequest(req monitor.SyncRequest) (monitor.SyncResponse, error) {
switch req.Action {
case monitor.SyncActionReplace:
pm.SyncMonitors(req.Configs)
return monitor.SyncResponse{}, nil
case monitor.SyncActionUpsert:
result, err := pm.UpsertMonitor(req.Config, req.RunNow)
if err != nil {
return monitor.SyncResponse{}, err
}
if result == nil {
return monitor.SyncResponse{}, nil
}
return monitor.SyncResponse{Result: *result}, nil
case monitor.SyncActionDelete:
if req.Config.ID == "" {
return monitor.SyncResponse{}, errors.New("missing monitor ID for delete")
}
pm.DeleteMonitor(req.Config.ID)
return monitor.SyncResponse{}, nil
default:
return monitor.SyncResponse{}, fmt.Errorf("unknown monitor sync action: %d", req.Action)
}
}
// UpsertMonitor creates or replaces a single monitor task.
func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*monitor.Result, error) {
if config.ID == "" {
return nil, errors.New("missing monitor ID")
}
pm.mu.Lock()
task, exists := pm.monitors[config.ID]
if exists && task.config == config {
pm.mu.Unlock()
if !runNow {
return nil, nil
}
return task.runProbe(pm.probe), nil
}
if exists {
task.cancel()
}
task = newMonitorTaskFromExisting(config, task)
task.resumeGuard = &pm.resumeGuard
pm.resumeGuard.start()
pm.monitors[config.ID] = task
pm.mu.Unlock()
if runNow {
result := task.runProbe(pm.probe)
pm.startMonitor(task)
return result, nil
}
pm.startMonitor(task)
return nil, nil
}
// DeleteMonitor stops and removes a single monitor task.
func (pm *MonitorManager) DeleteMonitor(id string) {
if id == "" {
return
}
pm.mu.Lock()
defer pm.mu.Unlock()
if task, exists := pm.monitors[id]; exists {
task.cancel()
delete(pm.monitors, id)
}
if len(pm.monitors) == 0 {
pm.resumeGuard.shutdown()
}
}
// GetResults returns aggregated results for all monitors over the last supplied duration in ms.
func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Result {
pm.mu.RLock()
defer pm.mu.RUnlock()
results := make(map[string]monitor.Result, len(pm.monitors))
now := time.Now()
duration := time.Duration(durationMs) * time.Millisecond
for _, task := range pm.monitors {
result, ok := task.history.result(duration, now)
if !ok {
continue
}
results[task.config.ID] = result
}
return results
}
// Stop stops all monitor tasks.
func (pm *MonitorManager) Stop() {
pm.mu.Lock()
defer pm.mu.Unlock()
for key, task := range pm.monitors {
task.cancel()
delete(pm.monitors, key)
}
pm.resumeGuard.shutdown()
}
+274
View File
@@ -0,0 +1,274 @@
package agent
import (
"math"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
// Monitors 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 monitorRawRetention).
// 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 (<= 61s) use raw samples.
// Long-term requests (up to 1h) use the minute buckets to avoid storing thousands
// of individual data points.
const (
// monitorRawRetention is the duration to keep individual samples
monitorRawRetention = 61 * time.Second
// monitorMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
monitorMinuteBucketLen int32 = 61
)
// monitorHistory owns retention and aggregation, independently of probe execution.
type monitorHistory struct {
mu sync.Mutex
sampleCount int64
samples []monitorSample
buckets [monitorMinuteBucketLen]monitorBucket
}
func newMonitorHistory() *monitorHistory {
// Start small for typical intervals; append grows the buffer for faster probes.
return &monitorHistory{samples: make([]monitorSample, 0, 4)}
}
func (h *monitorHistory) clone() *monitorHistory {
h.mu.Lock()
defer h.mu.Unlock()
cloned := newMonitorHistory()
cloned.samples = append(cloned.samples, h.samples...)
cloned.buckets = h.buckets
cloned.sampleCount = h.sampleCount
return cloned
}
func (h *monitorHistory) result(duration time.Duration, now time.Time) (monitor.Result, bool) {
h.mu.Lock()
defer h.mu.Unlock()
return h.resultLocked(duration, now)
}
func (h *monitorHistory) record(sample monitorSample) monitor.Result {
h.mu.Lock()
defer h.mu.Unlock()
h.addSampleLocked(sample)
result, _ := h.resultLocked(time.Minute, sample.timestamp)
return result
}
// monitorSample stores one monitor attempt and its collection time.
type monitorSample struct {
responseUs int64 // -1 means loss
timestamp time.Time
}
// monitorBucket stores one minute of aggregated monitor data.
type monitorBucket struct {
minute int32
filled bool
stats monitorAggregate
}
// monitorAggregate accumulates successful response stats and total sample counts.
type monitorAggregate struct {
sumUs int64
minUs int64
maxUs int64
totalCount int64
successCount int64
}
// newMonitorAggregate initializes an aggregate with an unset minimum value.
func newMonitorAggregate() monitorAggregate {
return monitorAggregate{minUs: math.MaxInt64}
}
// addResponse folds a single monitor sample into the aggregate.
func (agg *monitorAggregate) 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 *monitorAggregate) addAggregate(other monitorAggregate) {
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 monitorAggregate) hasData() bool {
return agg.totalCount > 0
}
// result converts the aggregate into the monitor result format.
func (agg monitorAggregate) result() monitor.Result {
avg := agg.avgResponse()
result := monitor.Result{
AvgResponse: avg,
MinResponse: agg.minUs,
MaxResponse: agg.maxUs,
PacketLoss: agg.lossPercentage(),
TotalCount: agg.totalCount,
SuccessCount: agg.successCount,
ResponseSum: agg.sumUs,
}
if agg.successCount == 0 {
result.MinResponse, result.MaxResponse = 0, 0
}
return result
}
// avgResponse returns the rounded average of successful samples.
func (agg monitorAggregate) avgResponse() int64 {
if agg.successCount == 0 {
return 0
}
return agg.sumUs / agg.successCount
}
// lossPercentage returns the rounded failure rate for the aggregate.
func (agg monitorAggregate) lossPercentage() float64 {
if agg.totalCount == 0 {
return 0
}
return math.Round(float64(agg.totalCount-agg.successCount)/float64(agg.totalCount)*10000) / 100
}
// resultLocked returns the aggregated monitor result for the requested duration along with a bool indicating whether any data was available.
func (h *monitorHistory) resultLocked(duration time.Duration, now time.Time) (monitor.Result, bool) {
agg := h.aggregateLocked(duration, now)
if !agg.hasData() {
// short realtime windows (e.g. the 1s window used for 1m/realtime charts) often fall
// between monitor samples since monitors run at longer, user-defined intervals; fall back to
// the most recent sample so realtime requests still report current status.
agg = h.latestSampleAggregateLocked()
}
hourAgg := h.aggregateLocked(time.Hour, now)
if !agg.hasData() {
return monitor.Result{}, false
}
result := agg.result()
if len(h.samples) > 0 {
result.LastProbeAt = h.samples[len(h.samples)-1].timestamp.UnixMilli()
}
result.AvgResponse1h = hourAgg.avgResponse()
result.MinResponse1h = hourAgg.minUs
result.MaxResponse1h = hourAgg.maxUs
result.PacketLoss1h = hourAgg.lossPercentage()
result.SampleCount = h.sampleCount
if hourAgg.successCount == 0 {
result.MinResponse1h, result.MaxResponse1h = 0, 0
}
return result, true
}
// latestSampleAggregateLocked returns an aggregate containing only the most recent sample, if any.
func (h *monitorHistory) latestSampleAggregateLocked() monitorAggregate {
agg := newMonitorAggregate()
if len(h.samples) == 0 {
return agg
}
agg.addResponse(h.samples[len(h.samples)-1].responseUs)
return agg
}
// aggregateLocked collects monitor data for the requested time window.
func (h *monitorHistory) aggregateLocked(duration time.Duration, now time.Time) monitorAggregate {
cutoff := now.Add(-duration)
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
if duration <= monitorRawRetention {
return aggregateSamplesSince(h.samples, cutoff)
}
return aggregateBucketsSince(h.buckets[:], cutoff, now)
}
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
func aggregateSamplesSince(samples []monitorSample, cutoff time.Time) monitorAggregate {
agg := newMonitorAggregate()
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 []monitorBucket, cutoff, now time.Time) monitorAggregate {
agg := newMonitorAggregate()
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 (h *monitorHistory) addSampleLocked(sample monitorSample) {
h.sampleCount++
cutoff := sample.timestamp.Add(-monitorRawRetention)
start := 0
for i := range h.samples {
if !h.samples[i].timestamp.Before(cutoff) {
start = i
break
}
if i == len(h.samples)-1 {
start = len(h.samples)
}
}
if start > 0 {
size := copy(h.samples, h.samples[start:])
h.samples = h.samples[:size]
}
h.samples = append(h.samples, sample)
minute := int32(sample.timestamp.Unix() / 60)
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per monitor.
bucket := &h.buckets[minute%monitorMinuteBucketLen]
if !bucket.filled || bucket.minute != minute {
bucket.minute = minute
bucket.filled = true
bucket.stats = newMonitorAggregate()
}
bucket.stats.addResponse(sample.responseUs)
}
+154
View File
@@ -0,0 +1,154 @@
package agent
import (
"testing"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMonitorHistoryWindowCounts(t *testing.T) {
history := newMonitorHistory()
now := time.Now()
// This older success counts toward lifetime warm-up, but not this window.
history.record(monitorSample{responseUs: 1000, timestamp: now.Add(-2 * time.Minute)})
history.record(monitorSample{responseUs: 10, timestamp: now.Add(-30 * time.Second)})
history.record(monitorSample{responseUs: 21, timestamp: now.Add(-20 * time.Second)})
history.record(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
result, ok := history.result(time.Minute, now)
require.True(t, ok)
assert.EqualValues(t, 4, result.SampleCount)
assert.EqualValues(t, 3, result.TotalCount)
assert.EqualValues(t, 2, result.SuccessCount)
assert.EqualValues(t, 31, result.ResponseSum, "preserve the sum before average rounding")
assert.EqualValues(t, 15, result.AvgResponse)
assert.Equal(t, 33.33, result.PacketLoss)
encoded, err := cbor.Marshal(result)
require.NoError(t, err)
var decoded monitor.Result
require.NoError(t, cbor.Unmarshal(encoded, &decoded))
assert.Equal(t, result, decoded)
stats := monitor.Stats{}.FromResult(decoded)
assert.Equal(t, result.TotalCount, stats.TotalCount)
assert.Equal(t, result.SuccessCount, stats.SuccessCount)
assert.Equal(t, result.ResponseSum, stats.ResponseSum)
// Reads do not consume samples. A short window's latest-sample fallback
// carries the count for that single failure, not the minute or lifetime count.
repeated, _ := history.result(time.Minute, now)
assert.Equal(t, result, repeated)
fallback, ok := history.result(time.Second, now)
require.True(t, ok)
assert.EqualValues(t, 1, fallback.TotalCount)
assert.Zero(t, fallback.SuccessCount)
assert.Zero(t, fallback.ResponseSum)
assert.Equal(t, 100.0, fallback.PacketLoss)
assert.EqualValues(t, 4, fallback.SampleCount)
}
func TestMonitorHistoryAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
history := newMonitorHistory()
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-90 * time.Second)})
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-30 * time.Second)})
history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
agg := history.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 TestMonitorHistoryAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
history := newMonitorHistory()
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-11 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
history.addSampleLocked(monitorSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
agg := history.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 TestMonitorHistoryAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
history := newMonitorHistory()
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-10 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now})
require.Len(t, history.samples, 1)
assert.Equal(t, int64(20), history.samples[0].responseUs)
agg := history.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 TestMonitorHistoryProbeTimestamp(t *testing.T) {
history := newMonitorHistory()
start := time.Date(2026, time.September, 14, 12, 0, 0, 0, time.UTC)
_, ok := history.result(time.Minute, start)
require.False(t, ok)
first := history.record(monitorSample{responseUs: 20, timestamp: start})
assert.Equal(t, start.UnixMilli(), first.LastProbeAt)
for minute := 0; minute < 5; minute++ {
now := start.Add(time.Duration(minute)*time.Minute + time.Second)
// Realtime reads must not consume freshness for the persistence request.
for _, window := range []time.Duration{time.Second, time.Minute} {
result, ok := history.result(window, now)
require.True(t, ok)
assert.Equal(t, first.LastProbeAt, result.LastProbeAt)
assert.Equal(t, int64(20), result.AvgResponse)
}
}
next := start.Add(5 * time.Minute)
failed := history.record(monitorSample{responseUs: -1, timestamp: next})
assert.Equal(t, next.UnixMilli(), failed.LastProbeAt)
assert.Equal(t, float64(100), failed.PacketLoss)
repeated, ok := history.result(time.Minute, next.Add(2*time.Minute))
require.True(t, ok)
assert.Equal(t, failed.LastProbeAt, repeated.LastProbeAt)
assert.Equal(t, float64(100), repeated.PacketLoss)
}
func TestMonitorHistorySampleCount(t *testing.T) {
history := newMonitorHistory()
now := time.Now()
// Both failed and successful probes count, including older samples so
// monitors with hourly intervals can finish warming up.
history.record(monitorSample{responseUs: -1, timestamp: now.Add(-2 * time.Hour)})
for i, response := range []int64{10, -1, 20} {
result := history.record(monitorSample{responseUs: response, timestamp: now.Add(time.Duration(i) * time.Second)})
assert.EqualValues(t, i+2, result.SampleCount)
}
result, ok := history.clone().result(time.Minute, now.Add(3*time.Second))
require.True(t, ok)
assert.EqualValues(t, 4, result.SampleCount)
}
+312
View File
@@ -0,0 +1,312 @@
package agent
import (
"bytes"
"context"
"crypto/rand"
"errors"
"fmt"
"math"
"net"
"os"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"log/slog"
)
// Match the numeric RTT independently of the localized label used by Windows.
var pingTimeRegex = regexp.MustCompile(`(?i)[=<]\s*([0-9]+(?:[.,][0-9]+)?)\s*ms\b`)
var icmpSequence atomic.Uint32
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 monitors 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)
}
)
// monitorICMP 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 monitors.
// Returns response in microseconds, or -1 and an error on failure.
func monitorICMP(ctx context.Context, target string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
family, ip, err := resolveICMPTarget(ctx, 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 monitorICMPNative(ctx, family.rawNetwork, family, &net.IPAddr{IP: ip})
case icmpDatagram:
return monitorICMPNative(ctx, family.dgramNetwork, family, &net.UDPAddr{IP: ip})
case icmpExecFallback:
return monitorICMPExec(ctx, ip.String(), 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(ctx context.Context, 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.DefaultResolver.LookupIP(ctx, "ip", 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
}
// monitorICMPNative sends an ICMP echo request using Go's x/net/icmp package.
func monitorICMPNative(ctx context.Context, 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()
return monitorICMPPacket(ctx, conn, family, dst)
}
func monitorICMPPacket(ctx context.Context, conn net.PacketConn, family *icmpFamily, dst net.Addr) (int64, error) {
if err := ctx.Err(); err != nil {
return -1, err
}
// Closing the socket interrupts both reads and writes on cancellation.
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
// Prepare correlation data before starting the round-trip timer. The token
// also distinguishes delayed replies after the 16-bit sequence wraps.
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
return -1, err
}
echo := &icmp.Echo{
ID: os.Getpid() & 0xffff,
Seq: int(icmpSequence.Add(1) & 0xffff),
Data: token,
}
// Linux ping sockets replace the Echo ID with their bound port. Darwin
// datagram sockets and raw sockets preserve the supplied ID.
if local, ok := conn.LocalAddr().(*net.UDPAddr); ok && runtime.GOOS == "linux" {
echo.ID = local.Port
}
targetIP := icmpAddrIP(dst)
msg := &icmp.Message{
Type: family.echoType,
Code: 0,
Body: echo,
}
msgBytes, err := msg.Marshal(nil)
if err != nil {
return -1, err
}
// Set deadline before sending
if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return -1, err
}
buf := make([]byte, 1500)
start := time.Now()
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
return -1, err
}
// Read reply
for {
n, peer, err := conn.ReadFrom(buf)
received := time.Now()
if err != nil {
return -1, err
}
if !targetIP.Equal(icmpAddrIP(peer)) {
continue
}
reply, err := icmp.ParseMessage(family.proto, buf[:n])
if err != nil || reply.Type != family.replyType || reply.Code != 0 {
continue
}
body, ok := reply.Body.(*icmp.Echo)
if ok && body.ID == echo.ID && body.Seq == echo.Seq && bytes.Equal(body.Data, echo.Data) {
return received.Sub(start).Microseconds(), nil
}
// Keep waiting for our reply without extending the original deadline.
}
}
func icmpAddrIP(addr net.Addr) net.IP {
switch addr := addr.(type) {
case *net.IPAddr:
return addr.IP
case *net.UDPAddr:
return addr.IP
default:
return nil
}
}
// pingCommand selects the executable and arguments for the supported agent platforms.
// The context deadline enforces the timeout: -W has incompatible meanings across
// Linux, BSD IPv4 ping, and macOS ping6.
func pingCommand(goos, target string, isIPv6 bool) (string, []string, error) {
family := "-4"
if isIPv6 {
family = "-6"
}
switch goos {
case "windows":
return "ping", []string{family, "-n", "1", "-w", "3000", target}, nil
case "linux":
return "ping", []string{family, "-n", "-c", "1", target}, nil
case "darwin", "freebsd", "openbsd":
command := "ping"
if isIPv6 {
command = "ping6"
}
return command, []string{"-n", "-c", "1", target}, nil
default:
return "", nil, fmt.Errorf("ping fallback is unsupported on %s", goos)
}
}
// monitorICMPExec falls back to the system ping command. Returns -1 and an error on failure.
func monitorICMPExec(ctx context.Context, target string, isIPv6 bool) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
name, args, err := pingCommand(runtime.GOOS, target, isIPv6)
if err != nil {
return -1, err
}
cmd := exec.CommandContext(ctx, name, args...)
// Keep Unix output and decimal formatting stable. Windows ignores LC_ALL.
cmd.Env = append(os.Environ(), "LC_ALL=C")
output, err := cmd.Output()
if ctx.Err() != nil {
return -1, ctx.Err()
}
if err != nil {
return -1, fmt.Errorf("%s failed: %w", name, err)
}
return parsePingResponse(output)
}
// parsePingResponse returns the reported RTT, never subprocess execution time.
// For a bounded value such as Windows' time<1ms, retain the reported upper bound.
func parsePingResponse(output []byte) (int64, error) {
matches := pingTimeRegex.FindSubmatch(output)
if len(matches) < 2 {
return -1, errors.New("ping output contains no round-trip time")
}
ms, err := strconv.ParseFloat(strings.ReplaceAll(string(matches[1]), ",", "."), 64)
if err != nil || math.IsInf(ms, 0) || ms >= float64(math.MaxInt64)/1000 {
return -1, errors.New("invalid round-trip time in ping output")
}
return int64(math.Round(ms * 1000)), nil
}
+433
View File
@@ -0,0 +1,433 @@
//go:build testing
package agent
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/icmp"
)
type testICMPPacketConn struct{}
func (testICMPPacketConn) Close() error { return nil }
type blockingICMPConn struct {
net.PacketConn
reading chan struct{}
}
func (c *blockingICMPConn) WriteTo(p []byte, addr net.Addr) (int, error) {
return len(p), nil
}
func (c *blockingICMPConn) ReadFrom(p []byte) (int, net.Addr, error) {
close(c.reading)
return c.PacketConn.ReadFrom(p)
}
func TestMonitorICMPPacketCancellation(t *testing.T) {
conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
require.NoError(t, err)
defer conn.Close()
blocking := &blockingICMPConn{PacketConn: conn, reading: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
_, err := monitorICMPPacket(ctx, blocking, &icmpV4, conn.LocalAddr())
done <- err
}()
select {
case <-blocking.reading:
case <-time.After(time.Second):
t.Fatal("probe did not begin reading")
}
cancel()
select {
case err := <-done:
require.Error(t, err)
case <-time.After(time.Second):
t.Fatal("cancellation did not interrupt the socket read")
}
}
func TestMonitorICMPExecCancellation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX shell stub for ping")
}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ping"), []byte("#!/bin/sh\nexec sleep 30\n"), 0o755))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := monitorICMPExec(ctx, "127.0.0.1", false)
done <- err
}()
select {
case err := <-done:
require.ErrorIs(t, err, context.DeadlineExceeded)
case <-time.After(time.Second):
t.Fatal("cancellation did not terminate ping")
}
}
func TestPingCommand(t *testing.T) {
for _, goos := range []string{"linux", "windows", "darwin", "freebsd", "openbsd"} {
for _, ipv6 := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/ipv6=%t", goos, ipv6), func(t *testing.T) {
target, family := "192.0.2.1", "-4"
if ipv6 {
target, family = "2001:db8::1", "-6"
}
name, args, err := pingCommand(goos, target, ipv6)
require.NoError(t, err)
wantName := "ping"
wantArgs := []string{"-n", "-c", "1", target}
switch goos {
case "windows":
wantArgs = []string{family, "-n", "1", "-w", "3000", target}
case "linux":
wantArgs = append([]string{family}, wantArgs...)
default:
if ipv6 {
wantName = "ping6"
}
}
assert.Equal(t, wantName, name)
assert.Equal(t, wantArgs, args)
})
}
}
_, _, err := pingCommand("unsupported", "192.0.2.1", false)
require.Error(t, err)
}
func TestParsePingResponse(t *testing.T) {
for _, tc := range []struct {
name string
output string
wantUs int64
}{
{"linux", "64 bytes from 192.0.2.1: icmp_seq=1 ttl=64 time=12.345 ms", 12345},
{"bsd", "64 bytes from 192.0.2.1: icmp_seq=0 ttl=64 time=0.023 ms", 23},
{"ipv6", "64 bytes from 2001:db8::1: icmp_seq=0 hlim=64 time=1.234 ms", 1234},
{"windows", "Reply from 192.0.2.1: bytes=32 time=12ms TTL=128", 12000},
{"windows submillisecond", "Reply from ::1: time<1ms", 1000},
{"localized windows", "Antwort von 192.0.2.1: Bytes=32 Zeit=12ms TTL=128", 12000},
{"decimal comma", "64 bytes from 192.0.2.1: time=1,234 ms", 1234},
{"rounding", "time=0.1236 ms", 124},
{"empty", "", -1},
{"timeout", "Request timed out.", -1},
{"unreachable", "Reply from 192.0.2.1: Destination host unreachable.", -1},
{"malformed", "time=oops ms", -1},
{"negative", "time=-1 ms", -1},
{"overflow", "time=999999999999999999999 ms", -1},
} {
t.Run(tc.name, func(t *testing.T) {
responseUs, err := parsePingResponse([]byte(tc.output))
if tc.wantUs < 0 {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantUs, responseUs)
})
}
}
func TestMonitorICMPExecOutput(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX shell stub for ping")
}
for _, tc := range []struct {
name string
output string
exit int
wantUs int64
}{
{"success", "time=1.234 ms", 0, 1234},
{"missing RTT", "unrecognized output", 0, -1},
{"failed command with RTT", "time=1.234 ms", 1, -1},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
// Also verify an inherited locale cannot override the C locale.
script := fmt.Sprintf("#!/bin/sh\n[ \"$LC_ALL\" = C ] || exit 2\nprintf '%%s\\n' '%s'\nexit %d\n", tc.output, tc.exit)
require.NoError(t, os.WriteFile(filepath.Join(dir, "ping"), []byte(script), 0o755))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("LC_ALL", "de_DE.UTF-8")
responseUs, err := monitorICMPExec(t.Context(), "127.0.0.1", false)
if tc.wantUs < 0 {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantUs, responseUs)
})
}
}
type icmpTestReply struct {
data []byte
peer net.Addr
}
type scriptedICMPConn struct {
net.PacketConn
local net.Addr
onWrite func([]byte, net.Addr)
replies []icmpTestReply
reads int
deadlineSets int
}
func (c *scriptedICMPConn) LocalAddr() net.Addr { return c.local }
func (c *scriptedICMPConn) SetDeadline(deadline time.Time) error {
c.deadlineSets++
return nil
}
func (c *scriptedICMPConn) WriteTo(data []byte, dst net.Addr) (int, error) {
c.onWrite(data, dst)
return len(data), nil
}
func (c *scriptedICMPConn) ReadFrom(buf []byte) (int, net.Addr, error) {
c.reads++
if len(c.replies) == 0 {
return 0, nil, os.ErrDeadlineExceeded
}
reply := c.replies[0]
c.replies = c.replies[1:]
return copy(buf, reply.data), reply.peer, nil
}
func TestMonitorICMPReplyCorrelation(t *testing.T) {
for _, family := range []*icmpFamily{&icmpV4, &icmpV6} {
for _, datagram := range []bool{false, true} {
network := family.rawNetwork
ip, other := net.ParseIP("192.0.2.1"), net.ParseIP("192.0.2.2")
if family.isIPv6 {
ip, other = net.ParseIP("2001:db8::1"), net.ParseIP("2001:db8::2")
}
var dst net.Addr = &net.IPAddr{IP: ip}
var wrongPeer net.Addr = &net.IPAddr{IP: other}
if datagram {
network = family.dgramNetwork
dst = &net.UDPAddr{IP: ip}
wrongPeer = &net.UDPAddr{IP: other}
}
for _, mismatch := range []string{"source", "id", "sequence", "payload", "type", "code", "malformed"} {
for _, eventuallyMatches := range []bool{false, true} {
ending := "timeout"
if eventuallyMatches {
ending = "success"
}
t.Run(network+"/"+mismatch+"/"+ending, func(t *testing.T) {
conn := &scriptedICMPConn{local: &net.IPAddr{IP: net.IPv4zero}}
if datagram {
conn.local = &net.UDPAddr{Port: 12345}
if runtime.GOOS == "linux" {
// Deliberately differ from the process ID.
conn.local = &net.UDPAddr{Port: (os.Getpid() % 65534) + 1}
}
}
conn.onWrite = func(data []byte, target net.Addr) {
require.Equal(t, dst, target)
request, err := icmp.ParseMessage(family.proto, data)
require.NoError(t, err)
echo := request.Body.(*icmp.Echo)
expectedID := os.Getpid() & 0xffff
if datagram && runtime.GOOS == "linux" {
expectedID = conn.local.(*net.UDPAddr).Port
}
require.Equal(t, expectedID, echo.ID)
reply := &icmp.Message{Type: family.replyType, Body: echo}
valid, err := reply.Marshal(nil)
require.NoError(t, err)
peer := dst
switch mismatch {
case "source":
peer = wrongPeer
case "id":
echo.ID ^= 1
case "sequence":
echo.Seq ^= 1
case "payload":
echo.Data[0] ^= 1
case "type":
reply.Type = family.echoType
case "code":
reply.Code = 1
}
invalid, err := reply.Marshal(nil)
require.NoError(t, err)
if mismatch == "malformed" {
invalid = invalid[:2]
}
conn.replies = []icmpTestReply{{invalid, peer}}
if eventuallyMatches {
conn.replies = append(conn.replies, icmpTestReply{valid, dst})
}
}
elapsed, err := monitorICMPPacket(context.Background(), conn, family, dst)
if eventuallyMatches {
require.NoError(t, err)
assert.GreaterOrEqual(t, elapsed, int64(0))
} else {
require.ErrorIs(t, err, os.ErrDeadlineExceeded)
assert.Equal(t, int64(-1), elapsed)
}
assert.Equal(t, 2, conn.reads)
assert.Equal(t, 1, conn.deadlineSets)
})
}
}
}
}
}
func TestMonitorICMPLoopback(t *testing.T) {
for _, family := range []*icmpFamily{&icmpV4, &icmpV6} {
for _, network := range []string{family.rawNetwork, family.dgramNetwork} {
t.Run(network, func(t *testing.T) {
conn, err := icmp.ListenPacket(network, family.listenAddr)
if err != nil {
t.Skipf("ICMP socket unavailable: %v", err)
}
defer conn.Close()
ip := net.ParseIP("127.0.0.1")
if family.isIPv6 {
ip = net.ParseIP("::1")
}
var dst net.Addr = &net.IPAddr{IP: ip}
if network == family.dgramNetwork {
dst = &net.UDPAddr{IP: ip}
}
elapsed, err := monitorICMPPacket(context.Background(), conn, family, dst)
require.NoError(t, err)
assert.GreaterOrEqual(t, elapsed, int64(0))
})
}
}
}
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(context.Background(), "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(context.Background(), "::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(context.Background(), "::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())
})
}
+105
View File
@@ -0,0 +1,105 @@
package agent
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
// Implementations must honor cancellation and bound their execution time.
type monitorProbe func(context.Context, monitor.Config) (int64, error)
func networkMonitorProbe(client *http.Client) monitorProbe {
return func(ctx context.Context, config monitor.Config) (int64, error) {
switch config.Protocol {
case "icmp":
return monitorICMP(ctx, config.Target)
case "tcp":
return monitorTCP(ctx, config.Target, config.Port)
case "http":
return monitorHTTP(ctx, client, config.Target)
case "dns":
return monitorDNS(ctx, config.Target)
default:
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
}
}
}
// monitorTCP measures connection establishment time, including address fallback
// but excluding DNS resolution.
// Returns -1 and an error on failure.
func monitorTCP(ctx context.Context, target string, port uint16) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// Resolve DNS first, outside the timing window but within the probe deadline.
ips, err := net.DefaultResolver.LookupHost(ctx, target)
if err != nil {
return -1, err
}
if len(ips) == 0 {
return -1, errors.New("no addresses resolved for TCP monitor")
}
portString := fmt.Sprintf("%d", port)
deadline, _ := ctx.Deadline()
// Share the remaining probe budget across addresses so an unresponsive
// first address cannot consume all the time available for alternatives.
start := time.Now()
for i, ip := range ips {
if err := ctx.Err(); err != nil {
return -1, err
}
dialer := net.Dialer{Timeout: time.Until(deadline) / time.Duration(len(ips)-i)}
var conn net.Conn
conn, err = dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, portString))
if err != nil {
continue
}
responseUs := time.Since(start).Microseconds()
conn.Close()
return responseUs, nil
}
return -1, err
}
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
func monitorDNS(ctx context.Context, target string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
start := time.Now()
ips, err := net.DefaultResolver.LookupHost(ctx, target)
if err != nil || len(ips) == 0 {
return -1, err
}
return time.Since(start).Microseconds(), nil
}
// monitorHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, error) {
if client == nil {
client = http.DefaultClient
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return -1, err
}
resp, err := client.Do(req)
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
}
+88
View File
@@ -0,0 +1,88 @@
package agent
import (
"sync"
"time"
)
const (
monitorResumeHeartbeat = 10 * time.Second
// Allow scheduling jitter without mistaking an ordinary tick for resume.
monitorResumeGap = 2 * monitorResumeHeartbeat
monitorResumePause = 10 * time.Second
)
// monitorResumeGuard detects likely suspend/resume using wall time. A long
// process stall or forward clock adjustment can also trigger the bounded pause.
// One heartbeat is shared by all configured monitors.
type monitorResumeGuard struct {
mu sync.Mutex
stop chan struct{}
lastTick time.Time
pauseUntil time.Time
generation uint32
}
func (g *monitorResumeGuard) start() {
g.mu.Lock()
defer g.mu.Unlock()
if g.stop != nil {
return
}
stop := make(chan struct{})
g.stop = stop
g.lastTick = time.Now().Round(0)
g.pauseUntil = time.Time{}
go func() {
ticker := time.NewTicker(monitorResumeHeartbeat)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
g.mu.Lock()
if g.stop == stop {
g.observe(time.Now())
}
g.mu.Unlock()
}
}
}()
}
func (g *monitorResumeGuard) shutdown() {
g.mu.Lock()
defer g.mu.Unlock()
if g.stop != nil {
close(g.stop)
g.stop = nil
g.generation++
}
}
// observe requires mu. Strip the monotonic component because it can stop during
// suspend. Read the current time rather than the ticker's queued timestamp.
func (g *monitorResumeGuard) observe(now time.Time) {
now = now.Round(0)
if now.Sub(g.lastTick) > monitorResumeGap {
g.pauseUntil = now.Add(monitorResumePause)
g.generation++
}
g.lastTick = now
}
// snapshot also observes time so a probe waking before the heartbeat detects
// resume itself. A changed generation invalidates probes spanning suspend.
func (g *monitorResumeGuard) snapshot() (generation uint32, allowed bool) {
if g == nil {
return 0, true
}
g.mu.Lock()
defer g.mu.Unlock()
if g.stop == nil {
return g.generation, true
}
g.observe(time.Now())
return g.generation, !g.lastTick.Before(g.pauseUntil)
}
+121
View File
@@ -0,0 +1,121 @@
//go:build testing
package agent
import (
"context"
"errors"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func simulateMonitorSleep(g *monitorResumeGuard) {
g.mu.Lock()
g.lastTick = time.Now().Add(-time.Hour).Round(0)
g.mu.Unlock()
}
func TestMonitorResumePause(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var g monitorResumeGuard
g.start()
defer g.shutdown()
generation, allowed := g.snapshot()
require.True(t, allowed)
// Heartbeats alone must keep the guard current between infrequent probes.
time.Sleep(time.Minute)
synctest.Wait()
steadyGeneration, allowed := g.snapshot()
require.True(t, allowed)
require.Equal(t, generation, steadyGeneration)
// The probe, rather than the heartbeat, must detect this gap.
simulateMonitorSleep(&g)
next, allowed := g.snapshot()
assert.False(t, allowed)
assert.NotEqual(t, generation, next)
time.Sleep(9 * time.Second)
_, allowed = g.snapshot()
assert.False(t, allowed)
time.Sleep(time.Second)
end, allowed := g.snapshot()
assert.True(t, allowed)
assert.Equal(t, next, end)
})
}
func TestMonitorResumeGuardLifecycle(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 1, nil })
defer pm.Stop()
assert.Nil(t, pm.resumeGuard.stop)
pm.SyncMonitors([]monitor.Config{{ID: "a", Interval: 3600}, {ID: "b", Interval: 3600}})
stop := pm.resumeGuard.stop
require.NotNil(t, stop)
pm.DeleteMonitor("a")
assert.Equal(t, stop, pm.resumeGuard.stop)
pm.DeleteMonitor("b")
assert.Nil(t, pm.resumeGuard.stop)
select {
case <-stop:
default:
t.Fatal("heartbeat was not stopped")
}
time.Sleep(time.Hour)
_, err := pm.UpsertMonitor(monitor.Config{ID: "c", Interval: 3600}, false)
require.NoError(t, err)
_, allowed := pm.resumeGuard.snapshot()
assert.True(t, allowed, "idle time must not trigger a resume pause")
pm.SyncMonitors(nil)
assert.Nil(t, pm.resumeGuard.stop)
})
}
func TestMonitorResumeDiscardsInflightProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var g monitorResumeGuard
g.start()
defer g.shutdown()
task := newMonitorTask(monitor.Config{ID: "test"})
defer task.cancel()
task.resumeGuard = &g
result := task.runProbe(func(context.Context, monitor.Config) (int64, error) {
simulateMonitorSleep(&g)
return 0, errors.New("network not ready")
})
assert.Nil(t, result)
assert.Empty(t, task.history.samples)
// Explicit requests may still run during the pause and record real failures.
result = task.runProbe(func(context.Context, monitor.Config) (int64, error) {
return 0, errors.New("unreachable")
})
require.NotNil(t, result)
assert.Equal(t, 100.0, result.PacketLoss)
})
}
func TestMonitorResumeSkipsScheduledProbes(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var calls atomic.Int32
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) {
calls.Add(1)
return 1, nil
})
defer pm.Stop()
pm.SyncMonitors([]monitor.Config{{ID: "test", Interval: 1}})
simulateMonitorSleep(&pm.resumeGuard)
pm.resumeGuard.snapshot()
time.Sleep(9 * time.Second)
synctest.Wait()
assert.Zero(t, calls.Load())
assert.Empty(t, pm.GetResults(1000))
time.Sleep(2 * time.Second)
synctest.Wait()
assert.Positive(t, calls.Load())
})
}
+60
View File
@@ -0,0 +1,60 @@
package agent
import (
"context"
"log/slog"
"math/rand"
"time"
)
func (pm *MonitorManager) startMonitor(task *monitorTask) {
interval := time.Duration(task.config.Interval) * time.Second
if interval < time.Second {
interval = 30 * time.Second
}
delay := getStagger(interval.Milliseconds())
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
go runMonitorSchedule(task.ctx, interval, delay, func() {
if _, allowed := task.resumeGuard.snapshot(); allowed {
task.runProbe(pm.probe)
}
})
}
// runMonitorSchedule owns only timing. Checks run serially, and slow checks
// naturally drop missed ticks rather than building an execution backlog.
func runMonitorSchedule(ctx context.Context, interval, delay time.Duration, run func()) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
if ctx.Err() != nil {
return
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if ctx.Err() != nil {
return
}
run()
}
}
}
// getStagger returns an initial delay between half an interval and one interval.
func getStagger(intervalMilli int64) time.Duration {
delay := rand.Intn(int(intervalMilli))
if delay < int(intervalMilli)/2 {
delay += int(intervalMilli) / 2
}
return time.Duration(delay) * time.Millisecond
}
+167
View File
@@ -0,0 +1,167 @@
//go:build testing
package agent
import (
"context"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMonitorScheduleTiming(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var calls atomic.Int32
go runMonitorSchedule(ctx, 10*time.Second, 5*time.Second, func() { calls.Add(1) })
synctest.Wait()
time.Sleep(4 * time.Second)
synctest.Wait()
assert.Equal(t, 0, int(calls.Load()))
time.Sleep(time.Second)
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()))
time.Sleep(10 * time.Second)
synctest.Wait()
assert.Equal(t, 2, int(calls.Load()))
cancel()
synctest.Wait()
time.Sleep(time.Minute)
synctest.Wait()
assert.Equal(t, 2, int(calls.Load()))
})
}
func TestMonitorScheduleSlowProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var calls atomic.Int32
release := make(chan struct{})
go runMonitorSchedule(ctx, time.Second, 0, func() {
calls.Add(1)
select {
case <-release:
case <-ctx.Done():
}
})
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()))
time.Sleep(time.Minute)
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()), "a slow probe must not spawn overlapping checks")
close(release)
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()), "missed intervals must not accumulate a backlog")
time.Sleep(time.Second)
synctest.Wait()
assert.Equal(t, 2, int(calls.Load()))
cancel()
synctest.Wait()
})
}
func TestMonitorScheduledAndImmediateRequestsShareProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var calls atomic.Int32
release := make(chan struct{})
cfg := monitor.Config{ID: "test", Interval: 10}
pm := newMonitorManagerWithProbe(func(ctx context.Context, config monitor.Config) (int64, error) {
assert.Equal(t, cfg, config)
calls.Add(1)
<-release
return 42, nil
})
defer pm.Stop()
task := newMonitorTask(cfg)
pm.monitors[cfg.ID] = task
go runMonitorSchedule(task.ctx, 10*time.Second, 0, func() { task.runProbe(pm.probe) })
synctest.Wait()
results := make(chan *monitor.Result, 2)
for range 2 {
go func() {
result, _ := pm.UpsertMonitor(cfg, true)
results <- result
}()
}
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()))
assert.Empty(t, pm.GetResults(1000), "reading history must not wait for network I/O")
close(release)
synctest.Wait()
first, second := <-results, <-results
require.NotNil(t, first)
require.NotNil(t, second)
assert.Equal(t, int64(42), first.AvgResponse)
assert.Equal(t, first, second)
assert.NotSame(t, first, second, "callers must not share mutable result pointers")
assert.Len(t, task.history.samples, 1)
// A later explicit request must still perform a fresh probe.
_, err := pm.UpsertMonitor(cfg, true)
require.NoError(t, err)
assert.Equal(t, 2, int(calls.Load()))
assert.Len(t, task.history.samples, 2)
})
}
func TestMonitorReplacementCancelsSharedProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cfg := monitor.Config{ID: "test", Interval: 10}
pm := newMonitorManagerWithProbe(func(ctx context.Context, config monitor.Config) (int64, error) {
if config.Interval == 10 {
<-ctx.Done()
return 0, ctx.Err()
}
return 30, nil
})
defer pm.Stop()
task := newMonitorTask(cfg)
task.history.record(monitorSample{responseUs: 10, timestamp: time.Now()})
pm.monitors[cfg.ID] = task
results := make(chan *monitor.Result, 2)
for range 2 {
go func() {
result, _ := pm.UpsertMonitor(cfg, true)
results <- result
}()
}
synctest.Wait()
updated := cfg
updated.Interval = 20
result, err := pm.UpsertMonitor(updated, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, int64(20), result.AvgResponse)
assert.Zero(t, result.PacketLoss)
synctest.Wait()
assert.Nil(t, <-results)
assert.Nil(t, <-results)
assert.Len(t, task.history.samples, 1)
assert.Len(t, pm.monitors[cfg.ID].history.samples, 2)
})
}
func TestMonitorInjectedProbeTimeoutRecordsLoss(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
pm := newMonitorManagerWithProbe(func(ctx context.Context, _ monitor.Config) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
<-ctx.Done()
return 0, ctx.Err()
})
defer pm.Stop()
start := time.Now()
result, err := pm.UpsertMonitor(monitor.Config{ID: "test", Interval: 3600}, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 3*time.Second, time.Since(start))
assert.Equal(t, 100.0, result.PacketLoss)
assert.NoError(t, pm.monitors["test"].ctx.Err())
})
}
+116
View File
@@ -0,0 +1,116 @@
package agent
import (
"context"
"log/slog"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const monitorFailureLogInterval = 5 * time.Minute
// monitorTask coordinates a probe and its history for one immutable configuration.
type monitorTask struct {
config monitor.Config
ctx context.Context
cancel context.CancelFunc
history *monitorHistory
resumeGuard *monitorResumeGuard
runMu sync.Mutex
inflight *monitorRun
lastFailureLog int64 // Unix nanoseconds
}
type monitorRun struct {
done chan struct{}
result *monitor.Result // published by closing done; never mutated afterwards
}
func newMonitorTask(config monitor.Config) *monitorTask {
ctx, cancel := context.WithCancel(context.Background())
task := &monitorTask{config: config, ctx: ctx, history: newMonitorHistory()}
// Serialize cancellation with publication, so canceled probes cannot enter
// history copied into a replacement task.
task.cancel = func() {
task.runMu.Lock()
cancel()
task.runMu.Unlock()
}
return task
}
func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *monitorTask {
task := newMonitorTask(config)
if existing != nil {
task.history = existing.history.clone()
}
return task
}
// runProbe shares an in-flight check between scheduled and immediate requests.
// Every completed check contributes exactly one sample, regardless of how many
// callers were waiting for it. No task or history lock is held during network I/O.
func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result {
task.runMu.Lock()
if task.ctx.Err() != nil {
task.runMu.Unlock()
return nil
}
if run := task.inflight; run != nil {
task.runMu.Unlock()
select {
case <-task.ctx.Done():
return nil
case <-run.done:
if task.ctx.Err() != nil {
return nil
}
return copyMonitorResult(run.result)
}
}
run := &monitorRun{done: make(chan struct{})}
task.inflight = run
task.runMu.Unlock()
generation, _ := task.resumeGuard.snapshot()
responseUs, err := probe(task.ctx, task.config)
var logFailure bool
task.runMu.Lock()
currentGeneration, _ := task.resumeGuard.snapshot()
if task.ctx.Err() == nil && generation == currentGeneration {
now := time.Now()
if err != nil {
responseUs = -1
logAt := now.UnixNano()
if task.lastFailureLog == 0 || logAt < task.lastFailureLog || logAt-task.lastFailureLog >= int64(monitorFailureLogInterval) {
logFailure = true
task.lastFailureLog = logAt
}
} else {
task.lastFailureLog = 0
}
result := task.history.record(monitorSample{responseUs: responseUs, timestamp: now})
run.result = &result
}
task.inflight = nil
close(run.done)
task.runMu.Unlock()
if logFailure {
slog.Warn("monitor failed", "err", err, "target", task.config.Target, "protocol", task.config.Protocol)
}
if task.ctx.Err() != nil {
return nil
}
return copyMonitorResult(run.result)
}
func copyMonitorResult(result *monitor.Result) *monitor.Result {
if result == nil {
return nil
}
copy := *result
return &copy
}
+79
View File
@@ -0,0 +1,79 @@
//go:build testing
package agent
import (
"bytes"
"context"
"errors"
"log/slog"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMonitorFailureLogCooldown(t *testing.T) {
var logs bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(previous) })
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "example.test", Protocol: "tcp"})
defer task.cancel()
failure := errors.New("connection refused")
probe := func(context.Context, monitor.Config) (int64, error) { return 42, failure }
var samples int64
check := func(wantLog bool) {
t.Helper()
logs.Reset()
result := task.runProbe(probe)
require.NotNil(t, result)
samples++
assert.Equal(t, samples, result.SampleCount, "suppressed warnings must still record samples")
if !wantLog {
assert.Empty(t, logs.String())
} else {
assert.Contains(t, logs.String(), `msg="monitor failed"`)
assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("\n")))
}
}
check(true)
check(false)
time.Sleep(5*time.Minute - time.Nanosecond)
check(false)
time.Sleep(time.Nanosecond)
check(true)
check(false)
time.Sleep(5 * time.Minute)
check(true)
check(false)
// Recovery clears the cooldown.
failure = nil
check(false)
failure = errors.New("connection refused again")
check(true)
// Another monitor has its own cooldown.
other := newMonitorTask(task.config)
defer other.cancel()
logs.Reset()
require.NotNil(t, other.runProbe(probe))
assert.Contains(t, logs.String(), `msg="monitor failed"`)
// A canceled probe must not publish a failure or emit a warning.
logs.Reset()
result := other.runProbe(func(context.Context, monitor.Config) (int64, error) {
other.cancel()
return -1, context.Canceled
})
assert.Nil(t, result)
assert.Empty(t, logs.String())
})
}
+524
View File
@@ -0,0 +1,524 @@
package agent
import (
"context"
"encoding/binary"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/dns/dnsmessage"
)
func TestMonitorManagerGetResultsIncludesHourResponseRange(t *testing.T) {
now := time.Now().UTC()
task := newMonitorTask(monitor.Config{ID: "monitor-1"})
task.history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-30 * time.Minute)})
task.history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
task.history.addSampleLocked(monitorSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
task.history.addSampleLocked(monitorSample{responseUs: 30, timestamp: now.Add(-50 * time.Second)})
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{"icmp:example.com": task}
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["monitor-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 TestMonitorManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
now := time.Now().UTC()
task := newMonitorTask(monitor.Config{ID: "monitor-1"})
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{"icmp:example.com": task}
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["monitor-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 TestMonitorConfigResultKeyUsesSyncedID(t *testing.T) {
cfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
assert.Equal(t, "monitor-1", cfg.ID)
}
func TestMonitorManagerSyncMonitorsSkipsConfigsWithoutStableID(t *testing.T) {
validCfg := monitor.Config{ID: "monitor-1", Target: "ignored", Protocol: "noop", Interval: 10}
invalidCfg := monitor.Config{Target: "ignored", Protocol: "noop", Interval: 10}
pm := newMonitorManager()
pm.SyncMonitors([]monitor.Config{validCfg, invalidCfg})
defer pm.Stop()
_, validExists := pm.monitors[validCfg.ID]
_, invalidExists := pm.monitors[invalidCfg.ID]
assert.True(t, validExists)
assert.False(t, invalidExists)
}
func TestMonitorManagerSyncMonitorsStopsRemovedTasksButKeepsExisting(t *testing.T) {
keepCfg := monitor.Config{ID: "monitor-1", Target: "ignored", Protocol: "noop", Interval: 10}
removeCfg := monitor.Config{ID: "monitor-2", Target: "ignored", Protocol: "noop", Interval: 10}
keptTask := newMonitorTask(keepCfg)
removedTask := newMonitorTask(removeCfg)
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{
keepCfg.ID: keptTask,
removeCfg.ID: removedTask,
}
pm.SyncMonitors([]monitor.Config{keepCfg})
assert.Same(t, keptTask, pm.monitors[keepCfg.ID])
_, exists := pm.monitors[removeCfg.ID]
assert.False(t, exists)
select {
case <-removedTask.ctx.Done():
default:
t.Fatal("expected removed monitor task to be cancelled")
}
select {
case <-keptTask.ctx.Done():
t.Fatal("expected existing monitor task to remain active")
default:
}
}
func TestMonitorManagerSyncMonitorsRestartsChangedConfig(t *testing.T) {
originalCfg := monitor.Config{ID: "monitor-1", Target: "ignored-a", Protocol: "noop", Interval: 10}
updatedCfg := monitor.Config{ID: "monitor-1", Target: "ignored-b", Protocol: "noop", Interval: 10}
originalTask := newMonitorTask(originalCfg)
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{
originalCfg.ID: originalTask,
}
pm.SyncMonitors([]monitor.Config{updatedCfg})
defer pm.Stop()
restartedTask := pm.monitors[updatedCfg.ID]
assert.NotSame(t, originalTask, restartedTask)
assert.Equal(t, updatedCfg, restartedTask.config)
select {
case <-originalTask.ctx.Done():
default:
t.Fatal("expected changed monitor task to be cancelled")
}
}
func TestMonitorManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
pm := &MonitorManager{
monitors: make(map[string]*monitorTask),
probe: networkMonitorProbe(server.Client()),
}
resp, err := pm.HandleSyncRequest(monitor.SyncRequest{
Action: monitor.SyncActionUpsert,
Config: monitor.Config{ID: "monitor-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.monitors["monitor-1"]
require.NotNil(t, task)
task.history.mu.Lock()
defer task.history.mu.Unlock()
require.Len(t, task.history.samples, 1)
}
func TestMonitorManagerUpsertMonitorKeepsHistoryWhenOnlyIntervalChanges(t *testing.T) {
originalCfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
updatedCfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 30}
now := time.Now().UTC()
existingTask := newMonitorTask(originalCfg)
existingTask.history.addSampleLocked(monitorSample{responseUs: 12, timestamp: now.Add(-50 * time.Minute)})
existingTask.history.addSampleLocked(monitorSample{responseUs: 24, timestamp: now.Add(-30 * time.Second)})
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{originalCfg.ID: existingTask}
result, err := pm.UpsertMonitor(updatedCfg, false)
defer pm.Stop()
require.NoError(t, err)
assert.Nil(t, result)
updatedTask := pm.monitors[updatedCfg.ID]
require.NotNil(t, updatedTask)
assert.NotSame(t, existingTask, updatedTask)
assert.Equal(t, updatedCfg, updatedTask.config)
updatedTask.history.mu.Lock()
defer updatedTask.history.mu.Unlock()
require.Len(t, updatedTask.history.samples, 1)
assert.Equal(t, int64(24), updatedTask.history.samples[0].responseUs)
agg := updatedTask.history.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.ctx.Done():
default:
t.Fatal("expected original monitor task to be cancelled")
}
}
func TestMonitorManagerApplySyncDeleteRemovesTask(t *testing.T) {
config := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
task := newMonitorTask(config)
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{config.ID: task}
_, err := pm.HandleSyncRequest(monitor.SyncRequest{
Action: monitor.SyncActionDelete,
Config: monitor.Config{ID: config.ID},
})
require.NoError(t, err)
_, exists := pm.monitors[config.ID]
assert.False(t, exists)
select {
case <-task.ctx.Done():
default:
t.Fatal("expected deleted monitor task to be cancelled")
}
}
func TestMonitorManagerGetRandomDelay(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 TestMonitorHTTP(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 := monitorHTTP(context.Background(), 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 := monitorHTTP(context.Background(), server.Client(), server.URL)
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
}
func TestMonitorTCP(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 := monitorTCP(context.Background(), "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 := monitorTCP(context.Background(), "127.0.0.1", port)
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
}
func TestMonitorTCPAddressFallback(t *testing.T) {
for _, tc := range []struct {
name string
ips []string
loss bool
}{
{"first address fails", []string{"127.0.0.2", "127.0.0.1"}, false},
{"first address succeeds", []string{"127.0.0.1", "127.0.0.2"}, false},
{"all addresses fail", []string{"127.0.0.2", "127.0.0.3"}, true},
} {
t.Run(tc.name, func(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
original := net.DefaultResolver
net.DefaultResolver = tcpMonitorTestResolver(tc.ips)
defer func() { net.DefaultResolver = original }()
// Verify the resolver preserves the intended order, so success cannot
// accidentally bypass the failed first address in the regression case.
ips, err := net.DefaultResolver.LookupHost(t.Context(), "tcp-monitor.invalid.")
require.NoError(t, err)
require.Equal(t, tc.ips, ips)
responseUs, err := monitorTCP(t.Context(), "tcp-monitor.invalid.", uint16(listener.Addr().(*net.TCPAddr).Port))
if tc.loss {
require.Error(t, err)
assert.Equal(t, int64(-1), responseUs)
} else {
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
}
})
}
}
// tcpMonitorTestResolver supplies multiple A records without external DNS.
func tcpMonitorTestResolver(ips []string) *net.Resolver {
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
// net.Resolver uses TCP framing when its connection is not a PacketConn.
var size uint16
if err := binary.Read(server, binary.BigEndian, &size); err != nil {
return
}
packet := make([]byte, size)
if _, err := io.ReadFull(server, packet); err != nil {
return
}
var msg dnsmessage.Message
if err := msg.Unpack(packet); err != nil {
return
}
msg.Header.Response = true
msg.Header.RecursionAvailable = true
for _, question := range msg.Questions {
if question.Type != dnsmessage.TypeA {
continue
}
for _, ip := range ips {
msg.Answers = append(msg.Answers, dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
})
}
}
packet, err := msg.Pack()
if err != nil {
return
}
response := binary.BigEndian.AppendUint16(nil, uint16(len(packet)))
_, _ = server.Write(append(response, packet...))
}()
return client, nil
}}
}
func TestMonitorDNS(t *testing.T) {
t.Run("success", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "localhost")
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("lookup failure", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "")
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
}
func TestMonitorManagerCancelsActiveProbe(t *testing.T) {
for _, action := range []string{"stop", "delete", "upsert", "sync replace", "sync remove"} {
t.Run(action, func(t *testing.T) {
started := make(chan struct{})
canceled := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
select {
case <-r.Context().Done():
close(canceled)
case <-release:
}
}))
defer server.Close()
defer close(release)
pm := newMonitorManager()
defer pm.Stop()
cfg := monitor.Config{ID: "test", Protocol: "http", Target: server.URL, Interval: 3600}
task := newMonitorTask(cfg)
// Seed history to ensure a canceled RunNow does not return an old result.
task.history.addSampleLocked(monitorSample{responseUs: 123, timestamp: time.Now()})
pm.monitors[cfg.ID] = task
done := make(chan *monitor.Result, 1)
go func() {
result, _ := pm.UpsertMonitor(cfg, true)
done <- result
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("probe did not start")
}
updated := cfg
updated.Interval--
switch action {
case "stop":
pm.Stop()
case "delete":
pm.DeleteMonitor(cfg.ID)
case "upsert":
_, err := pm.UpsertMonitor(updated, false)
require.NoError(t, err)
case "sync replace":
pm.SyncMonitors([]monitor.Config{updated})
case "sync remove":
pm.SyncMonitors(nil)
}
select {
case <-canceled:
case <-time.After(time.Second):
t.Fatal("active HTTP request was not canceled")
}
select {
case result := <-done:
assert.Nil(t, result)
case <-time.After(time.Second):
t.Fatal("RunNow did not return after cancellation")
}
task.history.mu.Lock()
assert.Len(t, task.history.samples, 1, "cancellation must not record packet loss")
task.history.mu.Unlock()
})
}
}
func TestMonitorResolutionCancellation(t *testing.T) {
for _, protocol := range []string{"tcp", "dns", "icmp"} {
t.Run(protocol, func(t *testing.T) {
started := make(chan struct{}, 1)
original := net.DefaultResolver
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
select {
case started <- struct{}{}:
default:
}
<-ctx.Done()
return nil, ctx.Err()
}}
defer func() { net.DefaultResolver = original }()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
var err error
switch protocol {
case "tcp":
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
case "dns":
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.")
case "icmp":
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
}
done <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("lookup did not start")
}
cancel()
select {
case err := <-done:
require.Error(t, err)
case <-time.After(time.Second):
t.Fatal("lookup did not cancel")
}
})
}
}
func TestMonitorProbeTimeoutRecordsLoss(t *testing.T) {
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
defer server.Close()
defer close(release)
pm := newMonitorManager()
pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond})
task := newMonitorTask(monitor.Config{ID: "timeout", Protocol: "http", Target: server.URL})
defer task.cancel()
result := task.runProbe(pm.probe)
require.NotNil(t, result)
assert.Equal(t, 100.0, result.PacketLoss)
assert.Equal(t, 100.0, result.PacketLoss1h)
require.Len(t, task.history.samples, 1)
assert.Equal(t, int64(-1), task.history.samples[0].responseUs)
assert.NoError(t, task.ctx.Err(), "a probe timeout must not cancel the task")
}
+4 -1
View File
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.19.0"
Version = "0.20.0"
// AppName is the name of the application.
AppName = "beszel"
)
@@ -19,3 +19,6 @@ var MinVersionAgentResponse = semver.MustParse("0.13.0")
// MinVersionZfsData is the minimum agent version that supports ZFS detail requests.
var MinVersionZfsData = semver.MustParse("0.18.9")
// MinVersionNetworkMonitors is the minimum agent version that supports network monitor sync.
var MinVersionNetworkMonitors = semver.MustParse("0.20.0")
+9 -6
View File
@@ -20,10 +20,11 @@ type hubLike interface {
}
type AlertManager struct {
hub hubLike
stopOnce sync.Once
pendingAlerts sync.Map
alertsCache *AlertsCache
hub hubLike
stopOnce sync.Once
pendingAlerts sync.Map
alertsCache *AlertsCache
networkMonitors *networkMonitorCache
}
type AlertMessageData struct {
@@ -107,8 +108,9 @@ var supportsTitle = map[string]struct{}{
// NewAlertManager creates a new AlertManager instance.
func NewAlertManager(app hubLike) *AlertManager {
am := &AlertManager{
hub: app,
alertsCache: NewAlertsCache(app),
hub: app,
alertsCache: NewAlertsCache(app),
networkMonitors: newNetworkMonitorCache(app),
}
am.bindEvents()
return am
@@ -116,6 +118,7 @@ func NewAlertManager(app hubLike) *AlertManager {
// Bind events to the alerts collection lifecycle
func (am *AlertManager) bindEvents() {
am.bindNetworkMonitorAlertEvents()
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
+7
View File
@@ -29,6 +29,13 @@ func UpsertUserAlerts(e *core.RequestEvent) error {
return e.BadRequestError("Bad data", err)
}
if reqData.Name == alertNameNetworkMonitorLoss {
if reqData.Value < 0 || reqData.Value >= 100 {
return e.BadRequestError("Monitor loss threshold must be at least 0 and below 100", nil)
}
reqData.Min = 0
}
alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts")
if err != nil {
return err
+38 -12
View File
@@ -1,6 +1,7 @@
package alerts
import (
"sync"
"time"
"github.com/pocketbase/dbx"
@@ -18,6 +19,9 @@ type CachedAlertData struct {
Triggered bool
Min uint8
PendingSince time.Time
// Immutable after publication; decoded only when the alert record changes.
MonitorStates map[string]string
MonitorStatesValid bool
// Created types.DateTime
}
@@ -30,11 +34,18 @@ func (a *CachedAlertData) PopulateFromRecord(record *core.Record) {
a.Triggered = record.GetBool("triggered")
a.Min = uint8(record.GetInt("min"))
a.PendingSince = record.GetDateTime("pending_since").Time()
if a.Name == alertNameNetworkMonitorLoss {
var state networkMonitorAlertState
a.MonitorStatesValid = record.UnmarshalJSONField("state", &state) == nil
a.MonitorStates = state.Monitors
}
// a.Created = record.GetDateTime("created")
}
// AlertsCache provides an in-memory cache for system alerts.
type AlertsCache struct {
// Serialize lazy loads with updates so a late load cannot replace newer state.
loadMu sync.Mutex
app core.App
store *store.Store[string, *store.Store[string, CachedAlertData]]
populated bool
@@ -69,6 +80,8 @@ func (c *AlertsCache) bindEvents() *AlertsCache {
// PopulateFromDB clears current entries and loads all alerts from the database into the cache.
func (c *AlertsCache) PopulateFromDB(force bool) error {
c.loadMu.Lock()
defer c.loadMu.Unlock()
if !force && c.populated {
return nil
}
@@ -78,7 +91,7 @@ func (c *AlertsCache) PopulateFromDB(force bool) error {
}
c.store.RemoveAll()
for _, record := range records {
c.Update(record)
c.update(record)
}
c.populated = true
return nil
@@ -86,6 +99,12 @@ func (c *AlertsCache) PopulateFromDB(force bool) error {
// Update adds or updates an alert record in the cache.
func (c *AlertsCache) Update(record *core.Record) {
c.loadMu.Lock()
defer c.loadMu.Unlock()
c.update(record)
}
func (c *AlertsCache) update(record *core.Record) {
systemID := record.GetString("system")
if systemID == "" {
return
@@ -102,6 +121,8 @@ func (c *AlertsCache) Update(record *core.Record) {
// Delete removes an alert record from the cache.
func (c *AlertsCache) Delete(record *core.Record) {
c.loadMu.Lock()
defer c.loadMu.Unlock()
systemID := record.GetString("system")
if systemID == "" {
return
@@ -115,18 +136,23 @@ func (c *AlertsCache) Delete(record *core.Record) {
func (c *AlertsCache) GetSystemAlerts(systemID string) []CachedAlertData {
systemStore, ok := c.store.GetOk(systemID)
if !ok {
// Populate cache for this system
records, err := c.app.FindAllRecords("alerts", dbx.NewExp("system={:system}", dbx.Params{"system": systemID}))
if err != nil {
return nil
c.loadMu.Lock()
defer c.loadMu.Unlock()
systemStore, ok = c.store.GetOk(systemID)
if !ok {
// Populate cache for this system
records, err := c.app.FindAllRecords("alerts", dbx.NewExp("system={:system}", dbx.Params{"system": systemID}))
if err != nil {
return nil
}
systemStore = store.New(map[string]CachedAlertData{})
for _, record := range records {
var ca CachedAlertData
ca.PopulateFromRecord(record)
systemStore.Set(record.Id, ca)
}
c.store.Set(systemID, systemStore)
}
systemStore = store.New(map[string]CachedAlertData{})
for _, record := range records {
var ca CachedAlertData
ca.PopulateFromRecord(record)
systemStore.Set(record.Id, ca)
}
c.store.Set(systemID, systemStore)
}
all := systemStore.GetAll()
alerts := make([]CachedAlertData, 0, len(all))
+10
View File
@@ -9,6 +9,12 @@ import (
// On triggered alert record delete, set matching alert history record to resolved
func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
if e.Record.GetString("name") == alertNameNetworkMonitorLoss {
if err := resolveNetworkMonitorHistory(e.App, e.Record.Id); err != nil {
return err
}
return e.Next()
}
if !e.Record.GetBool("triggered") {
return e.Next()
}
@@ -18,6 +24,10 @@ func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
// On alert record update, update alert history record
func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
// Network monitor incidents have separate history entries per monitor.
if e.Record.GetString("name") == alertNameNetworkMonitorLoss {
return e.Next()
}
original := e.Record.Original()
new := e.Record
+269
View File
@@ -0,0 +1,269 @@
package alerts
import (
"database/sql"
"errors"
"fmt"
"math"
"net"
"strconv"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
const alertNameNetworkMonitorLoss = "NetworkMonitorLoss"
// networkMonitorAlertState is this alert type's persisted runtime state.
// Monitor IDs map to their open history entries independently of history retention.
type networkMonitorAlertState struct {
Monitors map[string]string `json:"monitors"`
}
func (am *AlertManager) bindNetworkMonitorAlertEvents() {
// Hidden fields are still writable through the record API unless protected.
protectState := func(e *core.RecordRequestEvent) error {
e.Record.Set("state", e.Record.Original().Get("state"))
oldName, newName := e.Record.Original().GetString("name"), e.Record.GetString("name")
if oldName != "" && (oldName == alertNameNetworkMonitorLoss || newName == alertNameNetworkMonitorLoss) &&
(oldName != newName || e.Record.GetString("system") != e.Record.Original().GetString("system")) {
return e.BadRequestError("Delete and recreate the alert to change its type or system", nil)
}
if e.Record.GetString("name") == alertNameNetworkMonitorLoss {
if !e.HasSuperuserAuth() && (e.Auth == nil || !userHasSystem(e.App, e.Auth.Id, e.Record.GetString("system"))) {
return e.ForbiddenError("You do not have access to this system", nil)
}
e.Record.Set("triggered", e.Record.Original().GetBool("triggered"))
value := e.Record.GetFloat("value")
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 100 {
return e.BadRequestError("Monitor loss threshold must be at least 0 and below 100", nil)
}
e.Record.Set("min", 0)
}
return e.Next()
}
am.hub.OnRecordCreateRequest("alerts").BindFunc(protectState)
am.hub.OnRecordUpdateRequest("alerts").BindFunc(protectState)
cleanup := func(e *core.RecordEvent) error {
if err := e.Next(); err != nil {
return err
}
return am.evaluateNetworkMonitorAlerts(e.App, e.Record.GetString("system"), nil)
}
am.hub.OnRecordAfterDeleteSuccess("network_monitors").BindFunc(cleanup)
am.hub.OnRecordAfterUpdateSuccess("network_monitors").BindFunc(func(e *core.RecordEvent) error {
if e.Record.GetBool("enabled") || !e.Record.Original().GetBool("enabled") {
return e.Next()
}
return cleanup(e)
})
}
// HandleNetworkMonitorAlerts runs after the full monitoring transaction commits,
// using its exact payload (dashboard requests can replace the cached payload).
// Omitted results and disconnected systems never imply recovery.
func (am *AlertManager) HandleNetworkMonitorAlerts(systemRecord *core.Record, results map[string]monitor.Result) error {
if systemRecord.GetString("status") != "up" {
return nil
}
alerts := am.alertsCache.GetAlertsByName(systemRecord.Id, alertNameNetworkMonitorLoss)
if len(alerts) == 0 {
return nil
}
monitors, err := am.networkMonitors.get(systemRecord.Id)
if err != nil {
return err
}
if !networkMonitorTransitionPending(alerts, monitors, results, time.Now()) {
return nil
}
// The cache only predicts a transition. Reload and recheck under the DB
// transaction before persisting, including current system/monitor status.
return am.evaluateNetworkMonitorAlerts(am.hub, systemRecord.Id, results)
}
// networkMonitorTransitionPending does no IO and never mutates cached maps.
func networkMonitorTransitionPending(alerts []CachedAlertData, monitors map[string]int, results map[string]monitor.Result, now time.Time) bool {
for _, alert := range alerts {
if !alert.MonitorStatesValid || alert.Triggered != (len(alert.MonitorStates) > 0) {
return true
}
for id := range alert.MonitorStates {
if _, enabled := monitors[id]; !enabled {
return true
}
}
for id, result := range results {
interval, enabled := monitors[id]
if !enabled || !monitorResultReady(result, interval, now) {
continue
}
_, active := alert.MonitorStates[id]
if (result.PacketLoss1h > alert.Value) != active {
return true
}
}
}
return false
}
func (am *AlertManager) evaluateNetworkMonitorAlerts(app core.App, systemID string, results map[string]monitor.Result) error {
var messages []AlertMessageData
err := app.RunInTransaction(func(tx core.App) error {
// Read configuration inside the transaction so concurrent threshold changes,
// disabling, and evaluations cannot overwrite each other's incident state.
alerts, err := tx.FindAllRecords("alerts", dbx.HashExp{"system": systemID, "name": alertNameNetworkMonitorLoss})
if err != nil || len(alerts) == 0 {
return err
}
system, err := tx.FindRecordById("systems", systemID)
if errors.Is(err, sql.ErrNoRows) {
// System deletion cascades to its alerts.
return nil
}
if err != nil {
return err
}
monitors, err := tx.FindAllRecords("network_monitors", dbx.HashExp{"system": systemID, "enabled": true})
if err != nil {
return err
}
enabled := make(map[string]*core.Record, len(monitors))
for _, m := range monitors {
enabled[m.Id] = m
}
now := time.Now()
for _, alert := range alerts {
var state networkMonitorAlertState
if err := alert.UnmarshalJSONField("state", &state); err != nil {
return err
}
states := state.Monitors
if states == nil {
states = map[string]string{}
}
changed := false
// Removing or disabling a monitor closes its incident silently.
for id, historyID := range states {
if _, ok := enabled[id]; !ok {
if err := resolveMonitorIncident(tx, historyID, now); err != nil {
return err
}
delete(states, id)
changed = true
}
}
if system.GetString("status") == "up" {
for _, m := range monitors {
result, ok := results[m.Id]
if !ok || !monitorResultReady(result, m.GetInt("interval"), now) {
continue
}
historyID, active := states[m.Id]
triggered := result.PacketLoss1h > alert.GetFloat("value")
if triggered == active {
continue
}
label := m.GetString("target")
if m.GetString("protocol") == "tcp" {
label = net.JoinHostPort(label, strconv.Itoa(m.GetInt("port")))
}
if triggered {
collection, err := tx.FindCachedCollectionByNameOrId("alerts_history")
if err != nil {
return err
}
history := core.NewRecord(collection)
history.Load(map[string]any{
"alert_id": alert.Id, "user": alert.GetString("user"), "system": systemID,
"name": alertNameNetworkMonitorLoss, "monitor_name": label, "value": result.PacketLoss1h,
})
if err := tx.Save(history); err != nil {
return err
}
states[m.Id] = history.Id
} else {
if err := resolveMonitorIncident(tx, historyID, now); err != nil {
return err
}
delete(states, m.Id)
}
changed = true
state, comparison := "loss", "exceeds"
if !triggered {
state, comparison = "recovered", "is at or below"
}
messages = append(messages, AlertMessageData{
UserID: alert.GetString("user"), SystemID: systemID,
Title: fmt.Sprintf("Network monitor %s on %s: %s", state, system.GetString("name"), label),
Message: fmt.Sprintf("%s on %s: loss over the past hour is %.2f%%, which %s the %.2f%% threshold.", label, system.GetString("name"), result.PacketLoss1h, comparison, alert.GetFloat("value")),
Link: am.hub.MakeLink("system", systemID), LinkText: "View " + system.GetString("name"),
})
}
}
if changed || alert.GetBool("triggered") != (len(states) > 0) {
alert.Set("state", networkMonitorAlertState{Monitors: states})
alert.Set("triggered", len(states) > 0)
if err := tx.Save(alert); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return err
}
// Match other alert types: persist transitions before delivery, and respect
// the user's existing notification destinations and quiet hours.
for _, message := range messages {
if err := am.SendAlert(message); err != nil {
app.Logger().Error("Failed to send network monitor alert", "err", err)
}
}
return nil
}
func monitorResultReady(result monitor.Result, interval int, now time.Time) bool {
// Three completed attempts provide a short warm-up, including after an agent
// restart.
if result.SampleCount < 3 || result.LastProbeAt <= 0 || math.IsNaN(result.PacketLoss1h) || math.IsInf(result.PacketLoss1h, 0) || result.PacketLoss1h < 0 || result.PacketLoss1h > 100 {
return false
}
// Never interpret an empty one-hour window as zero loss.
maxAge := min(time.Hour, max(3*time.Duration(interval)*time.Second, 3*time.Minute))
age := now.Sub(time.UnixMilli(result.LastProbeAt))
return age >= -time.Minute && age <= maxAge
}
func resolveMonitorIncident(app core.App, id string, now time.Time) error {
record, err := app.FindRecordById("alerts_history", id)
if errors.Is(err, sql.ErrNoRows) {
// History can be purged independently.
return nil
}
if err != nil {
return err
}
if !record.GetDateTime("resolved").IsZero() {
return nil
}
record.Set("resolved", now.UTC())
return app.Save(record)
}
func resolveNetworkMonitorHistory(app core.App, alertID string) error {
records, err := app.FindAllRecords("alerts_history", dbx.HashExp{"alert_id": alertID, "resolved": ""})
if err != nil {
return err
}
for _, record := range records {
record.Set("resolved", time.Now().UTC())
if err := app.Save(record); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,513 @@
//go:build testing
package alerts_test
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/entities/monitor"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func networkAlertSetup(t *testing.T) (*beszelTests.TestHub, *core.Record, *core.Record, []*core.Record) {
t.Helper()
hub, system, alert := systemdTestSetup(t, false)
t.Cleanup(hub.Cleanup)
alert.Set("name", "NetworkMonitorLoss")
alert.Set("value", 5)
require.NoError(t, hub.Save(alert))
var monitors []*core.Record
for _, name := range []string{"gateway", "website"} {
record, err := beszelTests.CreateRecord(hub, "network_monitors", map[string]any{
"system": system.Id, "target": name + ".example.com", "protocol": "icmp", "interval": 60, "enabled": true,
})
require.NoError(t, err)
monitors = append(monitors, record)
}
// Avoid starting a system update worker in tests.
_, err := hub.DB().Update("systems", dbx.Params{"status": "up"}, dbx.HashExp{"id": system.Id}).Execute()
require.NoError(t, err)
system.Set("status", "up")
return hub, system, alert, monitors
}
func monitorResult(loss float64) monitor.Result {
return monitor.Result{LastProbeAt: time.Now().UnixMilli(), SampleCount: 60, PacketLoss1h: loss}
}
func TestNetworkMonitorAlertIndependentIncidents(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
count := hub.TestMailer.TotalSend()
results := map[string]monitor.Result{monitors[0].Id: monitorResult(10), monitors[1].Id: monitorResult(0)}
check := func(active bool, open, sent int) {
t.Helper()
record, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.Equal(t, active, record.GetBool("triggered"))
total, err := hub.CountRecords("alerts_history", dbx.HashExp{"alert_id": alert.Id, "resolved": ""})
require.NoError(t, err)
assert.EqualValues(t, open, total)
assert.Equal(t, count+sent, hub.TestMailer.TotalSend())
}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
check(true, 1, 1)
message := hub.TestMailer.Messages()[count]
assert.Contains(t, message.Text, "gateway.example.com")
assert.Contains(t, message.Text, "10.00%")
assert.Contains(t, message.Text, "5.00%")
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
check(true, 1, 1)
// Persisted monitor state prevents duplicate notifications after a hub restart.
am = alerts.NewTestAlertManagerWithoutWorker(hub)
results[monitors[1].Id] = monitorResult(20)
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
check(true, 2, 2)
results[monitors[0].Id] = monitorResult(5) // Equality is a recovery.
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
check(true, 1, 3)
results[monitors[1].Id] = monitorResult(0)
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
check(false, 0, 4)
histories, err := hub.FindAllRecords("alerts_history", dbx.HashExp{"alert_id": alert.Id})
require.NoError(t, err)
require.Len(t, histories, 2)
for _, history := range histories {
assert.NotEmpty(t, history.GetString("monitor_name"))
}
}
func TestNetworkMonitorAlertTargetLabel(t *testing.T) {
for _, tc := range []struct {
protocol, target, label string
port int
}{
{"icmp", "gateway.example.com", "gateway.example.com", 0},
{"http", "https://example.com/health", "https://example.com/health", 0},
{"tcp", "example.com", "example.com:8443", 8443},
{"tcp", "2001:db8::1", "[2001:db8::1]:443", 443},
} {
t.Run(tc.label, func(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
m := monitors[0]
m.Set("protocol", tc.protocol)
m.Set("target", tc.target)
m.Set("port", tc.port)
require.NoError(t, hub.Save(m))
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.HandleNetworkMonitorAlerts(system, map[string]monitor.Result{m.Id: monitorResult(10)}))
histories, err := hub.FindAllRecords("alerts_history", dbx.HashExp{"alert_id": alert.Id})
require.NoError(t, err)
require.Len(t, histories, 1)
assert.Equal(t, tc.label, histories[0].GetString("monitor_name"))
assert.Contains(t, hub.TestMailer.Messages()[hub.TestMailer.TotalSend()-1].Text, tc.label)
})
}
}
func TestNetworkMonitorAlertIgnoresUnknownResults(t *testing.T) {
for _, scenario := range []string{"missing", "stale", "warmup", "no probes", "down", "paused", "future", "expired hourly window"} {
t.Run(scenario, func(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
apply := func(loss float64) {
result := monitorResult(loss)
results := map[string]monitor.Result{monitors[0].Id: result}
switch scenario {
case "missing":
results = nil
case "stale":
result.LastProbeAt = time.Now().Add(-10 * time.Minute).UnixMilli()
results[monitors[0].Id] = result
case "expired hourly window":
monitors[0].Set("interval", 3600)
require.NoError(t, hub.Save(monitors[0]))
result.LastProbeAt = time.Now().Add(-2 * time.Hour).UnixMilli()
results[monitors[0].Id] = result
case "future":
result.LastProbeAt = time.Now().Add(time.Hour).UnixMilli()
results[monitors[0].Id] = result
case "warmup":
result.SampleCount = 2
results[monitors[0].Id] = result
case "no probes":
result.SampleCount = 0
results[monitors[0].Id] = result
case "down":
_, err := hub.DB().Update("systems", dbx.Params{"status": scenario}, dbx.HashExp{"id": system.Id}).Execute()
require.NoError(t, err)
case "paused":
record, err := hub.FindRecordById("systems", system.Id)
require.NoError(t, err)
record.Set("status", "paused")
require.NoError(t, hub.Save(record))
}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
}
count := hub.TestMailer.TotalSend()
apply(100)
assert.Equal(t, count, hub.TestMailer.TotalSend())
_, err := hub.DB().Update("systems", dbx.Params{"status": "up"}, dbx.HashExp{"id": system.Id}).Execute()
require.NoError(t, err)
require.NoError(t, am.HandleNetworkMonitorAlerts(system, map[string]monitor.Result{monitors[0].Id: monitorResult(10)}))
apply(0)
assert.Equal(t, count+1, hub.TestMailer.TotalSend(), "unknown data must not recover an incident")
record, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.True(t, record.GetBool("triggered"))
})
}
}
func TestNetworkMonitorAlertCleanup(t *testing.T) {
for _, scenario := range []string{"disable monitor", "delete monitor", "disable alert", "purge history", "delete system"} {
t.Run(scenario, func(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
results := map[string]monitor.Result{monitors[0].Id: monitorResult(10), monitors[1].Id: monitorResult(20)}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
count := hub.TestMailer.TotalSend()
switch scenario {
case "disable monitor":
monitors[0].Set("enabled", false)
require.NoError(t, hub.Save(monitors[0]))
case "delete monitor":
require.NoError(t, hub.Delete(monitors[0]))
case "disable alert":
require.NoError(t, hub.Delete(alert))
case "delete system":
require.NoError(t, hub.Delete(system))
case "purge history":
history, err := hub.FindAllRecords("alerts_history")
require.NoError(t, err)
for _, record := range history {
require.NoError(t, hub.Delete(record))
}
}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count, hub.TestMailer.TotalSend())
open, err := hub.CountRecords("alerts_history", dbx.HashExp{"alert_id": alert.Id, "resolved": ""})
require.NoError(t, err)
if scenario == "disable monitor" || scenario == "delete monitor" {
assert.EqualValues(t, 1, open)
record, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.True(t, record.GetBool("triggered"))
require.NoError(t, hub.Delete(monitors[1]))
record, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, record.GetBool("triggered"))
} else {
assert.Zero(t, open)
}
})
}
}
func TestNetworkMonitorAlertPerUserThresholds(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
user, err := beszelTests.CreateUser(hub, "monitor2@example.com", "password")
require.NoError(t, err)
other, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{"name": "NetworkMonitorLoss", "system": system.Id, "user": user.Id, "value": 20})
require.NoError(t, err)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
results := map[string]monitor.Result{monitors[0].Id: monitorResult(10)}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
other, err = hub.FindRecordById("alerts", other.Id)
require.NoError(t, err)
assert.False(t, other.GetBool("triggered"))
// Editing the threshold re-evaluates on the next batch, without losing state.
alert, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
alert.Set("value", 15)
require.NoError(t, hub.Save(alert))
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
alert, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, alert.GetBool("triggered"))
}
func TestNetworkMonitorAlertAPI(t *testing.T) {
for _, tc := range []struct {
name string
value float64
direct, denied, patch bool
status int
}{
{name: "zero threshold", value: 0, status: 200},
{name: "fractional threshold", value: 5.5, status: 200},
{name: "negative threshold", value: -1, status: 400},
{name: "unreachable threshold", value: 100, status: 400},
{name: "bulk inaccessible system", value: 5, denied: true, status: 200},
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 403},
{name: "direct invalid threshold", value: -1, direct: true, status: 400},
{name: "direct private state", value: 5, direct: true, status: 200},
{name: "patch preserves state", value: 10, direct: true, patch: true, status: 200},
} {
t.Run(tc.name, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
owner := user.Id
if tc.denied {
other, err := beszelTests.CreateUser(hub, "other@example.com", "password")
require.NoError(t, err)
owner = other.Id
}
systems, err := beszelTests.CreateSystems(hub, 1, owner, "paused")
require.NoError(t, err)
token, err := user.NewAuthToken()
require.NoError(t, err)
body := map[string]any{"name": "NetworkMonitorLoss", "value": tc.value, "min": 60, "systems": []string{systems[0].Id}, "overwrite": true}
url, method := "/api/beszel/user-alerts", "POST"
if tc.direct {
url = "/api/collections/alerts/records"
body["system"], body["user"] = systems[0].Id, user.Id
body["state"], body["triggered"] = map[string]any{"monitors": map[string]string{"fake": "fake"}}, true
}
if tc.patch {
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{"name": "NetworkMonitorLoss", "system": systems[0].Id, "user": user.Id, "value": 5, "triggered": true, "state": map[string]any{"monitors": map[string]string{"real": "history"}}})
require.NoError(t, err)
url += "/" + alert.Id
method = "PATCH"
body["triggered"] = false
}
content := `"success":true`
if tc.direct {
content = `"name":"NetworkMonitorLoss"`
}
if tc.status == 400 {
content = `"status":400`
}
if tc.status == 403 {
content = `"status":403`
}
scenario := beszelTests.ApiScenario{
Name: tc.name, Method: method, URL: url, Body: jsonReader(body),
Headers: map[string]string{"Authorization": token}, ExpectedStatus: tc.status, ExpectedContent: []string{content},
TestAppFactory: func(testing.TB) *pbTests.TestApp { return hub.TestApp },
}
scenario.Test(t)
records, err := hub.FindAllRecords("alerts")
require.NoError(t, err)
if tc.status != 200 || tc.denied {
assert.Empty(t, records)
return
}
require.Len(t, records, 1)
assert.Equal(t, tc.value, records[0].GetFloat("value"))
assert.Zero(t, records[0].GetInt("min"))
state := struct {
Monitors map[string]string `json:"monitors"`
}{}
require.NoError(t, records[0].UnmarshalJSONField("state", &state))
states := state.Monitors
if tc.patch {
assert.Equal(t, map[string]string{"real": "history"}, states)
assert.True(t, records[0].GetBool("triggered"))
} else {
assert.Empty(t, states)
assert.False(t, records[0].GetBool("triggered"))
}
})
}
}
type monitorCountingHub struct {
*beszelTests.TestHub
transactions atomic.Int64
beforeTransaction func()
}
func (h *monitorCountingHub) RunInTransaction(fn func(core.App) error) error {
h.transactions.Add(1)
if h.beforeTransaction != nil {
h.beforeTransaction()
}
return h.App.RunInTransaction(fn)
}
// Count actual SQL on both DB connections, including queries through record APIs.
func monitorSQLCounter(t *testing.T, app core.App) *atomic.Int64 {
t.Helper()
count := &atomic.Int64{}
for _, builder := range []dbx.Builder{app.ConcurrentDB(), app.NonconcurrentDB()} {
db := builder.(*dbx.DB)
old := db.LogFunc
db.LogFunc = func(string, ...any) { count.Add(1) }
t.Cleanup(func() { db.LogFunc = old })
}
return count
}
func TestNetworkMonitorAlertSteadyStateNoDatabaseWork(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
counted := &monitorCountingHub{TestHub: hub}
am := alerts.NewTestAlertManagerWithoutWorker(counted)
sql := monitorSQLCounter(t, hub)
results := map[string]monitor.Result{monitors[0].Id: monitorResult(0)}
evaluate := func() { t.Helper(); require.NoError(t, am.HandleNetworkMonitorAlerts(system, results)) }
noWork := func() {
t.Helper()
sql.Store(0)
counted.transactions.Store(0)
for range 100 {
evaluate()
}
assert.Zero(t, sql.Load(), "steady state must not issue SQL")
assert.Zero(t, counted.transactions.Load(), "steady state must not open transactions")
}
// One-time lazy loads are permitted, including on hub restart.
evaluate()
assert.Positive(t, sql.Load())
noWork()
// Realtime metric saves invoke record hooks but must not invalidate config.
fresh, err := hub.FindRecordById("network_monitors", monitors[0].Id)
require.NoError(t, err)
monitors[0] = fresh
monitors[0].Set("loss1h", 0)
monitors[0].Set("res", 100)
require.NoError(t, hub.Save(monitors[0]))
noWork()
results[monitors[0].Id] = monitorResult(10)
evaluate()
assert.Positive(t, sql.Load(), "transitions must still be persisted")
assert.EqualValues(t, 1, counted.transactions.Load())
noWork()
// Missing and stale observations must not enter the transaction either.
results = nil
noWork()
results = map[string]monitor.Result{monitors[0].Id: {SampleCount: 60, LastProbeAt: time.Now().Add(-10 * time.Minute).UnixMilli()}}
noWork()
results[monitors[0].Id] = monitorResult(0)
evaluate()
noWork()
require.NoError(t, hub.Delete(alert))
noWork()
}
func TestNetworkMonitorAlertConfigCacheInvalidation(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
results := map[string]monitor.Result{monitors[0].Id: monitorResult(0)}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
count := hub.TestMailer.TotalSend()
// Widening the interval makes this observation fresh. A stale interval cache
// would miss the failure indefinitely, even though results keep arriving.
result := monitorResult(10)
result.LastProbeAt = time.Now().Add(-4 * time.Minute).UnixMilli()
results[monitors[0].Id] = result
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count, hub.TestMailer.TotalSend())
monitors[0].Set("interval", 120)
require.NoError(t, hub.Save(monitors[0]))
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count+1, hub.TestMailer.TotalSend())
// Disable, then re-enable the same ID: its new failure must be detected.
monitors[0].Set("enabled", false)
require.NoError(t, hub.Save(monitors[0]))
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
monitors[0].Set("enabled", true)
require.NoError(t, hub.Save(monitors[0]))
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count+2, hub.TestMailer.TotalSend())
// A new monitor must also become eligible without restarting the hub.
created, err := beszelTests.CreateRecord(hub, "network_monitors", map[string]any{
"system": system.Id, "name": "new", "target": "new.example.com", "protocol": "icmp", "interval": 60, "enabled": true,
})
require.NoError(t, err)
results[created.Id] = monitorResult(10)
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count+3, hub.TestMailer.TotalSend())
// Threshold changes refresh cached config and preserve the active incidents.
alert, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
alert.Set("value", 15)
require.NoError(t, hub.Save(alert))
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count+5, hub.TestMailer.TotalSend())
}
func TestNetworkMonitorAlertRevalidatesCandidate(t *testing.T) {
for _, change := range []string{"threshold", "disable alert", "disable monitor", "down"} {
t.Run(change, func(t *testing.T) {
hub, system, alert, monitors := networkAlertSetup(t)
counted := &monitorCountingHub{TestHub: hub}
am := alerts.NewTestAlertManagerWithoutWorker(counted)
results := map[string]monitor.Result{monitors[0].Id: monitorResult(0)}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
count := hub.TestMailer.TotalSend()
// Change the DB after the cache predicts a transition, before its transaction.
counted.beforeTransaction = func() {
counted.beforeTransaction = nil
switch change {
case "threshold":
alert.Set("value", 20)
require.NoError(t, hub.Save(alert))
case "disable alert":
require.NoError(t, hub.Delete(alert))
case "disable monitor":
monitors[0].Set("enabled", false)
require.NoError(t, hub.Save(monitors[0]))
case "down":
_, err := hub.DB().Update("systems", dbx.Params{"status": "down"}, dbx.HashExp{"id": system.Id}).Execute()
require.NoError(t, err)
}
}
results[monitors[0].Id] = monitorResult(10)
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.EqualValues(t, 1, counted.transactions.Load())
assert.Equal(t, count, hub.TestMailer.TotalSend())
histories, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
assert.Zero(t, histories)
})
}
}
func TestNetworkMonitorAlertConcurrentEvaluations(t *testing.T) {
hub, system, _, monitors := networkAlertSetup(t)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
results := map[string]monitor.Result{monitors[0].Id: monitorResult(10)}
count := hub.TestMailer.TotalSend()
var wg sync.WaitGroup
errs := make(chan error, 8)
for range 8 {
wg.Go(func() { errs <- am.HandleNetworkMonitorAlerts(system, results) })
}
wg.Wait()
close(errs)
for err := range errs {
require.NoError(t, err)
}
assert.Equal(t, count+1, hub.TestMailer.TotalSend())
}
func TestNetworkMonitorAlertCacheAfterRollback(t *testing.T) {
hub, system, _, monitors := networkAlertSetup(t)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
results := map[string]monitor.Result{monitors[0].Id: monitorResult(0)}
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
_, err := hub.DB().NewQuery(`CREATE TRIGGER fail_alert BEFORE UPDATE ON alerts BEGIN SELECT RAISE(ABORT, 'test rollback'); END`).Execute()
require.NoError(t, err)
count := hub.TestMailer.TotalSend()
results[monitors[0].Id] = monitorResult(10)
require.Error(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count, hub.TestMailer.TotalSend())
histories, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
assert.Zero(t, histories)
_, err = hub.DB().NewQuery("DROP TRIGGER fail_alert").Execute()
require.NoError(t, err)
// A failed transition must not be published to the cache and mask the retry.
require.NoError(t, am.HandleNetworkMonitorAlerts(system, results))
assert.Equal(t, count+1, hub.TestMailer.TotalSend())
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
return nil
}
alerts := am.alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status", alertNameSystemdFailed, containerAlertName)
alerts := am.alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status", alertNameSystemdFailed, containerAlertName, alertNameNetworkMonitorLoss)
if len(alerts) == 0 {
return nil
}
+3 -2
View File
@@ -11,8 +11,9 @@ import (
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
return &AlertManager{
hub: app,
alertsCache: NewAlertsCache(app),
hub: app,
alertsCache: NewAlertsCache(app),
networkMonitors: newNetworkMonitorCache(app),
}
}
+79
View File
@@ -0,0 +1,79 @@
package alerts
import (
"sync"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// networkMonitorCache keeps just the enabled monitor IDs and probe intervals
// needed for the alert fast path. Names and targets are read only on transitions.
// Returned maps are immutable; configuration changes invalidate the whole entry.
type networkMonitorCache struct {
app core.App
mu sync.RWMutex
systems map[string]map[string]int
}
func newNetworkMonitorCache(app core.App) *networkMonitorCache {
c := &networkMonitorCache{app: app, systems: make(map[string]map[string]int)}
invalidate := func(e *core.RecordEvent) error {
c.invalidate(e.Record.GetString("system"))
return e.Next()
}
app.OnRecordAfterCreateSuccess("network_monitors").BindFunc(invalidate)
app.OnRecordAfterDeleteSuccess("network_monitors").BindFunc(invalidate)
app.OnRecordAfterUpdateSuccess("network_monitors").BindFunc(func(e *core.RecordEvent) error {
old := e.Record.Original()
// Realtime metric saves also invoke this hook. They must not evict config.
if old.GetString("system") != e.Record.GetString("system") ||
old.GetBool("enabled") != e.Record.GetBool("enabled") ||
old.GetInt("interval") != e.Record.GetInt("interval") {
c.invalidate(old.GetString("system"))
c.invalidate(e.Record.GetString("system"))
}
return e.Next()
})
app.OnRecordAfterDeleteSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
c.invalidate(e.Record.Id)
return e.Next()
})
return c
}
func (c *networkMonitorCache) invalidate(systemID string) {
c.mu.Lock()
delete(c.systems, systemID)
c.mu.Unlock()
}
func (c *networkMonitorCache) get(systemID string) (map[string]int, error) {
c.mu.RLock()
monitors, ok := c.systems[systemID]
c.mu.RUnlock()
if ok {
return monitors, nil
}
c.mu.Lock()
defer c.mu.Unlock()
if monitors, ok := c.systems[systemID]; ok {
return monitors, nil
}
// Keep the lock through the load so a concurrent config change cannot be
// invalidated first and then overwritten by the older query result.
var rows []struct {
ID string `db:"id"`
Interval int `db:"interval"`
}
if err := c.app.DB().Select("id", "interval").From("network_monitors").
Where(dbx.HashExp{"system": systemID, "enabled": true}).All(&rows); err != nil {
return nil, err
}
monitors = make(map[string]int, len(rows))
for _, row := range rows {
monitors[row.ID] = row.Interval
}
c.systems[systemID] = monitors
return monitors, nil
}
+2
View File
@@ -24,6 +24,8 @@ const (
GetSystemdInfo
// Request ZFS detail data from agent
GetZfsData
// Sync network monitor configuration to agent
SyncNetworkMonitors
// Add new actions here...
)
+102
View File
@@ -0,0 +1,102 @@
package monitor
import "time"
// MaxProbeTimeout is the longest agent probe timeout (currently HTTP).
// Hub requests that run a probe must allow this time in addition to transport overhead.
const MaxProbeTimeout = 10 * time.Second
type SyncAction uint8
const (
// SyncActionReplace indicates a full sync where the provided configs should replace all existing monitors 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 monitor task sent from hub to agent.
type Config struct {
// ID is the stable network_monitors record ID generated by the hub.
ID string `cbor:"0,keyasint"`
Target string `cbor:"1,keyasint"`
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", "http", or "dns"
Port uint16 `cbor:"3,keyasint,omitempty"`
Interval uint16 `cbor:"4,keyasint"` // seconds
}
// SyncRequest defines an incremental or full monitor 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 monitor 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"`
// LastProbeAt is the latest completed probe's Unix timestamp in milliseconds.
LastProbeAt int64 `cbor:"8,keyasint"`
// SampleCount includes all completed probes since this monitor started.
// Used for alert warm-up even when the interval is longer than 20 minutes.
SampleCount int64 `cbor:"9,keyasint,omitempty"`
// Counts and sum cover the current response window (or latest-sample
// fallback), not the hourly window or lifetime SampleCount.
TotalCount int64 `cbor:"10,keyasint"`
SuccessCount int64 `cbor:"11,keyasint"`
ResponseSum int64 `cbor:"12,keyasint"`
}
// Stats holds response times in microseconds and packet loss percentage (0-100).
type Stats struct {
ResAvg float64 `json:"res_avg" db:"-"` // Derived for display; not stored.
ResMin float64 `json:"res_min" db:"res_min"`
ResMax float64 `json:"res_max" db:"res_max"`
Loss float64 `json:"loss" db:"-"` // Derived for display; not stored.
TotalCount int64 `json:"-" db:"total_count"`
SuccessCount int64 `json:"-" db:"success_count"`
ResponseSum int64 `json:"-" db:"res_sum"`
}
func (s Stats) FromResult(result Result) Stats {
return Stats{
ResAvg: float64(result.AvgResponse),
ResMin: float64(result.MinResponse),
ResMax: float64(result.MaxResponse),
Loss: result.PacketLoss,
TotalCount: result.TotalCount,
SuccessCount: result.SuccessCount,
ResponseSum: result.ResponseSum,
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/systemd"
)
@@ -210,5 +211,6 @@ type CombinedData struct {
Details *Details `cbor:"4,keyasint,omitempty"`
// SystemdServicesUpdated distinguishes a fresh empty snapshot from a response
// that omitted systemd data (for example, a short-cache dashboard request).
SystemdServicesUpdated bool `json:"systemdUpdated,omitempty" cbor:"5,keyasint,omitempty"`
SystemdServicesUpdated bool `json:"systemdUpdated,omitempty" cbor:"5,keyasint,omitempty"`
Monitors map[string]monitor.Result `cbor:"6,keyasint"`
}
+11 -1
View File
@@ -78,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_monitor_stats"}, collectionRules{
list: &systemScopedReadRule,
}); err != nil {
return err
@@ -108,6 +108,16 @@ func setCollectionAuthSettings(app core.App) error {
return err
}
if err := applyCollectionRules(app, []string{"network_monitors"}, collectionRules{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
create: &systemScopedWriteRule,
update: &systemScopedWriteRule,
delete: &systemScopedWriteRule,
}); err != nil {
return err
}
if err := applyCollectionRules(app, []string{"system_details"}, collectionRules{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
+2
View File
@@ -109,6 +109,8 @@ func (h *Hub) StartHub() error {
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
bindNetworkMonitorsEvents(h)
pb, ok := h.App.(*pocketbase.PocketBase)
if !ok {
return errors.New("not a pocketbase app")
+158
View File
@@ -0,0 +1,158 @@
package hub
import (
"strconv"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/hub/systems"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
// generateMonitorID creates a stable hash ID for a monitor based on its configuration and the system it belongs to.
func generateMonitorID(systemId string, config monitor.Config) string {
args := []string{systemId, config.Target, config.Protocol}
// only use port for TCP monitors, 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...)
}
// bindNetworkMonitorsEvents keeps monitor records and agent monitor state in sync.
func bindNetworkMonitorsEvents(hub *Hub) {
// on create, make sure the id is set to a stable hash
hub.OnRecordCreate("network_monitors").BindFunc(func(e *core.RecordEvent) error {
systemID := e.Record.GetString("system")
config := monitorConfigFromRecord(e.Record)
id := generateMonitorID(systemID, *config)
e.Record.Set("id", id)
return e.Next()
})
// sync monitor to agent on creation and persist the first result immediately when available
hub.OnRecordAfterCreateSuccess("network_monitors").BindFunc(func(e *core.RecordEvent) error {
err := e.Next()
if err != nil {
return err
}
if !e.Record.GetBool("enabled") {
return nil
}
// If connected, run the monitor immediately. Paused systems may be absent
// from the manager; their monitors will sync when they reconnect.
system, err := hub.sm.GetSystem(e.Record.GetString("system"))
if err == nil && system.Status == "up" {
go hub.upsertNetworkMonitor(e.Record, true)
}
return nil
})
// On API update requests, if the monitor 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 monitor on the agent.
hub.OnRecordUpdateRequest("network_monitors").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 := generateMonitorID(systemID, *monitorConfigFromRecord(e.Record))
if ID != e.Record.Id {
newRecord := copyMonitorToNewRecord(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 err != nil {
return err
}
if e.Record.GetBool("enabled") {
// if the monitor is enabled, sync the updated config to the agent now
runNow := !e.Record.Original().GetBool("enabled")
err = hub.upsertNetworkMonitor(e.Record, runNow)
} else {
// if the monitor is paused, remove it from the agent
err = hub.deleteNetworkMonitor(e.Record)
}
if err != nil {
hub.Logger().Warn("failed to sync updated monitor", "system", systemID, "monitor", e.Record.Id, "err", err)
}
return nil
})
// sync monitor to agent on delete
hub.OnRecordAfterDeleteSuccess("network_monitors").BindFunc(func(e *core.RecordEvent) error {
if err := hub.deleteNetworkMonitor(e.Record); err != nil {
hub.Logger().Warn("failed to delete monitor on agent", "system", e.Record.GetString("system"), "monitor", e.Record.Id, "err", err)
}
return e.Next()
})
}
// monitorConfigFromRecord builds a monitor config from a network_monitors record.
func monitorConfigFromRecord(record *core.Record) *monitor.Config {
return &monitor.Config{
ID: record.Id,
Target: record.GetString("target"),
Protocol: record.GetString("protocol"),
Port: uint16(record.GetInt("port")),
Interval: uint16(record.GetInt("interval")),
}
}
// setMonitorResultFields stores the latest monitor result values on the record.
func setMonitorResultFields(record *core.Record, result monitor.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)
}
// copyMonitorToNewRecord creates a new record with the same field values as the old one.
// This is used when the monitor 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 copyMonitorToNewRecord(oldRecord *core.Record, newID string) *core.Record {
collection := oldRecord.Collection()
newRecord := core.NewRecord(collection)
newRecord.Id = newID
fields := []string{"system", "target", "protocol", "port", "interval", "enabled"}
for _, field := range fields {
newRecord.Set(field, oldRecord.Get(field))
}
return newRecord
}
// upsertNetworkMonitor creates or updates the record's monitor on the target system. If runNow
// is true, it will also trigger an immediate monitor run and update the record with the result.
func (h *Hub) upsertNetworkMonitor(record *core.Record, runNow bool) error {
systemID := record.GetString("system")
system, err := h.sm.GetSystem(systemID)
if err != nil {
return err
}
result, err := system.UpsertNetworkMonitor(*monitorConfigFromRecord(record), runNow)
if err != nil || result == nil {
return err
}
setMonitorResultFields(record, *result)
return h.App.SaveNoValidate(record)
}
// deleteNetworkMonitor removes the record's monitor from the target system.
func (h *Hub) deleteNetworkMonitor(record *core.Record) error {
systemID := record.GetString("system")
system, err := h.sm.GetSystem(systemID)
if err != nil {
return err
}
return system.DeleteNetworkMonitor(record.Id)
}
+225
View File
@@ -0,0 +1,225 @@
package hub
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateNetworkMonitorsOnPausedSystem(t *testing.T) {
for _, batch := range []bool{false, true} {
name := "single"
if batch {
name = "batch"
}
t.Run(name, func(t *testing.T) {
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer cleanupTestHub(hub, testApp)
bindNetworkMonitorsEvents(hub)
user, err := createTestUser(hub)
require.NoError(t, err)
system, err := createTestRecord(hub, "systems", map[string]any{
"name": "Paused", "host": "localhost", "port": "45876",
"status": "paused", "users": []string{user.Id},
})
require.NoError(t, err)
// Paused systems are not loaded into the manager at startup.
_, err = hub.sm.GetSystem(system.Id)
require.Error(t, err)
payload := func(target string) map[string]any {
return map[string]any{
"system": system.Id, "target": target, "protocol": "icmp",
"interval": 60, "enabled": true,
}
}
url := "/api/collections/network_monitors/records"
var body any = payload("1.1.1.1")
count := 1
if batch {
body = map[string]any{"requests": []map[string]any{
{"method": "POST", "url": url, "body": payload("1.1.1.1")},
{"method": "POST", "url": url, "body": payload("8.8.8.8")},
}}
url = "/api/batch"
count = 2
}
data, err := json.Marshal(body)
require.NoError(t, err)
token, err := user.NewAuthToken()
require.NoError(t, err)
router, err := apis.NewRouter(hub)
require.NoError(t, err)
handler, err := router.BuildMux()
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(data))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", token)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code, response.Body.String())
records, err := hub.FindAllRecords("network_monitors")
require.NoError(t, err)
require.Len(t, records, count)
for _, record := range records {
assert.Equal(t, system.Id, record.GetString("system"))
assert.True(t, record.GetBool("enabled"))
}
})
}
}
func TestGenerateMonitorID(t *testing.T) {
tests := []struct {
name string
systemID string
config monitor.Config
expected string
}{
{
name: "HTTP monitor on example.com",
systemID: "sys123",
config: monitor.Config{
Protocol: "http",
Target: "example.com",
Port: 0,
Interval: 60,
},
expected: "a20a5827",
},
{
name: "HTTP monitor on example.com with different port",
systemID: "sys123",
config: monitor.Config{
Protocol: "http",
Target: "example.com",
Port: 8080,
Interval: 60,
},
expected: "a20a5827",
},
{
name: "HTTP monitor on example.com with different system ID",
systemID: "sys1234",
config: monitor.Config{
Protocol: "http",
Target: "example.com",
Port: 80,
Interval: 60,
},
expected: "ab602ae7",
},
{
name: "Same monitor, different interval",
systemID: "sys1234",
config: monitor.Config{
Protocol: "http",
Target: "example.com",
Port: 80,
Interval: 120,
},
expected: "ab602ae7",
},
{
name: "ICMP monitor on 1.1.1.1",
systemID: "sys456",
config: monitor.Config{
Protocol: "icmp",
Target: "1.1.1.1",
Port: 0,
Interval: 10,
},
expected: "6d13a4a4",
}, {
name: "ICMP monitor on 1.1.1.1 with different system ID",
systemID: "sys4567",
config: monitor.Config{
Protocol: "icmp",
Target: "1.1.1.1",
Port: 0,
Interval: 10,
},
expected: "ddd6c81",
},
{
name: "TCP monitor on example.com with port 443",
systemID: "sys789",
config: monitor.Config{
Protocol: "tcp",
Target: "example.com",
Port: 443,
Interval: 30,
},
expected: "677b991",
},
{
name: "TCP monitor on example.com with port 8443",
systemID: "sys789",
config: monitor.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 := generateMonitorID(tt.systemID, tt.config)
assert.Equal(t, tt.expected, got, "generateMonitorID() = %v, want %v", got, tt.expected)
})
}
}
func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer cleanupTestHub(hub, testApp)
collection, err := hub.FindCachedCollectionByNameOrId("network_monitors")
require.NoError(t, err)
assert.Nil(t, collection.Fields.GetByName("name"))
oldRecord := core.NewRecord(collection)
oldRecord.Load(map[string]any{
"system": "sys123",
"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 := copyMonitorToNewRecord(oldRecord, "next12345")
assert.Equal(t, "next12345", newRecord.Id)
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"))
}
@@ -0,0 +1,224 @@
//go:build testing
package systems
import (
"fmt"
"testing"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNetworkMonitorProbePruning(t *testing.T) {
for _, tc := range []struct {
name string
monitors map[string]monitor.Result
fail bool
want map[string]int64
}{
{"nil report", nil, false, map[string]int64{"monitor1": 1000, "monitor2": 1000}},
{"empty report", map[string]monitor.Result{}, false, map[string]int64{}},
{"removed monitor", map[string]monitor.Result{"monitor1": {LastProbeAt: 1000}}, false, map[string]int64{"monitor1": 1000}},
{"rolled back report", map[string]monitor.Result{}, true, map[string]int64{"monitor1": 1000, "monitor2": 1000}},
} {
t.Run(tc.name, func(t *testing.T) {
sys, app := newTestSystemWithHub(t)
sys.lastSavedMonitorProbe = map[string]int64{"monitor1": 1000, "monitor2": 1000}
// Preserve the distinction between nil and empty across the agent transport.
encoded, err := cbor.Marshal(system.CombinedData{Monitors: tc.monitors})
require.NoError(t, err)
var data system.CombinedData
require.NoError(t, cbor.Unmarshal(encoded, &data))
if tc.fail {
_, err = app.DB().NewQuery(`CREATE TRIGGER fail_system_update BEFORE UPDATE ON systems BEGIN SELECT RAISE(ABORT, 'test rollback'); END`).Execute()
require.NoError(t, err)
}
_, err = sys.createRecords(&data)
if tc.fail {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.want, sys.lastSavedMonitorProbe)
})
}
}
func TestNetworkMonitorStatsFreshness(t *testing.T) {
for _, realtime := range []bool{false, true} {
name := "sql"
if realtime {
name = "realtime"
}
t.Run(name, func(t *testing.T) {
sys, app := newTestSystemWithHub(t)
if realtime {
client := subscriptions.NewDefaultClient()
client.Subscribe("network_monitors/*")
app.SubscriptionsBroker().Register(client)
t.Cleanup(func() { app.SubscriptionsBroker().Unregister(client.Id()) })
}
col, err := app.FindCachedCollectionByNameOrId("network_monitors")
require.NoError(t, err)
for _, id := range []string{"monitor1", "monitor2"} {
record := core.NewRecord(col)
record.Id = id
record.Set("system", sys.Id)
require.NoError(t, app.SaveNoValidate(record))
}
data := &system.CombinedData{Monitors: map[string]monitor.Result{
"monitor1": {LastProbeAt: 1000, AvgResponse: 20, TotalCount: 6, SuccessCount: 6, ResponseSum: 123},
"monitor2": {LastProbeAt: 1000, PacketLoss: 100, TotalCount: 1},
}}
count := func(want int64) {
t.Helper()
got, err := app.CountRecords("network_monitor_stats")
require.NoError(t, err)
assert.Equal(t, want, got)
}
save := func() {
t.Helper()
_, err := sys.createRecords(data)
require.NoError(t, err)
}
save()
count(2)
stored, err := app.FindAllRecords("network_monitor_stats")
require.NoError(t, err)
for _, record := range stored {
result := data.Monitors[record.GetString("monitor")]
assert.EqualValues(t, result.TotalCount, record.GetInt("total_count"))
assert.EqualValues(t, result.SuccessCount, record.GetInt("success_count"))
assert.EqualValues(t, result.ResponseSum, record.GetInt("res_sum"))
}
// A resume can overlap the scheduled update with the same probe.
errs := make(chan error, 4)
for range 4 {
go func() {
_, err := sys.createRecords(data)
errs <- err
}()
}
for range 4 {
require.NoError(t, <-errs)
}
count(2)
// A rolling hourly value can change without a new probe.
result := data.Monitors["monitor1"]
result.AvgResponse1h = 42
data.Monitors["monitor1"] = result
save()
count(2)
record, err := app.FindRecordById("network_monitors", "monitor1")
require.NoError(t, err)
assert.Equal(t, 42, record.GetInt("resAvg1h"))
// Identical response values and failed probes still count as new measurements.
for id, result := range data.Monitors {
result.LastProbeAt = 301000
data.Monitors[id] = result
}
save()
count(4)
// A failed individual insert must remain retryable, even if others commit.
_, err = app.DB().NewQuery(`CREATE TRIGGER fail_monitor_insert BEFORE INSERT ON network_monitor_stats WHEN NEW.monitor = 'monitor1' BEGIN SELECT RAISE(ABORT, 'test insert failure'); END`).Execute()
require.NoError(t, err)
for id, result := range data.Monitors {
result.LastProbeAt = 601000
data.Monitors[id] = result
}
save()
count(5)
assert.Equal(t, int64(301000), sys.lastSavedMonitorProbe["monitor1"])
assert.Equal(t, int64(601000), sys.lastSavedMonitorProbe["monitor2"])
_, err = app.DB().NewQuery("DROP TRIGGER fail_monitor_insert").Execute()
require.NoError(t, err)
save()
count(6)
// Failure after inserting stats rolls back the whole transaction and its markers.
_, err = app.DB().NewQuery(`CREATE TRIGGER fail_system_update BEFORE UPDATE ON systems BEGIN SELECT RAISE(ABORT, 'test rollback'); END`).Execute()
require.NoError(t, err)
result = data.Monitors["monitor1"]
result.LastProbeAt = 901000
data.Monitors["monitor1"] = result
_, err = sys.createRecords(data)
require.Error(t, err)
count(6)
assert.Equal(t, int64(601000), sys.lastSavedMonitorProbe["monitor1"])
_, err = app.DB().NewQuery("DROP TRIGGER fail_system_update").Execute()
require.NoError(t, err)
save()
count(7)
// Clock rollback is a new probe identity, not a reason to stall writes.
result.LastProbeAt = 500
data.Monitors["monitor1"] = result
save()
count(8)
// Recreated systems intentionally accept the first result without restoring state.
sys = &System{Id: sys.Id, manager: sys.manager}
save()
count(10)
})
}
}
// Observes the committed DB through the hub, not the transaction's app.
type monitorAlertHub struct {
stubHub
handle func(*core.Record, map[string]monitor.Result) error
}
func (h monitorAlertHub) HandleNetworkMonitorAlerts(record *core.Record, results map[string]monitor.Result) error {
return h.handle(record, results)
}
func TestNetworkMonitorAlertsAfterCommit(t *testing.T) {
for _, realtime := range []bool{false, true} {
t.Run(fmt.Sprint(realtime), func(t *testing.T) {
sys, app := newTestSystemWithHub(t)
if realtime {
client := subscriptions.NewDefaultClient()
client.Subscribe("network_monitors/*")
app.SubscriptionsBroker().Register(client)
}
collection, err := app.FindCachedCollectionByNameOrId("network_monitors")
require.NoError(t, err)
record := core.NewRecord(collection)
record.Set("system", sys.Id)
require.NoError(t, app.SaveNoValidate(record))
called := 0
result := monitor.Result{LastProbeAt: time.Now().UnixMilli(), SampleCount: 3, PacketLoss1h: 10}
sys.manager.hub = monitorAlertHub{stubHub: stubHub{app}, handle: func(systemRecord *core.Record, results map[string]monitor.Result) error {
called++
assert.Equal(t, sys.Id, systemRecord.Id)
assert.Equal(t, result, results[record.Id])
saved, err := app.FindRecordById("network_monitors", record.Id)
require.NoError(t, err)
assert.Equal(t, 10.0, saved.GetFloat("loss1h"))
return nil
}}
data := &system.CombinedData{Monitors: map[string]monitor.Result{record.Id: result}}
_, err = sys.createRecords(data)
require.NoError(t, err)
assert.Equal(t, 1, called)
// A transaction that fails after writing monitor stats must not notify.
_, err = app.DB().NewQuery(`CREATE TRIGGER fail_system BEFORE UPDATE ON systems BEGIN SELECT RAISE(ABORT, 'test rollback'); END`).Execute()
require.NoError(t, err)
_, err = sys.createRecords(data)
require.Error(t, err)
assert.Equal(t, 1, called)
})
}
}
@@ -0,0 +1,144 @@
//go:build testing
package systems
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/lxzan/gws"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/require"
)
type monitorSyncClient struct {
gws.BuiltinEventHandler
requests chan common.HubRequest[monitor.SyncRequest]
}
func (c *monitorSyncClient) OnMessage(conn *gws.Conn, message *gws.Message) {
defer message.Close()
var req common.HubRequest[monitor.SyncRequest]
if err := cbor.Unmarshal(message.Bytes(), &req); err != nil {
return
}
c.requests <- req
data, _ := cbor.Marshal(monitor.SyncResponse{})
response, _ := cbor.Marshal(common.AgentResponse{Id: req.Id, Data: data})
_ = conn.WriteMessage(gws.OpcodeBinary, response)
}
// Avoid the production delayed disconnect notification; these tests explicitly
// remove each connection from the manager before reconnecting.
type monitorSyncServer struct{ ws.Handler }
func (*monitorSyncServer) OnClose(*gws.Conn, error) {}
func TestNetworkMonitorSyncSkipsOlderAgents(t *testing.T) {
for _, version := range []string{"0.0.0", "0.18.0", "0.19.0"} {
t.Run(version, func(t *testing.T) {
// No transport: attempting to send any request would fail.
sys := &System{agentVersion: semver.MustParse(version)}
require.NoError(t, sys.SyncNetworkMonitors(nil))
result, err := sys.UpsertNetworkMonitor(monitor.Config{ID: "test"}, true)
require.NoError(t, err)
require.Nil(t, result)
require.NoError(t, sys.DeleteNetworkMonitor("test"))
})
}
}
func TestNetworkMonitorReconnectSync(t *testing.T) {
for _, change := range []string{"delete", "disable"} {
t.Run(change, func(t *testing.T) {
sys, app := newTestSystemWithHub(t)
record, err := app.FindRecordById("systems", sys.Id)
require.NoError(t, err)
// Suppress unrelated system-stat requests while exercising reconnects.
record.Set("status", paused)
require.NoError(t, app.SaveNoValidate(record))
collection, err := app.FindCachedCollectionByNameOrId("network_monitors")
require.NoError(t, err)
probe := core.NewRecord(collection)
probe.Load(map[string]any{
"system": sys.Id, "target": "localhost", "protocol": "tcp",
"port": 80, "interval": 60, "enabled": true,
})
require.NoError(t, app.SaveNoValidate(probe))
sm := NewSystemManager(stubHub{app})
t.Cleanup(func() {
sm.cancel()
_ = sm.RemoveSystem(sys.Id)
sm.smartFetchMap.StopCleaner()
sm.zfsFetchMap.StopCleaner()
})
version := semver.MustParse("0.20.0")
connections := make(chan *ws.WsConn, 1)
upgrader := gws.NewUpgrader(&monitorSyncServer{}, nil)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r)
if err != nil {
t.Error(err)
return
}
wsConn := ws.NewWsConnection(conn, version)
conn.Session().Store("wsConn", wsConn)
connections <- wsConn
conn.ReadLoop()
}))
t.Cleanup(server.Close)
client := &monitorSyncClient{requests: make(chan common.HubRequest[monitor.SyncRequest], 2)}
connect := func() monitor.SyncRequest {
t.Helper()
conn, _, err := gws.NewClient(client, &gws.ClientOption{Addr: "ws" + strings.TrimPrefix(server.URL, "http")})
require.NoError(t, err)
t.Cleanup(func() { _ = conn.NetConn().Close() })
go conn.ReadLoop()
select {
case wsConn := <-connections:
require.NoError(t, sm.AddWebSocketSystem(sys.Id, version, wsConn))
case <-time.After(3 * time.Second):
t.Fatal("websocket connection was not established")
}
select {
case req := <-client.requests:
require.Equal(t, common.SyncNetworkMonitors, req.Action)
require.Equal(t, monitor.SyncActionReplace, req.Data.Action)
return req.Data
case <-time.After(3 * time.Second):
t.Fatal("reconnected agent did not receive a monitor replacement")
return monitor.SyncRequest{}
}
}
initial := connect()
require.Len(t, initial.Configs, 1)
require.Equal(t, probe.Id, initial.Configs[0].ID)
require.NoError(t, sm.RemoveSystem(sys.Id))
if change == "delete" {
require.NoError(t, app.Delete(probe))
} else {
probe.Set("enabled", false)
require.NoError(t, app.SaveNoValidate(probe))
}
require.Empty(t, connect().Configs, "reconnect must clear the agent's previous probe")
})
}
}
func TestGetMonitorConfigsForSystemQueryError(t *testing.T) {
sys, app := newTestSystemWithHub(t)
_, err := app.DB().NewQuery("DROP TABLE network_monitors").Execute()
require.NoError(t, err)
_, err = sys.manager.GetMonitorConfigsForSystem(sys.Id)
require.Error(t, err, "a failed query must not be treated as an empty monitor set")
}
+58
View File
@@ -0,0 +1,58 @@
package systems
import (
"context"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/monitor"
)
// SyncNetworkMonitors sends monitor configurations to the agent.
func (sys *System) SyncNetworkMonitors(configs []monitor.Config) error {
_, err := sys.syncNetworkMonitors(monitor.SyncRequest{Action: monitor.SyncActionReplace, Configs: configs})
return err
}
// UpsertNetworkMonitor sends a single monitor configuration change to the agent.
func (sys *System) UpsertNetworkMonitor(config monitor.Config, runNow bool) (*monitor.Result, error) {
resp, err := sys.syncNetworkMonitors(monitor.SyncRequest{
Action: monitor.SyncActionUpsert,
Config: config,
RunNow: runNow,
})
if err != nil {
return nil, err
}
if resp.Result == (monitor.Result{}) {
return nil, nil
}
result := resp.Result
return &result, nil
}
// DeleteNetworkMonitor removes a single monitor task from the agent.
func (sys *System) DeleteNetworkMonitor(id string) error {
_, err := sys.syncNetworkMonitors(monitor.SyncRequest{
Action: monitor.SyncActionDelete,
Config: monitor.Config{ID: id},
})
return err
}
func (sys *System) syncNetworkMonitors(req monitor.SyncRequest) (monitor.SyncResponse, error) {
if sys.agentVersion.LT(beszel.MinVersionNetworkMonitors) {
return monitor.SyncResponse{}, nil
}
timeout := 5 * time.Second
if req.Action == monitor.SyncActionUpsert && req.RunNow {
// Allow the probe to finish, including a timeout result, while preserving
// the normal request budget for transport and response handling.
timeout += monitor.MaxProbeTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var result monitor.SyncResponse
return result, sys.request(ctx, common.SyncNetworkMonitors, req, &result)
}
+143 -2
View File
@@ -9,6 +9,7 @@ import (
"math/rand"
"net"
"strings"
"sync"
"sync/atomic"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
@@ -30,6 +32,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"
)
@@ -52,6 +56,10 @@ type System struct {
smartInterval time.Duration // Interval for periodic SMART data updates
zfsFetching atomic.Bool // True if ZFS pools are currently being fetched
zfsInterval time.Duration // Interval for periodic ZFS detail data updates
// Serialize persistence from scheduled updates and resumes through commit.
recordsMu sync.Mutex
// Protected by recordsMu; realtime reads don't consume probes.
lastSavedMonitorProbe map[string]int64
}
func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -211,11 +219,15 @@ func (sys *System) handlePaused() {
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
sys.recordsMu.Lock()
defer sys.recordsMu.Unlock()
systemRecord, err := sys.getRecord(sys.manager.hub)
if err != nil {
return nil, err
}
hub := sys.manager.hub
savedMonitorProbes := make(map[string]int64)
err = hub.RunInTransaction(func(txApp core.App) error {
// add system_stats record
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
@@ -266,6 +278,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
}
}
if data.Monitors != nil {
if err := sys.updateNetworkMonitorsRecords(txApp, data.Monitors, savedMonitorProbes); err != nil {
return err
}
}
if err := sys.syncZfsPoolHealth(txApp, data.Stats.ZfsPools); err != nil {
return err
}
@@ -287,6 +305,29 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return nil
})
// Publish only successful inserts after the entire transaction commits.
if err == nil && len(savedMonitorProbes) > 0 {
if sys.lastSavedMonitorProbe == nil {
sys.lastSavedMonitorProbe = savedMonitorProbes
} else {
for id, timestamp := range savedMonitorProbes {
sys.lastSavedMonitorProbe[id] = timestamp
}
}
}
// A non-nil report includes cached results for all remaining monitors.
if err == nil && data.Monitors != nil {
for id := range sys.lastSavedMonitorProbe {
if _, exists := data.Monitors[id]; !exists {
delete(sys.lastSavedMonitorProbe, id)
}
}
}
if err == nil {
if alertErr := hub.HandleNetworkMonitorAlerts(systemRecord, data.Monitors); alertErr != nil {
hub.Logger().Error("Error handling network monitor alerts", "err", alertErr)
}
}
return systemRecord, err
}
@@ -337,7 +378,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
}
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
@@ -363,6 +404,106 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
return err
}
func (sys *System) updateNetworkMonitorsRecords(app core.App, monitorResults map[string]monitor.Result, savedProbes map[string]int64) error {
if len(monitorResults) == 0 {
return nil
}
var err error
systemId := sys.Id
const monitorCollectionName = "network_monitors"
// 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, monitorCollectionName, 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()
monitorFields := []string{"res", "resMin1h", "resMax1h", "resAvg1h", "loss1h", "updated"}
setClauses := make([]string, len(monitorFields))
for i, f := range monitorFields {
setClauses[i] = fmt.Sprintf("%s={:%s}", f, f)
}
queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", monitorCollectionName, strings.Join(setClauses, ", "))
updateQuery = db.NewQuery(queryString)
}
// update network_monitors records
for id, result := range monitorResults {
monitorData := 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(monitorCollectionName, id)
if err == nil {
record.Load(monitorData)
err = app.SaveNoValidate(record)
}
default:
_, err = updateQuery.Bind(dbx.Params(monitorData)).Execute()
}
if err != nil {
app.Logger().Warn("Failed to update monitor", "system", systemId, "monitor", id, "err", err)
}
}
// handle stats collection — one record per monitor
const statsCollectionName = "network_monitor_stats"
var statsCollection *core.Collection
if realtimeActive {
statsCollection, _ = app.FindCachedCollectionByNameOrId(statsCollectionName)
}
for monitorId, result := range monitorResults {
// Compare identity, not ordering, so agent clock changes don't stall writes.
if result.LastProbeAt == sys.lastSavedMonitorProbe[monitorId] {
continue
}
statsRecordData := map[string]any{
"system": systemId,
"monitor": monitorId,
"type": "1m",
"created": nowMilli,
"res_min": result.MinResponse,
"res_max": result.MaxResponse,
"total_count": result.TotalCount,
"success_count": result.SuccessCount,
"res_sum": result.ResponseSum,
}
switch realtimeActive {
case true:
record := core.NewRecord(statsCollection)
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 monitor stats", "system", systemId, "monitor", monitorId, "err", err)
} else {
savedProbes[monitorId] = result.LastProbeAt
}
}
return nil
}
// createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 {
@@ -622,7 +763,7 @@ func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, 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))
+29 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap"
@@ -17,6 +18,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"
@@ -62,6 +64,7 @@ type hubLike interface {
core.App
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleNetworkMonitorAlerts(systemRecord *core.Record, results map[string]monitor.Result) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
HandleContainerAlerts(systemRecord *core.Record, data *system.CombinedData, fetchLogs func(containerID string) (string, error)) error
CancelPendingStatusAlerts(systemID string)
@@ -350,6 +353,20 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
if err := sm.AddRecord(systemRecord, system); err != nil {
return err
}
// Sync network monitors to the newly connected agent
go func() {
configs, err := sm.GetMonitorConfigsForSystem(systemId)
if err != nil {
sm.hub.Logger().Warn("failed to load monitors for agent", "system", systemId, "err", err)
return
}
// An empty set must also replace any probes retained across a disconnect.
if err := system.SyncNetworkMonitors(configs); err != nil {
sm.hub.Logger().Warn("failed to sync monitors to agent", "system", systemId, "err", err)
}
}()
return nil
}
@@ -362,6 +379,16 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
}
}
// GetMonitorConfigsForSystem returns all enabled monitor configs for a system.
func (sm *SystemManager) GetMonitorConfigsForSystem(systemID string) ([]monitor.Config, error) {
var configs []monitor.Config
err := sm.hub.DB().
NewQuery("SELECT id, target, protocol, port, interval FROM network_monitors WHERE system = {:system} AND enabled = true").
Bind(dbx.Params{"system": systemID}).
All(&configs)
return configs, err
}
// resetFailedZfsFetchState clears only failed ZFS cooldown entries so a fresh
// agent reconnect retries ZFS discovery immediately after configuration changes.
func (sm *SystemManager) resetFailedZfsFetchState(systemID string) {
@@ -397,11 +424,12 @@ func (sm *SystemManager) createSSHClientConfig() error {
// deactivateAlerts finds all triggered alerts for a system and sets them to inactive.
// This is called when a system is paused or goes offline to prevent continued alerts.
// Monitor incidents remain open: a missing observation does not establish recovery.
func deactivateAlerts(app core.App, systemID string) error {
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1 && name != 'NetworkMonitorLoss'", systemID), "", -1, 0)
if err != nil {
return err
}
+19 -1
View File
@@ -6,6 +6,8 @@ import (
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
@@ -165,7 +167,7 @@ func (sm *SystemManager) fetchRealtimeDataAndNotify() {
if err != nil {
return
}
bytes, err := json.Marshal(data)
bytes, err := marshalRealtimeData(data)
if err == nil {
notify(sm.hub, system, fetch.subscription, bytes)
}
@@ -204,6 +206,22 @@ func (sm *SystemManager) finishRealtimeFetch(fetch realtimeFetch) {
}
}
// marshalRealtimeData marshals combined agent data for a realtime broadcast, converting
// the per-monitor results into the derived metric fields the frontend charts expect.
func marshalRealtimeData(data *system.CombinedData) ([]byte, error) {
if len(data.Monitors) == 0 {
return json.Marshal(data)
}
monitorStats := make(map[string]monitor.Stats, len(data.Monitors))
for id, result := range data.Monitors {
monitorStats[id] = monitor.Stats{}.FromResult(result)
}
return json.Marshal(struct {
*system.CombinedData
Monitors map[string]monitor.Stats `json:"Monitors"`
}{data, monitorStats})
}
// notify broadcasts realtime data to all clients subscribed to a specific subscription.
// Custom topics bypass collection rules, so check current access for every
// recipient, including clients whose authentication or membership was revoked.
+2 -2
View File
@@ -77,7 +77,7 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, comple
currentIDs := make(map[string]struct{}, len(smartData))
for deviceKey := range smartData {
currentIDs[makeStableHashId(sys.Id, deviceKey)] = struct{}{}
currentIDs[MakeStableHashId(sys.Id, deviceKey)] = struct{}{}
}
err = hub.RunInTransaction(func(txApp core.App) error {
@@ -115,7 +115,7 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, comple
}
func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collection, deviceKey string, device smart.SmartData) error {
recordID := makeStableHashId(sys.Id, deviceKey)
recordID := MakeStableHashId(sys.Id, deviceKey)
record, err := app.FindRecordById(collection, recordID)
if err != nil {
+4 -2
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/smart"
esystem "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap"
@@ -28,7 +29,8 @@ func (stubHub) GetSSHKey(dataDir string) (ssh.Signer, error) { return nil, nil }
func (stubHub) HandleSystemAlerts(systemRecord *core.Record, data *esystem.CombinedData) error {
return nil
}
func (stubHub) HandleStatusAlerts(status string, systemRecord *core.Record) error { return nil }
func (stubHub) HandleNetworkMonitorAlerts(*core.Record, map[string]monitor.Result) error { return nil }
func (stubHub) HandleStatusAlerts(status string, systemRecord *core.Record) error { return nil }
func (stubHub) HandleContainerAlerts(systemRecord *core.Record, data *esystem.CombinedData, fetchLogs func(containerID string) (string, error)) error {
return nil
}
@@ -212,7 +214,7 @@ func TestSaveSmartDevices_IncompleteDataDoesNotRemoveDevices(t *testing.T) {
}, false))
assert.Len(t, countSmartDeviceRecords(t, testApp, sys.Id), 2)
recordA, err := testApp.FindRecordById("smart_devices", makeStableHashId(sys.Id, "AAA"))
recordA, err := testApp.FindRecordById("smart_devices", MakeStableHashId(sys.Id, "AAA"))
require.NoError(t, err)
assert.EqualValues(t, 42, recordA.GetInt("temp"))
}
+9 -9
View File
@@ -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
+2 -2
View File
@@ -129,7 +129,7 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
}
func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection, pool *zfs.PoolDetail) error {
recordID := makeStableHashId(sys.Id, pool.Name)
recordID := MakeStableHashId(sys.Id, pool.Name)
record, err := app.FindRecordById(collection, recordID)
if err != nil {
@@ -171,7 +171,7 @@ func (sys *System) syncZfsPoolHealth(app core.App, pools map[string]*system.ZfsP
if pool == nil {
continue
}
recordID := makeStableHashId(sys.Id, name)
recordID := MakeStableHashId(sys.Id, name)
record, err := app.FindRecordById(collection, recordID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
+6 -6
View File
@@ -135,14 +135,14 @@ func TestSavePartialBackendInventory(t *testing.T) {
{Name: healthyKey, Alloc: 10}, {Name: failedKey, Alloc: 10},
}}
require.NoError(t, sys.saveZfsPools(initial))
failedID := makeStableHashId(sys.Id, failedKey)
failedID := MakeStableHashId(sys.Id, failedKey)
before, err := app.FindRecordById("zfs_pools", failedID)
require.NoError(t, err)
partial := &zfs.ZfsData{CompleteBackends: []string{healthy}, Pools: []*zfs.PoolDetail{
{Name: healthyKey, Alloc: 20}, {Name: failedKey, Alloc: 99},
}}
assert.ErrorIs(t, sys.saveZfsPools(partial), errIncompleteZfsData)
fresh, err := app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, healthyKey))
fresh, err := app.FindRecordById("zfs_pools", MakeStableHashId(sys.Id, healthyKey))
require.NoError(t, err)
assert.EqualValues(t, 20, fresh.GetInt("alloc"))
cached, err := app.FindRecordById("zfs_pools", failedID)
@@ -169,7 +169,7 @@ func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{
"tank": {Total: 100, Used: 25, Health: "ONLINE"},
}))
record, err := app.FindRecordById(collection, makeStableHashId(sys.Id, "tank"))
record, err := app.FindRecordById(collection, MakeStableHashId(sys.Id, "tank"))
require.NoError(t, err)
firstUpdated := record.GetDateTime("updated")
assert.Equal(t, "ONLINE", record.GetString("health"))
@@ -193,7 +193,7 @@ func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
func TestZfsRawCapacityPersistence(t *testing.T) {
sys, app := newTestSystemWithHub(t)
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true, Pools: []*zfs.PoolDetail{{Name: "btrfs", Size: 200, Alloc: 10, Raw: true}}}))
record, err := app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, "btrfs"))
record, err := app.FindRecordById("zfs_pools", MakeStableHashId(sys.Id, "btrfs"))
require.NoError(t, err)
require.True(t, record.GetBool("raw"))
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{"btrfs": {Total: 1, Used: 0.25}}))
@@ -210,7 +210,7 @@ func TestBtrfsDisplayNameKeepsRecordIdentity(t *testing.T) {
key: {DisplayName: "tank", Health: "ONLINE"},
"tank": {Health: "ONLINE"},
}))
id := makeStableHashId(sys.Id, key)
id := MakeStableHashId(sys.Id, key)
record, err := app.FindRecordById("zfs_pools", id)
require.NoError(t, err)
assert.Equal(t, "tank", record.GetString("display_name"))
@@ -225,6 +225,6 @@ func TestBtrfsDisplayNameKeepsRecordIdentity(t *testing.T) {
record, err = app.FindRecordById("zfs_pools", id)
require.NoError(t, err)
assert.Equal(t, "detail name", record.GetString("display_name"))
_, err = app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, "tank"))
_, err = app.FindRecordById("zfs_pools", MakeStableHashId(sys.Id, "tank"))
require.NoError(t, err)
}
+28 -1
View File
@@ -1,7 +1,11 @@
// Package utils provides utility functions for the hub.
package utils
import "os"
import (
"os"
"github.com/pocketbase/pocketbase/core"
)
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
@@ -10,3 +14,26 @@ func GetEnv(key string) (value string, exists bool) {
}
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
}
@@ -83,7 +83,8 @@ func init() {
"ContainerHealth",
"SystemdFailed",
"CPUIOWait",
"CPUSteal"
"CPUSteal",
"NetworkMonitorLoss"
]
},
{
@@ -119,6 +120,16 @@ func init() {
"system": false,
"type": "bool"
},
{
"hidden": true,
"id": "json4000656575",
"maxSize": 0,
"name": "state",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": true,
"id": "date1302749137",
@@ -152,6 +163,7 @@ func init() {
}
],
"indexes": [
"CREATE INDEX idx_alerts_system_name ON alerts (system, name)",
"CREATE UNIQUE INDEX ` + "`" + `idx_MnhEt21L5r` + "`" + ` ON ` + "`" + `alerts` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `,\n ` + "`" + `name` + "`" + `\n)"
],
"system": false
@@ -234,6 +246,20 @@ func init() {
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3888135399",
"max": 0,
"min": 0,
"name": "monitor_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number494360628",
@@ -1721,6 +1747,7 @@ func init() {
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
@@ -1736,6 +1763,7 @@ func init() {
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"help": "",
"hidden": false,
"id": "relation1204987316",
"maxSelect": 1,
@@ -1748,6 +1776,7 @@ func init() {
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text7739291048",
"max": 0,
@@ -1762,6 +1791,7 @@ func init() {
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text5528164482",
"max": 0,
@@ -1775,6 +1805,7 @@ func init() {
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number8862034195",
"max": null,
@@ -1787,6 +1818,7 @@ func init() {
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number4418907321",
"max": null,
@@ -1799,6 +1831,7 @@ func init() {
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number2904183765",
"max": null,
@@ -1811,6 +1844,7 @@ func init() {
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "json4466109723",
"maxSize": 0,
@@ -1821,6 +1855,7 @@ func init() {
"type": "json"
},
{
"help": "",
"hidden": false,
"id": "json9012873456",
"maxSize": 0,
@@ -1831,6 +1866,7 @@ func init() {
"type": "json"
},
{
"help": "",
"hidden": false,
"id": "json7182045639",
"maxSize": 0,
@@ -1841,6 +1877,7 @@ func init() {
"type": "json"
},
{
"help": "",
"hidden": false,
"id": "date9274163058",
"max": "",
@@ -1860,19 +1897,401 @@ func init() {
"presentable": false,
"system": false,
"type": "autodate"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3578368839",
"max": 0,
"min": 0,
"name": "display_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "bool447994709",
"name": "raw",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}
],
"id": "pbc_8441057391",
"indexes": [
"CREATE INDEX ` + "`" + `idx_zfsPoolsSystem` + "`" + ` ON ` + "`" + `zfs_pools` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"listRule": null,
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "zfs_pools",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"help": "",
"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",
"help": "",
"hidden": false,
"id": "nm_system",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "nm_target",
"max": 500,
"min": 1,
"name": "target",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "nm_protocol",
"maxSelect": 1,
"name": "protocol",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"icmp",
"tcp",
"http",
"dns"
]
},
{
"help": "",
"hidden": false,
"id": "nm_port",
"max": 65535,
"min": 0,
"name": "port",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "nm_interval",
"max": 3600,
"min": 1,
"name": "interval",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number926446584",
"max": null,
"min": null,
"name": "res",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number1006954605",
"max": null,
"min": null,
"name": "resAvg1h",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number4267669802",
"max": null,
"min": null,
"name": "resMin1h",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number591433223",
"max": null,
"min": null,
"name": "resMax1h",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number3726709001",
"max": null,
"min": null,
"name": "loss1h",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "nm_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"
},
{
"help": "",
"hidden": false,
"id": "date3332085495",
"max": "",
"min": "",
"name": "updated",
"presentable": false,
"required": false,
"system": false,
"type": "date"
}
],
"id": "nm_monitors_001",
"indexes": [
"CREATE INDEX ` + "`" + `idx_nm_system_enabled` + "`" + ` ON ` + "`" + `network_monitors` + "`" + ` (` + "`" + `system` + "`" + `, ` + "`" + `enabled` + "`" + `)"
],
"listRule": null,
"name": "network_monitors",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"help": "",
"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",
"help": "",
"hidden": false,
"id": "nms_system",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "nm_monitors_001",
"help": "",
"hidden": false,
"id": "nms_monitor",
"maxSelect": 1,
"minSelect": 0,
"name": "monitor",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"help": "Number of probe attempts",
"hidden": false,
"id": "nms_total_count",
"max": null,
"min": 0,
"name": "total_count",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "Number of successful probe attempts",
"hidden": false,
"id": "nms_success_count",
"max": null,
"min": 0,
"name": "success_count",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "Sum of successful response times in microseconds",
"hidden": false,
"id": "nms_res_sum",
"max": null,
"min": 0,
"name": "res_sum",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "Response time in microseconds",
"hidden": false,
"id": "nms_res_min",
"max": null,
"min": 0,
"name": "res_min",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "Response time in microseconds",
"hidden": false,
"id": "nms_res_max",
"max": null,
"min": 0,
"name": "res_max",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "nms_type",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"1m",
"10m",
"20m",
"120m",
"480m"
]
},
{
"help": "",
"hidden": false,
"id": "number2990389176",
"max": null,
"min": null,
"name": "created",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}
],
"id": "nm_stats_001",
"indexes": [
"CREATE INDEX IF NOT EXISTS ` + "`" + `idx_nms_system_type_created` + "`" + ` ON ` + "`" + `network_monitor_stats` + "`" + ` (` + "`" + `system` + "`" + `, ` + "`" + `type` + "`" + `, ` + "`" + `created` + "`" + `)",
"CREATE INDEX IF NOT EXISTS ` + "`" + `idx_nms_monitor_type_created` + "`" + ` ON ` + "`" + `network_monitor_stats` + "`" + ` (` + "`" + `monitor` + "`" + `, ` + "`" + `type` + "`" + `, ` + "`" + `created` + "`" + `)",
"CREATE INDEX IF NOT EXISTS ` + "`" + `idx_nms_type_created` + "`" + ` ON ` + "`" + `network_monitor_stats` + "`" + ` (` + "`" + `type` + "`" + `, ` + "`" + `created` + "`" + `)"
],
"listRule": null,
"name": "network_monitor_stats",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}
]`
err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
+226
View File
@@ -0,0 +1,226 @@
//go:build testing
package records_test
import (
"testing"
"time"
monitorEntity "github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAverageMonitorStats(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
collection, err := hub.FindCachedCollectionByNameOrId("network_monitor_stats")
require.NoError(t, err)
assert.Nil(t, collection.Fields.GetByName("res_avg"))
assert.Nil(t, collection.Fields.GetByName("loss"))
rm := records.NewRecordManager(hub)
user, err := tests.CreateUser(hub, "monitor-avg@example.com", "testtesttest")
require.NoError(t, err)
sys, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "monitor-avg-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
monitor, err := tests.CreateRecord(hub, "network_monitors", map[string]any{
"system": sys.Id,
"name": "cloudflare",
"target": "1.1.1.1",
"protocol": "icmp",
"interval": 30,
"enabled": true,
})
require.NoError(t, err)
created := time.Now().UnixMilli()
// Unequal probe counts must weight both latency and loss.
recordA, err := tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
"system": sys.Id,
"monitor": monitor.Id,
"type": "1m",
"created": created,
"res_min": 5,
"res_max": 20,
"total_count": 6, "success_count": 6, "res_sum": 60,
})
require.NoError(t, err)
recordB, err := tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
"system": sys.Id,
"monitor": monitor.Id,
"type": "1m",
"created": created,
"res_min": 10,
"res_max": 60,
"total_count": 1, "success_count": 1, "res_sum": 22,
})
require.NoError(t, err)
result, count, err := rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
require.NoError(t, err)
assert.Equal(t, 2, count)
assert.Equal(t, monitorEntity.Stats{ResAvg: 11.71, ResMin: 5, ResMax: 60, TotalCount: 7, SuccessCount: 7, ResponseSum: 82}, result)
for _, tc := range []struct {
name, monitor, recordType string
after int64
}{
{"other monitor", "missing", "1m", created - 1},
{"other type", monitor.Id, "10m", created - 1},
{"exclusive cutoff", monitor.Id, "1m", created},
} {
t.Run(tc.name, func(t *testing.T) {
stats, count, err := rm.AverageMonitorStats(hub.DB(), tc.monitor, tc.recordType, tc.after)
require.NoError(t, err)
assert.Zero(t, count)
assert.Equal(t, monitorEntity.Stats{}, stats)
})
}
// A failure-only bucket counts toward loss but must not lower latency.
recordB.Set("res_min", 0)
recordB.Set("res_max", 0)
recordB.Set("success_count", 0)
recordB.Set("res_sum", 0)
require.NoError(t, hub.Save(recordB))
result, count, err = rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
require.NoError(t, err)
assert.Equal(t, 2, count)
assert.Equal(t, monitorEntity.Stats{ResAvg: 10, ResMin: 5, ResMax: 20, Loss: 14.29, TotalCount: 7, SuccessCount: 6, ResponseSum: 60}, result)
// Sparse monitor records must propagate through every rollup level.
rm.CreateLongerRecords()
for _, recordType := range []string{"10m", "20m", "120m", "480m"} {
rollups, err := hub.FindAllRecords("network_monitor_stats", dbx.HashExp{"monitor": monitor.Id, "type": recordType})
require.NoError(t, err)
require.Len(t, rollups, 1, recordType)
assert.Equal(t, 5.0, rollups[0].GetFloat("res_min"))
assert.Equal(t, 20.0, rollups[0].GetFloat("res_max"))
assert.Equal(t, 7, rollups[0].GetInt("total_count"))
assert.Equal(t, 6, rollups[0].GetInt("success_count"))
assert.Equal(t, 60, rollups[0].GetInt("res_sum"))
// A sibling with a different number of probes must retain its actual
// weight when the next tier combines their underlying counts.
_, err = tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
"system": sys.Id, "monitor": monitor.Id, "type": recordType, "created": created,
"res_min": 100, "res_max": 100,
"total_count": 3, "success_count": 1, "res_sum": 100,
})
require.NoError(t, err)
merged, count, err := rm.AverageMonitorStats(hub.DB(), monitor.Id, recordType, created-1)
require.NoError(t, err)
assert.Equal(t, 2, count)
assert.Equal(t, monitorEntity.Stats{
ResAvg: 22.86, ResMin: 5, ResMax: 100, Loss: 30,
TotalCount: 10, SuccessCount: 7, ResponseSum: 160,
}, merged)
}
// All failures produce zero latency, while a genuine zero-microsecond
// success remains a valid minimum (it must not be filtered out as a sentinel).
recordA.Set("success_count", 0)
recordA.Set("res_sum", 0)
require.NoError(t, hub.Save(recordA))
result, _, err = rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
require.NoError(t, err)
assert.Equal(t, monitorEntity.Stats{TotalCount: 7, Loss: 100}, result)
recordA.Set("success_count", 1)
recordA.Set("res_min", 0)
recordA.Set("res_max", 0)
require.NoError(t, hub.Save(recordA))
result, _, err = rm.AverageMonitorStats(hub.DB(), monitor.Id, "1m", created-1)
require.NoError(t, err)
assert.Equal(t, monitorEntity.Stats{TotalCount: 7, SuccessCount: 1, Loss: 85.71}, result)
}
func TestSparseMonitorRollups(t *testing.T) {
for _, tc := range []struct {
name string
interval int
samples int
disabled bool
}{
{"five minute interval", 300, 2, false},
{"ten minute interval", 600, 1, false},
{"fifteen minute interval", 900, 1, false},
{"empty window", 900, 0, false},
{"disabled with pending history", 300, 2, true},
{"disabled empty window", 900, 0, true},
} {
t.Run(tc.name, func(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
user, err := tests.CreateUser(hub, "sparse-monitor@example.com", "testtesttest")
require.NoError(t, err)
sys, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "sparse-monitor-system", "host": "localhost", "port": "45876", "status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
monitor, err := tests.CreateRecord(hub, "network_monitors", map[string]any{
"system": sys.Id, "target": "1.1.1.1", "protocol": "icmp",
"interval": tc.interval, "enabled": true,
})
require.NoError(t, err)
now := time.Now()
for i := range tc.samples {
_, err := tests.CreateRecord(hub, "network_monitor_stats", map[string]any{
"system": sys.Id, "monitor": monitor.Id, "type": "1m",
"created": now.Add(-time.Minute - time.Duration(i*tc.interval)*time.Second).UnixMilli(),
"res_min": 8, "res_max": 20,
"total_count": 4, "success_count": 3, "res_sum": 36,
})
require.NoError(t, err)
}
// Other collections must still reject fewer than nine minute records.
for _, collection := range []string{"system_stats", "container_stats"} {
stats := `{"cpu":10}`
if collection == "container_stats" {
stats = `[{"name":"test","cpu":10}]`
}
for range 8 {
_, err := tests.CreateRecord(hub, collection, map[string]any{
"system": sys.Id, "type": "1m", "stats": stats,
})
require.NoError(t, err)
}
}
if tc.disabled {
monitor.Set("enabled", false)
require.NoError(t, hub.Save(monitor))
}
records.NewRecordManager(hub).CreateLongerRecords()
for _, recordType := range []string{"10m", "20m", "120m", "480m"} {
rollups, err := hub.FindAllRecords("network_monitor_stats", dbx.HashExp{"monitor": monitor.Id, "type": recordType})
require.NoError(t, err)
if tc.samples == 0 {
assert.Empty(t, rollups, recordType)
} else {
require.Len(t, rollups, 1, recordType)
assert.Equal(t, 8.0, rollups[0].GetFloat("res_min"))
assert.Equal(t, 20.0, rollups[0].GetFloat("res_max"))
assert.Equal(t, 4*tc.samples, rollups[0].GetInt("total_count"))
assert.Equal(t, 3*tc.samples, rollups[0].GetInt("success_count"))
assert.Equal(t, 36*tc.samples, rollups[0].GetInt("res_sum"))
}
for _, collection := range []string{"system_stats", "container_stats"} {
count, err := hub.CountRecords(collection, dbx.HashExp{"system": sys.Id, "type": recordType})
require.NoError(t, err)
assert.Zero(t, count, "%s %s", collection, recordType)
}
}
})
}
}
+140 -28
View File
@@ -3,15 +3,16 @@ package records
import (
"encoding/json"
"log/slog"
"math"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/monitor"
"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,7 +40,7 @@ type StatsRecord struct {
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
now := time.Now().UTC()
longerRecordData := []LongerRecordData{
{
shorterType: "1m",
@@ -69,23 +70,28 @@ func (rm *RecordManager) CreateLongerRecords() {
}
// wrap the operations in a transaction
// Pocketbase cron does not handle errors, log them here.
rm.app.RunInTransaction(func(txApp core.App) error {
err := rm.app.RunInTransaction(func(txApp core.App) error {
var err error
collections := [2]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
slog.Error("Error finding cached collection using system stats:", "err", err)
return err
}
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
slog.Error("Error finding cached collection using container stats:", "err", err)
return err
}
monitorStatsColl, err := txApp.FindCachedCollectionByNameOrId("network_monitor_stats")
if err != nil {
return err
}
var systems RecordIds
db := txApp.DB()
db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
if err := db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems); err != nil {
return err
}
// loop through all active systems, time periods, and collections
for _, system := range systems {
@@ -94,44 +100,52 @@ 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)
// loop through both collections
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
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},
),
)
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.Format(types.DefaultDateLayout),
},
))
if err != nil {
return err
}
// continue if longer record exists
if err != nil || count > 0 {
if count > 0 {
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": shorterRecordPeriod.Format(types.DefaultDateLayout),
}
err := 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,
)).
OrderBy("created").
All(&recordIds)
if err != nil {
return err
}
// 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
@@ -142,20 +156,88 @@ func (rm *RecordManager) CreateLongerRecords() {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
slog.Error("failed to save longer record", "err", err)
txApp.Logger().Error("failed to save longer record", "err", err)
}
}
}
}
// network_monitor_stats is aggregated per monitor (not per system)
var monitors []struct {
Id string `db:"id"`
System string `db:"system"`
}
// Disabled monitors still have history that must advance through retention tiers.
if err := db.NewQuery("SELECT id, system FROM network_monitors").All(&monitors); err != nil {
return err
}
for _, monitorRec := range monitors {
for i := range longerRecordData {
recordData := longerRecordData[i]
longerRecordPeriod := now.Add(recordData.longerTimeDuration + time.Minute)
shorterRecordPeriod := now.Add(recordData.longerTimeDuration)
if recordData.longerType != "10m" {
count, err := txApp.CountRecords(monitorStatsColl.Id, dbx.NewExp(
"monitor={:monitor} AND type={:type} AND created>{:created}",
dbx.Params{
"monitor": monitorRec.Id,
"type": recordData.longerType,
"created": longerRecordPeriod.UnixMilli(),
},
))
if err != nil {
return err
}
if count > 0 {
continue
}
}
stats, count, err := rm.AverageMonitorStats(db, monitorRec.Id, recordData.shorterType, shorterRecordPeriod.UnixMilli())
if err != nil {
txApp.Logger().Error("failed to average monitor stats", "monitor", monitorRec.Id, "err", err)
continue
}
// Monitor intervals can exceed the aggregation window, so average
// any available records at every level and skip only empty windows.
if count == 0 {
continue
}
longerRecord := core.NewRecord(monitorStatsColl)
longerRecord.Set("system", monitorRec.System)
longerRecord.Set("monitor", monitorRec.Id)
longerRecord.Set("type", recordData.longerType)
longerRecord.Set("created", now.UnixMilli())
longerRecord.Set("res_min", stats.ResMin)
longerRecord.Set("res_max", stats.ResMax)
longerRecord.Set("total_count", stats.TotalCount)
longerRecord.Set("success_count", stats.SuccessCount)
longerRecord.Set("res_sum", stats.ResponseSum)
if err := txApp.SaveNoValidate(longerRecord); err != nil {
txApp.Logger().Error("failed to save monitor longer record", "err", err)
}
}
}
return nil
})
if err != nil {
rm.app.Logger().Error("failed to create longer records", "err", err)
}
}
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
func getCreatedTimeField(collectionName string, period time.Time) any {
// network_monitor_stats stores created as unix timestamp in ms, not as a date string
if collectionName == "network_monitor_stats" {
return period.UnixMilli()
}
return period.Format(types.DefaultDateLayout)
}
// Calculate the average stats of a list of system_stats records without reflect
@@ -596,6 +678,36 @@ func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
return result
}
// AverageMonitorStats merges probe counts and response sums, preserving their
// weights through every retention tier. Failed probes do not contribute latency.
func (rm *RecordManager) AverageMonitorStats(db dbx.Builder, monitorID, recordType string, createdAfter int64) (monitor.Stats, int, error) {
var result struct {
monitor.Stats
Count int `db:"count"`
}
err := db.Select(
"COUNT(*) AS count",
"COALESCE(SUM(total_count), 0) AS total_count",
"COALESCE(SUM(success_count), 0) AS success_count",
"COALESCE(SUM(res_sum), 0) AS res_sum",
"COALESCE(MIN(CASE WHEN success_count > 0 THEN res_min END), 0) AS res_min",
"COALESCE(MAX(CASE WHEN success_count > 0 THEN res_max END), 0) AS res_max",
).From("network_monitor_stats").Where(dbx.NewExp(
"monitor={:monitor} AND type={:type} AND created>{:created}",
dbx.Params{"monitor": monitorID, "type": recordType, "created": createdAfter},
)).One(&result)
if err != nil {
return monitor.Stats{}, 0, err
}
if result.SuccessCount > 0 {
result.ResAvg = twoDecimals(float64(result.ResponseSum) / float64(result.SuccessCount))
}
if result.TotalCount > 0 {
result.Loss = twoDecimals(float64(result.TotalCount-result.SuccessCount) * 100 / float64(result.TotalCount))
}
return result.Stats, result.Count, nil
}
/* Round float to two decimals */
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
+10 -18
View File
@@ -3,7 +3,6 @@ package records
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/pocketbase/dbx"
@@ -60,7 +59,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
// 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"}
collections := [3]string{"system_stats", "container_stats", "network_monitor_stats"}
// Record types and their retention periods
type RecordDeletionData struct {
@@ -76,24 +75,17 @@ func deleteOldSystemStats(app core.App) error {
}
now := time.Now().UTC()
db := app.DB()
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)
}
// 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)
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
+86
View File
@@ -0,0 +1,86 @@
//go:build testing
package records_test
import (
"testing"
"time"
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/require"
)
func TestLongerRecordsPreventDuplicates(t *testing.T) {
for _, collection := range []string{"system_stats", "container_stats", "network_monitor_stats"} {
for _, tier := range []struct {
shorter, longer string
count int
}{
{"10m", "20m", 2},
{"20m", "120m", 6},
{"120m", "480m", 4},
} {
t.Run(collection+"/"+tier.longer, func(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
user, err := tests.CreateUser(hub, "rollup@example.com", "testtesttest")
require.NoError(t, err)
sys, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "rollup-system", "host": "localhost", "port": "45876",
"status": "up", "users": []string{user.Id},
})
require.NoError(t, err)
created := time.Now().UTC().Add(-time.Minute)
data := map[string]any{
"system": sys.Id, "type": tier.shorter,
"created": created.Format(types.DefaultDateLayout),
}
filter := dbx.HashExp{"system": sys.Id, "type": tier.longer}
switch collection {
case "system_stats":
data["stats"] = `{"cpu":10}`
case "container_stats":
data["stats"] = `[{"name":"test","cpu":10}]`
case "network_monitor_stats":
monitor, err := tests.CreateRecord(hub, "network_monitors", map[string]any{
"system": sys.Id, "target": "1.1.1.1", "protocol": "icmp",
"interval": 30, "enabled": true,
})
require.NoError(t, err)
data["monitor"] = monitor.Id
data["created"] = created.UnixMilli()
data["total_count"] = 1
data["success_count"] = 1
data["res_sum"] = 10
data["res_min"] = 10
data["res_max"] = 10
filter["monitor"] = monitor.Id
}
for range tier.count {
_, err := tests.CreateRecord(hub, collection, data)
require.NoError(t, err)
}
rm := records.NewRecordManager(hub)
rm.CreateLongerRecords()
first, err := hub.FindAllRecords(collection, filter)
require.NoError(t, err)
require.Len(t, first, 1)
// The shorter records remain eligible, but the existing longer
// record must prevent another rollup on a subsequent invocation.
rm.CreateLongerRecords()
second, err := hub.FindAllRecords(collection, filter)
require.NoError(t, err)
require.Len(t, second, 1)
require.Equal(t, first[0].Id, second[0].Id)
})
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.19.0",
"version": "0.20.0",
"type": "module",
"scripts": {
"dev": "vite --host",
@@ -74,4 +74,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}
@@ -61,6 +61,8 @@ export const ActiveAlerts = () => {
<AlertDescription>
{info.triggeredDesc ? (
info.triggeredDesc()
) : alert.name === "NetworkMonitorLoss" ? (
<Trans>One or more monitors exceed {alert.value}% loss</Trans>
) : alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : info.invert ? (
@@ -30,7 +30,8 @@ export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
accessorFn: (record) => {
const name = record.name
const info = alertInfo[name]
return info?.name().replace("cpu", "CPU") || name
const label = info?.name().replace("cpu", "CPU") || name
return record.monitor_name ? `${label}: ${record.monitor_name}` : label
},
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
@@ -239,13 +239,13 @@ export function AlertContent({
/** Alerts that fire on first observation have no duration to configure */
const noDuration = alertData.noDuration === true
/** Binary alerts have no threshold to configure */
const noThreshold = !!singleDescription || noDuration
const noThreshold = !!singleDescription || alertData.noThreshold === true
/** Whether enabling the alert reveals anything to configure */
const hasControls = !(noThreshold && noDuration)
const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || (noDuration ? 0 : 10))
const [value, setValue] = useState(alert?.value || (noThreshold ? 0 : (alertData.start ?? 80)))
const [value, setValue] = useState(alert?.value ?? (noThreshold ? 0 : (alertData.start ?? 80)))
const Icon = alertData.icon
@@ -319,7 +319,7 @@ export function AlertContent({
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
{!noThreshold && (
<div>
<div className={cn(noDuration && "col-span-full")}>
<p id={`v${name}`} className="text-sm block h-6">
{alertData.invert ? (
<Trans>
@@ -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])
}
@@ -9,14 +9,21 @@ import { memo } from "react"
export default memo(function ChartTimeSelect({
className,
agentVersion,
chartTimeStore = $chartTime,
allowRealtime = true,
}: {
className?: string
agentVersion: SemVer
chartTimeStore?: typeof $chartTime
allowRealtime?: boolean
}) {
const chartTime = useStore($chartTime)
const chartTime = useStore(chartTimeStore)
// remove chart times that are not supported by the system agent version
const availableChartTimes = Object.entries(chartTimeData).filter(([_, { minVersion }]) => {
const availableChartTimes = Object.entries(chartTimeData).filter(([value, { minVersion }]) => {
if (value === "1m" && !allowRealtime) {
return false
}
if (!minVersion) {
return true
}
@@ -24,7 +31,7 @@ export default memo(function ChartTimeSelect({
})
return (
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => chartTimeStore.set(value)}>
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
<HistoryIcon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
@@ -22,6 +22,10 @@ export type DataPoint<T = SystemStatsRecord> = {
order?: number
strokeOpacity?: number
activeDot?: boolean
dot?: boolean
/** Which Y axis this series plots against. Defaults to "left". */
yAxisId?: "left" | "right"
strokeDasharray?: string
}
export default function LineChartDefault({
@@ -30,9 +34,12 @@ export default function LineChartDefault({
max,
maxToggled,
tickFormatter,
tickFormatter2,
contentFormatter,
dataPoints,
domain,
domain2,
max2,
legend,
itemSorter,
showTotal = false,
@@ -41,18 +48,24 @@ export default function LineChartDefault({
filter,
truncate = false,
chartProps,
connectNulls,
}: {
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
customData?: any[]
max?: number
max2?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
/** Tick formatter for the right ("right"-yAxisId) axis, when any dataPoint uses it. */
tickFormatter2?: (value: number, index: number) => string
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
contentFormatter: (item: any, key: string) => ReactNode
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
dataPoints?: DataPoint<any>[]
domain?: AxisDomain
/** Domain for the right axis, when any dataPoint uses it. */
domain2?: AxisDomain
legend?: boolean
showTotal?: boolean
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
@@ -62,10 +75,15 @@ export default function LineChartDefault({
filter?: string
truncate?: boolean
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
connectNulls?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const hasRightAxis = !!dataPoints?.some((dp) => dp.yAxisId === "right")
// fixed width for the secondary axis rather than measured, since its labels (e.g. loss %) are short
// and predictable, and this avoids depending on a second async width-measurement pass to settle
const rightAxisWidth = 38
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 +101,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}${d.yAxisId}${d.strokeDasharray}`).join("\0")
const XAxis = xAxis(chartData.chartTime, displayData.at(-1)?.created)
const Lines = useMemo(() => {
return dataPoints?.map((dataPoint, i) => {
@@ -94,17 +114,20 @@ export default function LineChartDefault({
return (
<Line
key={dataPoint.label}
yAxisId={dataPoint.yAxisId ?? "left"}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
dot={dataPoint.dot || false}
strokeWidth={1.5}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
strokeDasharray={dataPoint.strokeDasharray}
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
activeDot={dataPoint.activeDot ?? true}
connectNulls={connectNulls}
/>
)
})
@@ -135,6 +158,7 @@ export default function LineChartDefault({
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
yAxisId="left"
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
@@ -145,7 +169,20 @@ export default function LineChartDefault({
axisLine={false}
/>
)}
{xAxis(chartData)}
{!hideYAxis && hasRightAxis && (
<YAxis
yAxisId="right"
direction="ltr"
orientation={chartData.orientation === "left" ? "right" : "left"}
className="tracking-tighter"
width={rightAxisWidth}
domain={domain2 ?? [0, max2 ?? "auto"]}
tickFormatter={tickFormatter2 ?? tickFormatter}
tickLine={false}
axisLine={false}
/>
)}
{XAxis}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
@@ -166,5 +203,5 @@ export default function LineChartDefault({
</LineChart>
</ChartContainer>
)
}, [displayData, yAxisWidth, filter, Lines])
}, [displayData, yAxisWidth, hasRightAxis, filter, Lines, XAxis])
}
@@ -11,6 +11,7 @@ import {
HardDriveIcon,
LogsIcon,
MailIcon,
NetworkIcon,
Server,
ServerIcon,
SettingsIcon,
@@ -122,6 +123,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "monitors"))
setOpen(false)
}}
>
<NetworkIcon className="me-2 size-4" />
<span>
<Trans>Network Monitors</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" }))
@@ -16,7 +16,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"
@@ -64,10 +64,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>
)
},
+23
View File
@@ -8,6 +8,7 @@ import {
LogOutIcon,
LogsIcon,
MenuIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
ServerIcon,
@@ -108,6 +109,13 @@ 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, "monitors"))}
className="flex items-center"
>
<NetworkIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<Trans>Network Monitors</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
className="flex items-center"
@@ -179,6 +187,21 @@ export default function Navbar() {
</TooltipTrigger>
<TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "monitors")}
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Network Monitors"
onMouseEnter={() => import("@/components/routes/monitors")}
>
<NetworkIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>Network Monitors</Trans>
</TooltipContent>
</Tooltip>
<ModeToggle />
<Tooltip>
<TooltipTrigger asChild>
@@ -0,0 +1,778 @@
import { useCallback, 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,
DropdownMenuCheckboxItem,
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, SearchIcon, ServerIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores"
import { cn, supportsNetworkMonitors } from "@/lib/utils"
import type { NetworkMonitorRecord } from "@/types"
import * as v from "valibot"
type MonitorProtocol = "icmp" | "tcp" | "http" | "dns"
type MonitorValues = {
system: string
target: string
protocol: MonitorProtocol
port: number
interval: string
}
type NormalizedMonitorValues = Omit<MonitorValues, "system" | "interval"> & {
interval: number
}
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval">
const defaultInterval = 30
const MonitorProtocolSchema = v.picklist(["icmp", "tcp", "http", "dns"])
const MonitorIntervalSchema = v.pipe(v.string(), v.toNumber(), v.minValue(1), v.maxValue(3600))
// Both the single-monitor form and the bulk importer flow through this schema so
// defaults and HTTP target normalization stay in one place.
const NormalizedMonitorValuesSchema = v.pipe(
v.object({
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
protocol: MonitorProtocolSchema,
port: v.number(),
interval: MonitorIntervalSchema,
}),
v.transform((input): NormalizedMonitorValues => {
let { protocol, port } = input
let httpTarget = input.target
if (protocol === "icmp" || protocol === "http" || protocol === "dns") {
if (protocol === "http") {
httpTarget = normalizeHttpTarget(input.target, port)
}
port = 0
} else if (protocol === "tcp" && !port) {
port = 443
}
return {
// HTTP monitors 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,
}
}),
v.forward(
v.check((input) => {
if (input.protocol === "icmp" || input.protocol === "http" || input.protocol === "dns") {
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 BulkMonitorSchema = 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())),
})
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 buildMonitorPayload(values: MonitorValues, enabled = true) {
const normalizedValues = v.safeParse(NormalizedMonitorValuesSchema, values)
if (!normalizedValues.success) {
throw new Error(normalizedValues.issues[0]?.message || "Invalid monitor")
}
const payload = {
system: values.system,
enabled,
...normalizedValues.output,
}
return payload
}
type MonitorIdentity = Pick<MonitorValues, "system" | "target" | "protocol" | "port">
function getMonitorIdentityKey({ system, target, protocol, port }: MonitorIdentity) {
return `${system}${target}${protocol}${port}`
}
function parseBulkMonitorLine(line: string, lineNumber: number, system: string) {
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = ""] = line.split(",")
const parsed = v.safeParse(BulkMonitorSchema, {
target: rawTarget,
protocol: rawProtocol,
port: rawPort,
interval: rawInterval,
})
if (!parsed.success) {
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid monitor entry"}`)
}
const protocol = (parsed.output.protocol?.toLowerCase() ||
(/^https?:\/\//i.test(parsed.output.target) ? "http" : "icmp")) as MonitorProtocol
return buildMonitorPayload({
system,
target: parsed.output.target,
protocol,
port: parsed.output.port ? Number(parsed.output.port) : 0,
interval: parsed.output.interval || `${defaultInterval}`,
})
}
export function formatBulkMonitorLine(monitor: BulkMonitorLineSource) {
const port = monitor.protocol !== "tcp" || monitor.port === 443 ? "" : `${monitor.port}`
const interval = monitor.interval === defaultInterval ? "" : `${monitor.interval}`
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval]).join(",")
}
function SystemMultiSelect({
id,
selectedSystemIds,
onChange,
disabled,
className,
}: {
id: string
selectedSystemIds: Set<string>
onChange: (ids: Set<string>) => void
disabled?: boolean
className?: string
}) {
const systems = useStore($systems)
const { t } = useLingui()
const [search, setSearch] = useState("")
const searchRef = useRef<HTMLInputElement>(null)
const focusSearchOnMount = useCallback((node: HTMLInputElement | null) => {
searchRef.current = node
if (!node) return
// Focus after the menu has completed its own initial focus handling.
const frame = requestAnimationFrame(() => node.focus())
return () => cancelAnimationFrame(frame)
}, [])
const contentRef = useRef<HTMLDivElement>(null)
const query = search.trim().toLocaleLowerCase()
const filteredSystems = systems.filter(
(system) => supportsNetworkMonitors(system) && system.name.toLocaleLowerCase().includes(query)
)
const allSelected = filteredSystems.every((system) => selectedSystemIds.has(system.id))
const anySelected = filteredSystems.some((system) => selectedSystemIds.has(system.id))
const selectFiltered = (selected: boolean) => {
const next = new Set(selectedSystemIds)
for (const system of filteredSystems) {
if (selected) next.add(system.id)
else next.delete(system.id)
}
onChange(next)
}
return (
<DropdownMenu onOpenChange={() => setSearch("")}>
<DropdownMenuTrigger asChild>
<Button
id={id}
disabled={disabled}
type="button"
variant="outline"
className={cn("relative w-full min-w-0 ps-10 pe-10 justify-start font-normal text-start", className)}
>
<ServerIcon className="size-3.5 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<span className="truncate">
{selectedSystemIds.size === 0
? t`Select systems`
: selectedSystemIds.size === 1
? systems.find((s) => selectedSystemIds.has(s.id))?.name
: t`${selectedSystemIds.size} systems selected`}
</span>
<ChevronDownIcon className="size-4 absolute end-4 top-1/2 -translate-y-1/2 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={contentRef}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
searchRef.current?.focus()
}
}}
align="start"
className="w-[var(--radix-dropdown-menu-trigger-width)] max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] flex flex-col overflow-hidden"
>
<div className="shrink-0 border-b mb-1">
<div className="flex items-center gap-2 px-2.5">
<SearchIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<Input
ref={focusSearchOnMount}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t`Search systems`}
aria-label={t`Search systems`}
className="h-10 min-w-0 rounded-none border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onKeyDown={(event) => {
if (event.key === "Escape") return
// Keep menu typeahead and form submission from consuming search input.
event.stopPropagation()
if (event.key === "Enter") event.preventDefault()
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Tab") {
event.preventDefault()
const items = contentRef.current?.querySelectorAll<HTMLElement>(
'[role^="menuitem"]:not([data-disabled])'
)
const index = event.key === "ArrowUp" || event.shiftKey ? (items?.length ?? 1) - 1 : 0
items?.[index]?.focus()
}
}}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-1 pb-1">
<div className="flex items-center">
<DropdownMenuItem
className="px-1.5 py-1 text-xs text-muted-foreground"
disabled={!filteredSystems.length || allSelected}
onSelect={(event) => {
event.preventDefault()
selectFiltered(true)
}}
>
{query ? <Trans>Select matches</Trans> : <Trans>Select all</Trans>}
</DropdownMenuItem>
<span aria-hidden="true" className="text-xs text-muted-foreground/50">
·
</span>
<DropdownMenuItem
className="px-1.5 py-1 text-xs text-muted-foreground"
disabled={!anySelected}
onSelect={(event) => {
event.preventDefault()
selectFiltered(false)
}}
>
{query ? <Trans>Clear matches</Trans> : <Trans>Clear all</Trans>}
</DropdownMenuItem>
</div>
<span className="px-1.5 text-xs tabular-nums text-muted-foreground">
{t`${selectedSystemIds.size} selected`}
</span>
</div>
</div>
<div className="min-h-0 overflow-y-auto">
{filteredSystems.length === 0 && (
<output className="block px-2.5 py-3 text-sm text-muted-foreground">
<Trans>No systems found.</Trans>
</output>
)}
{filteredSystems.map((sys) => (
<DropdownMenuCheckboxItem
key={sys.id}
checked={selectedSystemIds.has(sys.id)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => {
const next = new Set(selectedSystemIds)
if (checked) next.add(sys.id)
else next.delete(sys.id)
onChange(next)
}}
className="group min-w-0 gap-2.5 py-2 ps-2.5"
indicatorClassName="static size-4 shrink-0 rounded border border-input group-data-[state=checked]:border-primary group-data-[state=checked]:bg-primary group-data-[state=checked]:text-primary-foreground [&_svg]:size-3"
>
<span className="truncate">{sys.name}</span>
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; monitors: NetworkMonitorRecord[] }) {
const [open, setOpen] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkInput, setBulkInput] = useState("")
const [bulkLoading, setBulkLoading] = useState(false)
const [bulkSelectedSystemIds, setBulkSelectedSystemIds] = useState<Set<string>>(new Set())
const bulkFormRef = useRef<HTMLFormElement>(null)
const { toast } = useToast()
const { t } = useLingui()
const resetBulkForm = () => {
setBulkInput("")
}
const openBulkAdd = (selectedSystemIds?: Set<string>) => {
if (!systemId && selectedSystemIds) {
setBulkSelectedSystemIds(new Set(selectedSystemIds))
}
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 targetSystems = systemId ? [systemId] : Array.from(bulkSelectedSystemIds)
if (!targetSystems.length) {
throw new Error("Select at least one system.")
}
const rawLines = bulkInput.split(/\r?\n/).filter((line) => line.trim())
if (!rawLines.length) {
throw new Error("Enter at least one monitor.")
}
let totalCreated = 0
closedForSubmit = true
for (const system of targetSystems) {
const payloads = rawLines.map((line, index) => parseBulkMonitorLine(line, index + 1, system))
const existingMonitorKeys = new Set(
monitors.filter((monitor) => monitor.system === system).map((monitor) => getMonitorIdentityKey(monitor))
)
const newPayloads: typeof payloads = []
for (const payload of payloads) {
const monitorKey = getMonitorIdentityKey(payload)
if (existingMonitorKeys.has(monitorKey)) {
continue
}
existingMonitorKeys.add(monitorKey)
newPayloads.push(payload)
}
if (!newPayloads.length) continue
let batch = pb.createBatch()
let inBatch = 0
for (const payload of newPayloads) {
batch.collection("network_monitors").create(payload)
inBatch++
if (inBatch > 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
if (inBatch) {
await batch.send()
}
totalCreated += newPayloads.length
}
if (!totalCreated) {
throw new Error("No new monitors. All entries already exist.")
}
resetBulkForm()
toast({ title: t`Monitors created`, description: `${totalCreated} monitor(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`Monitor` }}</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 monitor actions`}>
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openBulkAdd()}>
<ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
}}
>
<MonitorDialogContent 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 Monitors` }}</Trans>
</SheetTitle>
<SheetDescription>target[,protocol[,port[,interval]]]</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 htmlFor="bulk-monitor-systems" className="sr-only">
<Trans>Systems</Trans>
</Label>
<SystemMultiSelect
id="bulk-monitor-systems"
selectedSystemIds={bulkSelectedSystemIds}
onChange={setBulkSelectedSystemIds}
disabled={bulkLoading}
className="bg-card"
/>
</div>
)}
<div className="grow flex flex-col gap-2">
<Label htmlFor="bulk-monitors" className="sr-only">
Entries
</Label>
<Textarea
id="bulk-monitors"
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"].join("\n")}
required
/>
<p className="text-xs text-muted-foreground">target[,protocol[,port[,interval]]]</p>
</div>
</div>
<SheetFooter className="border-t">
<Button type="submit" disabled={bulkLoading || (!systemId && !bulkSelectedSystemIds.size)}>
<Trans>Add {{ foo: t`Network Monitors` }}</Trans>
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
</>
)
}
export function EditMonitorDialog({
open,
setOpen,
systemId,
monitor,
}: {
open: boolean
setOpen: (open: boolean) => void
systemId?: string
monitor?: NetworkMonitorRecord
}) {
const hasOpened = useRef(false)
if (!monitor && !hasOpened.current) {
return null
}
hasOpened.current = true
return (
<Dialog open={open} onOpenChange={setOpen}>
<MonitorDialogContent open={open} setOpen={setOpen} systemId={systemId} monitor={monitor} />
</Dialog>
)
}
function MonitorDialogContent({
open,
setOpen,
systemId,
monitor,
onOpenBulkAdd,
}: {
open: boolean
setOpen: (open: boolean) => void
systemId?: string
monitor?: NetworkMonitorRecord
onOpenBulkAdd?: (selectedSystemIds: Set<string>) => void
}) {
const [protocol, setProtocol] = useState<MonitorProtocol>(monitor?.protocol ?? "icmp")
const [target, setTarget] = useState(monitor?.target ?? "")
const [port, setPort] = useState(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
const [monitorInterval, setMonitorInterval] = useState(String(monitor?.interval ?? defaultInterval))
const [loading, setLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "")
const [selectedSystemIds, setSelectedSystemIds] = useState<Set<string>>(new Set())
const systems = useStore($systems)
const { toast } = useToast()
const { t } = useLingui()
const isEditing = !!monitor
// When the dialog is opened, initialize form fields with monitor values (if editing) or defaults (if adding).
useEffect(() => {
if (!open) {
return
}
setProtocol(monitor?.protocol ?? "icmp")
setTarget(monitor?.target ?? "")
setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
setMonitorInterval(String(monitor?.interval ?? defaultInterval))
setSelectedSystemId(monitor?.system ?? "")
setSelectedSystemIds(new Set())
setLoading(false)
}, [open, monitor])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
const targetSystems = systemId ? [systemId] : monitor ? [selectedSystemId] : Array.from(selectedSystemIds)
const remainingSystemIds = new Set(targetSystems)
try {
if (!targetSystems.length || !targetSystems[0]) throw new Error("Select at least one system.")
const payload = buildMonitorPayload(
{
system: targetSystems[0],
target,
protocol,
port: protocol === "tcp" ? Number(port) : 0,
interval: monitorInterval,
},
monitor ? monitor.enabled : true
)
if (monitor) {
await pb.collection("network_monitors").update(monitor.id, payload)
} else {
for (const system of targetSystems) {
await pb.collection("network_monitors").create({ ...payload, system })
remainingSystemIds.delete(system)
}
}
setOpen(false)
} catch (err: unknown) {
if (!monitor && !systemId) {
// Retain only unfinished systems so retrying cannot duplicate successful creates.
setSelectedSystemIds(remainingSystemIds)
}
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 Monitor` }}</Trans>
) : (
<Trans>Add {{ foo: t`Network Monitor` }}</Trans>
)}
</DialogTitle>
<DialogDescription>
<Trans>Configure response monitoring from this agent.</Trans>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
{!systemId && !isEditing && (
<div className="grid gap-2">
<Label htmlFor="monitor-systems">
<Trans>Systems</Trans>
</Label>
<SystemMultiSelect
id="monitor-systems"
selectedSystemIds={selectedSystemIds}
onChange={setSelectedSystemIds}
disabled={loading}
/>
</div>
)}
{!systemId && isEditing && (
<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
.filter((sys) => sys.id === monitor?.system || supportsNetworkMonitors(sys))
.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 MonitorProtocol)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="dns">DNS</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={monitorInterval}
onChange={(e) => setMonitorInterval(e.target.value)}
min={1}
max={3600}
required
/>
</div>
<DialogFooter>
{!isEditing && onOpenBulkAdd && (
<Button
type="button"
variant="outline"
onClick={() => onOpenBulkAdd(selectedSystemIds)}
disabled={loading}
className="me-auto"
>
<ListIcon className="size-4 me-2" />
<Trans>Bulk Add</Trans>
</Button>
)}
<Button
type="submit"
disabled={loading || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))}
>
{loading ? (
isEditing ? (
<Trans>Saving...</Trans>
) : (
<Trans>Creating...</Trans>
)
) : isEditing ? (
<Trans>Save {{ foo: t`Monitor` }}</Trans>
) : (
<Trans>Add {{ foo: t`Monitor` }}</Trans>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}
@@ -0,0 +1,411 @@
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,
RefreshCwIcon,
PenBoxIcon,
PauseCircleIcon,
PlayCircleIcon,
CopyIcon,
CopyPlusIcon,
} from "lucide-react"
import { t } from "@lingui/core/macro"
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
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 { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog"
import { Badge } from "../ui/badge"
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { pb } from "@/lib/api"
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",
dns: "bg-amber-500/15! text-amber-600 dark:text-amber-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 monitor is considered muted if it's disabled or if its associated system is not up.
*/
const isMuted = (record: NetworkMonitorRecord, systemRecord: SystemRecord | undefined) =>
!record.enabled || systemRecord?.status !== SystemStatus.Up
export function getMonitorColumns(
longestTarget = "",
{
onEdit,
onDelete,
onSetEnabled,
}: {
onEdit?: (monitor: NetworkMonitorRecord) => void
onDelete?: (monitors: NetworkMonitorRecord[]) => void | Promise<void>
onSetEnabled?: (monitors: NetworkMonitorRecord[], enabled: boolean) => void | Promise<void>
} = {}
): ColumnDef<NetworkMonitorRecord>[] {
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: "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.target.localeCompare(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) => getMonitorTarget(record),
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
cell: ({ row, getValue }) => {
const monitor = row.original
const { status } = useStore($allSystemsById)[monitor.system] || {}
let color = "bg-green-500"
if (!monitor.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-64 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">
{longestTarget}
</span>
<span className="absolute inset-0 truncate">{getValue() as string}</span>
</div>
</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((monitor) => monitor.enabled)
const bulkCopyContent = actionRows.map((monitor) => formatBulkMonitorLine(monitor)).join("\n")
const allSystems = useStore($allSystemsById)
const otherSystems = useMemo(
() => Object.values(allSystems).filter((s) => !isBulkAction && s.id !== row.original.system),
[allSystems, isBulkAction]
)
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>
{!isBulkAction && otherSystems.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CopyPlusIcon className="me-2.5 size-4" />
<Trans>Copy to system</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] overflow-y-auto">
{otherSystems.map((sys) => (
<DropdownMenuItem
key={sys.id}
onClick={() => {
const { id: _id, system: _system, ...rest } = row.original
pb.collection("network_monitors")
.create({ ...rest, system: sys.id })
.catch(() => {})
}}
>
{sys.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<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 },
dns: { warning: 150_000, critical: 800_000 },
}
function responseTimeCell(cell: CellContext<NetworkMonitorRecord, unknown>) {
const monitor = cell.row.original
const systemRecord = useStore($allSystemsById)[monitor.system]
const responseTime = cell.getValue() as number | undefined
if (!responseTime) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
const muted = isMuted(monitor, systemRecord)
let color = "bg-green-500"
if (muted) {
color = "bg-muted-foreground/50"
} else if (responseTime > responseTimeThresholds[monitor.protocol].warning) {
color = "bg-yellow-500"
}
if (!muted && responseTime > responseTimeThresholds[monitor.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<NetworkMonitorRecord>
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,575 @@
import { getMonitorTarget } from "@/lib/network-monitor-utils"
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 { getMonitorColumns } from "@/components/network-monitors-table/network-monitors-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, $direction, $userSettings } from "@/lib/stores"
import {
cn,
isVisuallyLonger,
matchesFilterGroups,
parseFilterGroups,
parseSemVer,
useBrowserStorage,
} from "@/lib/utils"
import type { ChartData, NetworkMonitorRecord } from "@/types"
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
import { ArrowLeftRightIcon, EthernetPortIcon, LoaderCircleIcon, 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/monitors-charts"
import { useNetworkMonitorStats } from "@/lib/use-network-monitors"
import { useStore } from "@nanostores/react"
import { atom } from "nanostores"
import { Separator } from "../ui/separator"
import { $router, Link } from "../router"
import { getPagePath } from "@nanostores/router"
export default function NetworkMonitorsTableNew({
systemId,
monitors,
isLoading,
}: {
systemId?: string
monitors: NetworkMonitorRecord[]
isLoading: boolean
}) {
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-np-target-${systemId ? 1 : 0}`,
[{ id: systemId ? "target" : "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 [editingMonitor, setEditingMonitor] = useState<NetworkMonitorRecord>()
const { toast } = useToast()
const canManageMonitors = !isReadOnlyUser()
const longestTarget = useMemo(() => {
let longestTarget = ""
for (const p of monitors) {
if (isVisuallyLonger(getMonitorTarget(p), longestTarget)) {
longestTarget = getMonitorTarget(p)
}
}
return longestTarget
}, [monitors])
const runMonitorBatch = 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 (monitorsToDelete: NetworkMonitorRecord[]) => {
if (!monitorsToDelete.length) {
return
}
const ids = monitorsToDelete.map((monitor) => monitor.id)
if (ids.length === 1) {
try {
await pb.collection("network_monitors").delete(ids[0])
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to delete monitors.`,
})
}
return
}
setPendingDeleteIds(ids)
setDeleteOpen(true)
},
[toast]
)
const handleBulkDelete = async () => {
setDeleteOpen(false)
if (!pendingDeleteIds.length) {
return
}
try {
await runMonitorBatch(pendingDeleteIds, (batch, id) => batch.collection("network_monitors").delete(id))
setPendingDeleteIds([])
setRowSelection({})
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to delete monitors.`,
})
}
}
const handleSetEnabled = useCallback(
async (monitorsToUpdate: NetworkMonitorRecord[], enabled: boolean) => {
if (!monitorsToUpdate.length) {
return
}
const pendingUpdates = monitorsToUpdate.filter((monitor) => monitor.enabled !== enabled)
if (!pendingUpdates.length) {
return
}
try {
if (pendingUpdates.length === 1) {
await pb.collection("network_monitors").update(pendingUpdates[0].id, { enabled })
return
}
await runMonitorBatch(
pendingUpdates.map((monitor) => monitor.id),
(batch, id) => batch.collection("network_monitors").update(id, { enabled })
)
if (monitorsToUpdate.length > 1) {
setRowSelection({})
}
} catch (err: unknown) {
toast({
variant: "destructive",
title: t`Error`,
description: (err as Error)?.message || t`Failed to update monitors.`,
})
}
},
[runMonitorBatch, toast]
)
const columns = useMemo(() => {
let columns = getMonitorColumns(longestTarget, {
onEdit: setEditingMonitor,
onDelete: handleDeleteRequest,
onSetEnabled: handleSetEnabled,
})
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
columns = canManageMonitors ? columns : columns.filter((col) => col.id !== "actions")
return columns
}, [canManageMonitors, handleDeleteRequest, handleSetEnabled, systemId, longestTarget])
const table = useReactTable({
data: monitors,
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 value = (filterValue as string).trim()
if (!value) return true
const monitor = row.original
const systemName = $allSystemsById.get()[monitor.system]?.name ?? ""
const searchString = `${getMonitorTarget(monitor)}${monitor.protocol}${systemName}`.toLocaleLowerCase()
return matchesFilterGroups(searchString, parseFilterGroups(value))
},
})
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 Monitors</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">
{monitors.length > 0 && (
<div className="relative">
<Input
placeholder={t`Filter...`}
title={t`Use commas to match any of multiple terms, e.g. "system1, system2"`}
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>
)}
{canManageMonitors ? <AddMonitorDialog systemId={systemId} monitors={monitors} /> : null}
{canManageMonitors ? (
<EditMonitorDialog
systemId={systemId}
monitor={editingMonitor}
open={!!editingMonitor}
setOpen={(open) => {
if (!open) {
setEditingMonitor(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">
<NetworkMonitorsTable
table={table}
rows={rows}
colLength={visibleColumns.length}
rowSelection={rowSelection}
isLoading={isLoading}
/>
</div>
</Card>
)
}
const NetworkMonitorsTable = memo(function NetworkMonitorTable({
table,
rows,
colLength,
rowSelection,
isLoading,
}: {
table: TableType<NetworkMonitorRecord>
rows: Row<NetworkMonitorRecord>[]
colLength: number
rowSelection: RowSelectionState
isLoading: boolean
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [activeMonitorId, setActiveMonitorId] = useState<string | null>(null)
const activeMonitor = activeMonitorId
? table.options.data.find((monitor) => monitor.id === activeMonitorId)
: undefined
const openSheet = useCallback((monitor: NetworkMonitorRecord) => {
setActiveMonitorId(monitor.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",
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<NetworkMonitorTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<NetworkMonitorTableRow
key={row.id}
row={row}
virtualRow={virtualRow}
isSelected={row.getIsSelected()}
rowSelection={rowSelection}
openSheet={openSheet}
/>
)
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
{isLoading ? (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
) : (
<Trans>No results.</Trans>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<NetworkMonitorSheet
open={sheetOpen}
onOpenChange={(nextOpen) => {
setSheetOpen(nextOpen)
}}
monitor={activeMonitor}
/>
</div>
)
})
function NetworkMonitorTableHead({ table }: { table: TableType<NetworkMonitorRecord> }) {
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 NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
row,
virtualRow,
isSelected,
rowSelection: _rowSelection,
openSheet,
}: {
row: Row<NetworkMonitorRecord>
virtualRow: VirtualItem
isSelected: boolean
// Menus depend on the entire selection, including changes to other rows.
rowSelection: RowSelectionState
openSheet: (monitor: NetworkMonitorRecord) => 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 NetworkMonitorSheet({
open,
onOpenChange,
monitor,
}: {
open: boolean
onOpenChange: (open: boolean) => void
monitor?: NetworkMonitorRecord
}) {
if (!monitor) {
return null
}
return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
}
function NetworkMonitorSheetContent({
open,
onOpenChange,
monitor,
}: {
open: boolean
onOpenChange: (open: boolean) => void
monitor: NetworkMonitorRecord
}) {
// Keep monitor exploration independent of the system charts' time range.
const [chartTimeStore] = useState(() => {
const defaultTime = $userSettings.get().chartTime
return atom(defaultTime === "1m" ? "1h" : defaultTime)
})
const chartTime = useStore(chartTimeStore)
const direction = useStore($direction)
const system = useStore($allSystemsById)[monitor.system]
const monitorStats = useNetworkMonitorStats({
systemId: monitor.system,
monitorId: monitor.id,
chartTime,
enabled: open,
})
const chartData = useMemo<ChartData>(
() => ({
agentVersion: parseSemVer(system?.info?.v),
orientation: direction === "rtl" ? "right" : "left",
chartTime,
}),
[system?.info?.v, direction, chartTime]
)
const hasMonitorStats = monitorStats.some((record) => record.stats?.[monitor.id] != null)
const monitorLabel = getMonitorTarget(monitor)
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>{monitorLabel}</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" />
{monitor.protocol.toUpperCase()}
{monitor.protocol === "tcp" && monitor.port > 0 && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<EthernetPortIcon className="size-3.5 text-muted-foreground" />
<span>{monitor.port}</span>
</>
)}
</SheetDescription>
</SheetHeader>
<div className="grid gap-4">
<ChartTimeSelect
className="bg-card"
agentVersion={chartData.agentVersion}
chartTimeStore={chartTimeStore}
allowRealtime={false}
/>
<AvgMinMaxResponseChart
monitorStats={monitorStats}
monitor={monitor}
chartData={chartData}
empty={!hasMonitorStats}
/>
<LossChart
monitorStats={monitorStats}
grid={false}
monitors={[monitor]}
chartData={chartData}
empty={!hasMonitorStats}
showFilter={false}
/>
</div>
</SheetContent>
</Sheet>
)
}
+1
View File
@@ -4,6 +4,7 @@ const routes = {
home: "/",
containers: "/containers",
smart: "/smart",
monitors: "/monitors",
system: `/system/:id`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,
@@ -0,0 +1,33 @@
import { useLingui } from "@lingui/react/macro"
import { memo, useEffect } from "react"
import NetworkMonitorsTableNew from "@/components/network-monitors-table/network-monitors-table"
import { ActiveAlerts } from "@/components/active-alerts"
import { FooterRepoLink } from "@/components/footer-repo-link"
import { useNetworkMonitors } from "@/lib/use-network-monitors"
import { $allSystemsById } from "@/lib/stores"
import { supportsNetworkMonitors } from "@/lib/utils"
import { useStore } from "@nanostores/react"
export default memo(() => {
const { t } = useLingui()
const { monitors, isLoading } = useNetworkMonitors({})
const systems = useStore($allSystemsById)
const visibleMonitors = monitors.filter((monitor) => {
const system = systems[monitor.system]
return !system || supportsNetworkMonitors(system)
})
useEffect(() => {
document.title = `${t`Network Monitors`} / Beszel`
}, [t])
return (
<>
<div className="grid gap-4">
<ActiveAlerts />
<NetworkMonitorsTableNew monitors={visibleMonitors} isLoading={isLoading} />
</div>
<FooterRepoLink />
</>
)
})
@@ -78,7 +78,7 @@ export default function AlertsHistoryDataTable() {
let unsubscribe: (() => void) | undefined
const pbOptions = {
expand: "system",
fields: "id,name,value,state,created,resolved,expand.system.name",
fields: "id,name,monitor_name,value,state,created,resolved,expand.system.name",
}
// Initial load
pb.collection<AlertsHistoryRecord>("alerts_history")
@@ -199,7 +199,7 @@ export default function AlertsHistoryDataTable() {
if (!selectedRows.length) return
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
system: (record) => record.expand?.system?.name || record.system,
name: (record) => alertInfo[record.name]?.name() || record.name,
name: (record) => [alertInfo[record.name]?.name() || record.name, record.monitor_name].filter(Boolean).join(": "),
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
created: (record) => formatShortDate(record.created),
+30 -6
View File
@@ -1,6 +1,6 @@
import { memo, useState } from "react"
import { Trans } from "@lingui/react/macro"
import { compareSemVer, parseSemVer } from "@/lib/utils"
import { compareSemVer, parseSemVer, supportsNetworkMonitors } from "@/lib/utils"
import type { GPUData } from "@/types"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import InfoBar from "./system/info-bar"
@@ -12,9 +12,15 @@ import { ZfsCharts } from "./system/charts/storage-pool-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyZfsTable } from "./system/lazy-tables"
import {
LazyContainersTable,
LazyNetworkMonitorsTable,
LazySmartTable,
LazySystemdTable,
LazyZfsTable,
} 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,9 +71,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasSystemd = system.info.sv
const hasGpu = hasGpuData || hasGpuPowerData
const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
const hasNetworkMonitors = supportsNetworkMonitors(system)
// 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")
@@ -153,6 +160,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
{hasSystemd && <LazySystemdTable systemId={system.id} />}
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
</>
)
}
@@ -165,6 +174,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>
@@ -192,9 +205,9 @@ 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} />
<FanChart {...coreProps} />
<BatteryChart system={system} {...coreProps} />
@@ -203,6 +216,17 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</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>
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
</>
)}
</TabsContent>
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
{mountedTabs.has("disk") && (
<>
@@ -44,6 +44,7 @@ export function FilterBar({ store = $containerFilter }: { store?: typeof $contai
<>
<Input
placeholder={t`Filter...`}
title={t`Use commas to match any of multiple terms, e.g. "system1, system2"`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={inputValue}
@@ -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,
NetworkMonitorStatsRecord,
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 | NetworkMonitorStatsRecord>(
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
}
@@ -0,0 +1,212 @@
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import LineChartDefault from "@/components/charts/line-chart"
import type { DataPoint } from "@/components/charts/line-chart"
import { decimalString, formatMicroseconds, matchesFilterGroups, parseFilterGroups, toFixedFloat } from "@/lib/utils"
import { $monitorFilter } from "@/lib/stores"
import { useLingui } from "@lingui/react/macro"
import { ChartCard, FilterBar } from "../chart-card"
import type { ChartData, MonitorStats, NetworkMonitorRecord, NetworkMonitorStatsRecord } from "@/types"
import { useMemo } from "react"
import { useStore } from "@nanostores/react"
type MonitorChartProps = {
monitorStats: NetworkMonitorStatsRecord[]
grid?: boolean
monitors: NetworkMonitorRecord[]
chartData: ChartData
empty: boolean
showFilter?: boolean
/** Prepended to the chart title, e.g. a target/system name (rendered as "{titlePrefix} — Response"). */
titlePrefix?: string
}
type MonitorChartBaseProps = MonitorChartProps & {
metric: keyof MonitorStats
title: string
description: string
tickFormatter: (value: number) => string
contentFormatter: ({ value }: { value: number | string }) => string | number
domain?: [number | "auto", number | "auto"]
}
function MonitorChart({
monitorStats,
grid,
monitors,
chartData,
empty,
metric,
title,
description,
tickFormatter,
contentFormatter,
domain,
showFilter = monitors.length > 1,
}: MonitorChartBaseProps) {
const storedFilter = useStore($monitorFilter)
const filter = showFilter ? storedFilter : ""
const { dataPoints, visibleKeys } = useMemo(() => {
const sortedMonitors = [...monitors].sort((a, b) => b.resAvg1h - a.resAvg1h)
const count = sortedMonitors.length
const points: DataPoint<NetworkMonitorStatsRecord>[] = []
const visibleIDs: string[] = []
const filterGroups = parseFilterGroups(filter)
const dot = chartData.chartTime === "1m"
for (let i = 0; i < count; i++) {
const p = sortedMonitors[i]
const label = getMonitorTarget(p)
const labelLower = label.toLowerCase()
const filtered = filterGroups.length > 0 && !matchesFilterGroups(labelLower, filterGroups)
if (filtered) {
continue
}
visibleIDs.push(p.id)
points.push({
order: i,
label,
dataKey: (record: NetworkMonitorStatsRecord) => record.stats?.[p.id]?.[metric] ?? null,
dot,
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
})
}
return { dataPoints: points, visibleKeys: visibleIDs }
}, [monitors, filter, metric, chartData.chartTime])
const filteredMonitorStats = useMemo(() => {
if (!visibleKeys.length) return monitorStats
return monitorStats.filter((record) => visibleKeys.some((id) => record.stats?.[id] != null))
}, [monitorStats, visibleKeys])
const legend = dataPoints.length < 10 && showFilter
return (
<ChartCard
legend={legend || !showFilter}
cornerEl={showFilter ? <FilterBar store={$monitorFilter} /> : undefined}
empty={empty}
title={title}
description={description}
grid={grid}
>
<LineChartDefault
truncate
chartData={chartData}
customData={filteredMonitorStats}
dataPoints={dataPoints}
domain={domain ?? ["auto", "auto"]}
connectNulls
tickFormatter={tickFormatter}
contentFormatter={contentFormatter}
legend={legend}
filter={filter}
/>
</ChartCard>
)
}
interface AvgMinMaxResponseChartProps {
monitorStats: NetworkMonitorStatsRecord[]
monitor: NetworkMonitorRecord | null
chartData: ChartData
empty: boolean
}
export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty }: AvgMinMaxResponseChartProps) {
const { t } = useLingui()
const { chartTime } = chartData
const hasLongInterval = (monitor?.interval ?? 61) > 60
// only one monitor is relevant for this chart
const dataPoints: DataPoint<NetworkMonitorStatsRecord>[] = useMemo(() => {
const dataFn = (metric: keyof MonitorStats) => (record: NetworkMonitorStatsRecord) =>
record.stats?.[monitor?.id ?? ""]?.[metric] ?? "-"
const avgPoint = {
label: "Avg",
dataKey: dataFn("res_avg"),
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("res_max"),
color: 3,
order: 0,
},
avgPoint,
{
label: "Min",
dataKey: dataFn("res_min"),
color: 2,
order: 2,
},
]
}, [chartTime, hasLongInterval, monitor?.id])
const data = useMemo(() => {
if (!monitor) return []
return monitorStats.filter((record) => record.stats && monitor.id in record.stats)
}, [monitor, monitorStats])
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({ monitorStats, grid, monitors, chartData, empty, titlePrefix }: MonitorChartProps) {
const { t } = useLingui()
const lossTitle = t`Loss`
const title = titlePrefix ? `${titlePrefix} — ${lossTitle}` : lossTitle
return (
<MonitorChart
monitorStats={monitorStats}
grid={grid}
monitors={monitors}
chartData={chartData}
empty={empty}
metric="loss"
title={title}
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)}%`
}}
/>
)
}
@@ -1,6 +1,7 @@
import { lazy } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
import { useNetworkMonitors } from "@/lib/use-network-monitors"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
@@ -45,3 +46,19 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
</div>
)
}
const NetworkMonitorsTable = lazy(() => import("../../network-monitors-table/network-monitors-table"))
export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SystemNetworkMonitorsTable systemId={systemId} />}
</div>
)
}
function SystemNetworkMonitorsTable({ systemId }: { systemId: string }) {
const { monitors, isLoading } = useNetworkMonitors({ systemId })
return <NetworkMonitorsTable systemId={systemId} monitors={monitors} isLoading={isLoading} />
}
@@ -43,7 +43,6 @@ import {
toFixedFloat,
formatTemperature,
cn,
getVisualStringWidth,
secondsToString,
hourWithSeconds,
formatShortDate,
@@ -117,9 +116,9 @@ function formatDataUnits(units: 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",
@@ -134,8 +133,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>
)
},
@@ -145,12 +147,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>
),
},
@@ -161,12 +162,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>
),
},
@@ -320,7 +320,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
}
@@ -329,10 +329,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>
@@ -185,16 +185,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])
@@ -234,8 +229,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]) => {
// Ignore responses for a previous system or chart time
if (requestId !== statsRequestId.current) {
@@ -327,7 +322,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,
@@ -135,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 (
@@ -145,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
@@ -157,7 +156,10 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
}
}}
>
{name}
<span className="invisible block" aria-hidden="true">
{longestName}
</span>
<span className="absolute inset-0 truncate">{name}</span>
</Link>
</span>
<Link href={linkUrl} className="inset-0 absolute size-full" aria-label={name}></Link>
+66 -24
View File
@@ -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,
@@ -222,6 +228,11 @@ const ChartTooltipContent = React.forwardRef<
return null
}
payload = payload.filter((item) => item.value != null)
if (!payload.length) {
return null
}
// const nestLabel = payload.length === 1 && indicator !== 'dot'
const nestLabel = false
@@ -400,26 +411,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 {
@@ -90,8 +90,10 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
indicatorClassName?: string
}
>(({ className, children, checked, indicatorClassName, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
@@ -101,7 +103,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className={cn("absolute left-2 flex h-3.5 w-3.5 items-center justify-center", indicatorClassName)}>
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
+1 -1
View File
@@ -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}
+204 -191
View File
@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro"
import { ContainerIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, ServerCrashIcon, ServerIcon } from "lucide-react"
import { ContainerIcon, CpuIcon, HardDriveIcon, MemoryStickIcon, NetworkIcon, ServerCrashIcon, ServerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores"
@@ -9,210 +9,223 @@ import { ThermometerIcon, BatteryMediumIcon, HourglassIcon } from "@/components/
/** Alert info for each alert type */
export const alertInfo: Record<string, AlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
CPUIOWait: {
name: () => t`CPU I/O Wait`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU I/O wait exceeds a threshold`,
},
CPUSteal: {
name: () => t`CPU Steal Time`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU steal time exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250,
},
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
Battery: {
name: () => t`Battery`,
unit: "%",
icon: BatteryMediumIcon,
desc: () => t`Triggers when battery charge drops below a threshold`,
start: 20,
invert: true,
},
ContainerHealth: {
name: () => t`Container Health`,
unit: "",
icon: ContainerIcon,
desc: () => t`Triggers when a container's health check reports unhealthy`,
note: () =>
t`Notifications may include recent container log excerpts.`,
triggeredDesc: () => t`One or more containers are unhealthy`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when status switches between up and down`,
/** "for x minutes" is appended to desc when only one value */
singleDesc: () => `${t`System`} ${t`Down`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
CPUIOWait: {
name: () => t`CPU I/O Wait`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU I/O wait exceeds a threshold`,
},
CPUSteal: {
name: () => t`CPU Steal Time`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU steal time exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Disk: {
name: () => t`Disk Usage`,
unit: "%",
icon: HardDriveIcon,
desc: () => t`Triggers when usage of any disk exceeds a threshold`,
},
Bandwidth: {
name: () => t`Bandwidth`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250,
},
NetworkMonitorLoss: {
name: () => t`Network Monitor Loss`,
unit: "%",
icon: NetworkIcon,
desc: () => t`Triggers when one hour loss exceeds a threshold`,
// note: () => t`Uses available history after three probes.`,
noDuration: true,
min: 0,
max: 99.9,
step: 0.1,
start: 5,
},
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",
icon: ThermometerIcon,
desc: () => t`Triggers when any sensor exceeds a threshold`,
},
LoadAvg1: {
name: () => t`Load Average 1m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 1 minute load average exceeds a threshold`,
},
LoadAvg5: {
name: () => t`Load Average 5m`,
unit: "",
icon: HourglassIcon,
max: 100,
min: 0.1,
start: 10,
step: 0.1,
desc: () => t`Triggers when 5 minute load average exceeds a threshold`,
},
LoadAvg15: {
name: () => t`Load Average 15m`,
unit: "",
icon: HourglassIcon,
min: 0.1,
max: 100,
start: 10,
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
Battery: {
name: () => t`Battery`,
unit: "%",
icon: BatteryMediumIcon,
desc: () => t`Triggers when battery charge drops below a threshold`,
start: 20,
invert: true,
},
ContainerHealth: {
name: () => t`Container Health`,
unit: "",
icon: ContainerIcon,
desc: () => t`Triggers when a container's health check reports unhealthy`,
note: () =>
t`Notifications may include recent container log excerpts.`,
triggeredDesc: () => t`One or more containers are unhealthy`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
},
SystemdFailed: {
name: () => t`Failed Services`,
unit: "",
icon: ServerCrashIcon,
desc: () => t`Triggers when any systemd service enters the failed state`,
triggeredDesc: () => t`One or more services are in a failed state`,
/** Fires on first observation - the agent only polls systemd every 10 minutes */
noDuration: true,
},
},
SystemdFailed: {
name: () => t`Failed Services`,
unit: "",
icon: ServerCrashIcon,
desc: () => t`Triggers when any systemd service enters the failed state`,
triggeredDesc: () => t`One or more services are in a failed state`,
/** Fires on first observation - the agent only polls systemd every 10 minutes */
noDuration: true,
noThreshold: true,
},
} as const
/** Helper to manage user alerts */
export const alertManager = (() => {
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
const collection = pb.collection<AlertRecord>("alerts")
let unsub: () => void
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
async function subscribe() {
unsub = await collection.subscribe("*", batchUpdate, { fields })
}
function unsubscribe() {
unsub?.()
}
function unsubscribe() {
unsub?.()
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
async function refresh() {
const records = await fetchAlerts()
add(records)
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Subscribe to alerts */
subscribe,
/** Unsubscribe from alerts */
unsubscribe,
/** Refresh alerts with latest data from hub */
refresh,
}
})()
+4 -1
View File
@@ -106,8 +106,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")
@@ -0,0 +1,17 @@
import type { MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
return {
res_avg: record.success_count > 0 ? record.res_sum / record.success_count : 0,
res_min: record.res_min,
res_max: record.res_max,
loss: record.total_count > 0 ? ((record.total_count - record.success_count) / record.total_count) * 100 : 0,
}
}
export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" | "protocol" | "port">) {
if (monitor.protocol !== "tcp") return monitor.target
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
return `${host}:${monitor.port}`
}
+5 -4
View File
@@ -64,6 +64,9 @@ export const $containerFilter = atom("")
/** Temperature chart filter */
export const $temperatureFilter = atom("")
/** Filter for network monitor charts (compare page and per-system monitor charts) */
export const $monitorFilter = atom("")
/** Fan-speed chart filter */
export const $fanFilter = atom("")
@@ -73,7 +76,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("")
+19 -14
View File
@@ -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)
@@ -0,0 +1,351 @@
import { chartTimeData } from "@/lib/utils"
import { getMonitorStats } from "@/lib/network-monitor-utils"
import type {
ChartTimes,
MonitorStats,
NetworkMonitorRecord,
NetworkMonitorStatsRecord,
RawMonitorStatsRecord,
} from "@/types"
import { useEffect, useRef, useState } from "react"
import { appendData } from "@/components/routes/system/chart-data"
import { pb, getPbTimestamp } from "@/lib/api"
import { toast } from "@/components/ui/use-toast"
import type { RecordListOptions, RecordSubscription } from "pocketbase"
const cache = new Map<string, NetworkMonitorStatsRecord[]>()
function getCacheValue(monitorId: string, chartTime: ChartTimes | "rt") {
return cache.get(`${monitorId}:${chartTime}`) || []
}
function appendCacheValue(
monitorId: string,
chartTime: ChartTimes | "rt",
newStats: NetworkMonitorStatsRecord[],
maxPoints = 100
) {
const cache_key = `${monitorId}:${chartTime}`
const existingStats = getCacheValue(monitorId, 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
}
}
/** Merge an array of per-monitor raw records into the map-keyed format expected by chart components. */
export function mergeMonitorStats(rawRecords: RawMonitorStatsRecord[]): NetworkMonitorStatsRecord[] {
const byTimestamp = new Map<number, Record<string, MonitorStats>>()
for (const rec of rawRecords) {
let statsMap = byTimestamp.get(rec.created)
if (!statsMap) {
statsMap = {}
byTimestamp.set(rec.created, statsMap)
}
statsMap[rec.monitor] = getMonitorStats(rec)
}
return Array.from(byTimestamp.entries())
.sort(([a], [b]) => a - b)
.map(([created, stats]) => ({ created, stats }))
}
/** Fetch stats for one monitor and time range, returning merged chart records. */
async function fetchMonitorStats(
monitorId: string,
chartTime: ChartTimes,
cached?: NetworkMonitorStatsRecord[]
): Promise<NetworkMonitorStatsRecord[]> {
const lastCached = cached?.at(-1)?.created as number | undefined
const rawRecords = await pb.collection<RawMonitorStatsRecord>("network_monitor_stats").getFullList({
filter: pb.filter("monitor={:id} && created>{:created} && type={:type}", {
id: monitorId,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined, true),
type: chartTimeData[chartTime].type,
}),
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created",
sort: "created",
})
return mergeMonitorStats(rawRecords)
}
const NETWORK_MONITOR_FIELDS =
"id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
interface UseNetworkMonitorsProps {
systemId?: string
}
export function useNetworkMonitors(props: UseNetworkMonitorsProps) {
const { systemId } = props
const [monitors, setMonitors] = useState<NetworkMonitorRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const pendingMonitorEvents = useRef(new Map<string, RecordSubscription<NetworkMonitorRecord>>())
const monitorBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
// initial load
useEffect(() => {
let cancelled = false
setIsLoading(true)
setMonitors([])
fetchMonitors(systemId).then((monitors) => {
if (cancelled) return
setMonitors(monitors)
setIsLoading(false)
})
return () => {
cancelled = true
}
}, [systemId])
// subscribe to updates
useEffect(() => {
let unsubscribe: (() => void) | undefined
function flushPendingMonitorEvents() {
monitorBatchTimeout.current = null
if (!pendingMonitorEvents.current.size) {
return
}
const events = pendingMonitorEvents.current
pendingMonitorEvents.current = new Map()
setMonitors((currentMonitors) => {
return applyMonitorEvents(currentMonitors ?? [], events.values(), systemId)
})
}
const pbOptions: RecordListOptions = { fields: NETWORK_MONITOR_FIELDS }
if (systemId) {
pbOptions.filter = pb.filter("system = {:system}", { system: systemId })
}
;(async () => {
try {
unsubscribe = await pb.collection<NetworkMonitorRecord>("network_monitors").subscribe(
"*",
(event) => {
pendingMonitorEvents.current.set(event.record.id, event)
if (!monitorBatchTimeout.current) {
monitorBatchTimeout.current = setTimeout(flushPendingMonitorEvents, 50)
}
},
pbOptions
)
} catch (error) {
console.error("Failed to subscribe to monitors", error)
}
})()
return () => {
if (monitorBatchTimeout.current !== null) {
clearTimeout(monitorBatchTimeout.current)
monitorBatchTimeout.current = null
}
pendingMonitorEvents.current.clear()
unsubscribe?.()
}
}, [systemId])
return { monitors, isLoading }
}
interface UseNetworkMonitorStatsProps {
systemId: string
monitorId: string
chartTime: ChartTimes
enabled?: boolean
}
export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
const { systemId, monitorId, chartTime, enabled = true } = props
const [monitorStats, setMonitorStats] = useState<NetworkMonitorStatsRecord[]>([])
// pending raw events to be merged (keyed by monitor+created)
const pendingRaw = useRef(new Map<string, RawMonitorStatsRecord>())
const mergeBatchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setMonitorStats(getCacheValue(monitorId, chartTime === "1m" ? "rt" : chartTime))
}, [monitorId, chartTime])
// Fetch only the selected monitor's missing history.
useEffect(() => {
if (!enabled || chartTime === "1m") {
return
}
let cancelled = false
const { expectedInterval } = chartTimeData[chartTime]
const cachedMonitorStats = getCacheValue(monitorId, chartTime)
if (cachedMonitorStats.length) {
setMonitorStats(cachedMonitorStats)
const lastCreated = cachedMonitorStats.at(-1)?.created
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
return
}
}
fetchMonitorStats(monitorId, chartTime, cachedMonitorStats)
.then((newMonitorStats) => {
if (cancelled) return
setMonitorStats(appendCacheValue(monitorId, chartTime, newMonitorStats))
})
.catch((error) => {
if (!cancelled) console.error("Failed to fetch monitor stats:", error)
})
return () => {
cancelled = true
}
}, [monitorId, chartTime, enabled])
// subscribe to new per-monitor stats records; batch them into merged chart records
useEffect(() => {
if (!enabled || chartTime === "1m") {
return
}
let cancelled = false
let unsubscribe: (() => void) | undefined
const pbOptions = {
fields: "monitor,res_min,res_max,total_count,success_count,res_sum,created,type",
filter: pb.filter("monitor={:monitor} && type={:type}", {
monitor: monitorId,
type: chartTimeData[chartTime].type,
}),
}
function flushPending() {
mergeBatchTimeout.current = null
const pending = pendingRaw.current
pendingRaw.current = new Map()
const merged = mergeMonitorStats(Array.from(pending.values()))
if (merged.length > 0) {
const newStats = appendCacheValue(monitorId, chartTime, merged)
setMonitorStats(newStats)
}
}
;(async () => {
try {
unsubscribe = await pb.collection<RawMonitorStatsRecord>("network_monitor_stats").subscribe(
"*",
(event) => {
if (cancelled || event.action !== "create") {
return
}
const rec = event.record
pendingRaw.current.set(`${rec.monitor}:${rec.created}`, rec)
if (!mergeBatchTimeout.current) {
mergeBatchTimeout.current = setTimeout(flushPending, 200)
}
},
pbOptions
)
if (cancelled) unsubscribe()
} catch (error) {
console.error("Failed to subscribe to monitor stats:", error)
}
})()
return () => {
cancelled = true
if (mergeBatchTimeout.current) {
clearTimeout(mergeBatchTimeout.current)
mergeBatchTimeout.current = null
}
pendingRaw.current.clear()
unsubscribe?.()
}
}, [monitorId, chartTime, enabled])
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
if (!enabled || chartTime !== "1m") {
return
}
let cancelled = false
let unsubscribe: (() => void) | undefined
pb.realtime
.subscribe(
`rt_metrics`,
(data: { Monitors: NetworkMonitorStatsRecord["stats"] }) => {
const monitorStats = data.Monitors?.[monitorId]
if (cancelled || !monitorStats) return
const stats = { created: Date.now(), stats: { [monitorId]: monitorStats } }
const newStats = appendCacheValue(monitorId, "rt", [stats], 120)
setMonitorStats(newStats)
},
{ query: { system: systemId } }
)
.then((us) => {
unsubscribe = us
if (cancelled) unsubscribe()
})
return () => {
cancelled = true
unsubscribe?.()
}
}, [chartTime, systemId, monitorId, enabled])
return monitorStats
}
async function fetchMonitors(system?: string) {
try {
return await pb.collection<NetworkMonitorRecord>("network_monitors").getFullList({
fields: NETWORK_MONITOR_FIELDS,
filter: system ? pb.filter("system={:system}", { system }) : undefined,
})
} catch (error) {
toast({
title: "Error",
description: (error as Error)?.message,
variant: "destructive",
})
return []
}
}
function applyMonitorEvents(
monitors: NetworkMonitorRecord[],
events: Iterable<RecordSubscription<NetworkMonitorRecord>>,
systemId?: string
) {
const monitorById = new Map(monitors.map((monitor) => [monitor.id, monitor]))
const createdMonitors: NetworkMonitorRecord[] = []
for (const { action, record } of events) {
const matchesSystemScope = !systemId || record.system === systemId
if (action === "delete" || !matchesSystemScope) {
monitorById.delete(record.id)
continue
}
if (!monitorById.has(record.id)) {
createdMonitors.push(record)
}
monitorById.set(record.id, record)
}
const nextMonitors: NetworkMonitorRecord[] = []
for (let index = createdMonitors.length - 1; index >= 0; index -= 1) {
nextMonitors.push(createdMonitors[index])
}
for (const monitor of monitors) {
const nextMonitor = monitorById.get(monitor.id)
if (!nextMonitor) {
continue
}
nextMonitors.push(nextMonitor)
monitorById.delete(monitor.id)
}
return nextMonitors
}
+75 -11
View File
@@ -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)
@@ -393,6 +414,12 @@ export function compareSemVer(a: SemVer, b: SemVer) {
return a.patch - b.patch
}
const MIN_NETWORK_MONITOR_AGENT_VERSION = parseSemVer("0.20.0")
export function supportsNetworkMonitors(system: Pick<SystemRecord, "info">) {
return compareSemVer(parseSemVer(system.info?.v), MIN_NETWORK_MONITOR_AGENT_VERSION) >= 0
}
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow any function to be passed in
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
@@ -422,10 +449,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 +482,41 @@ 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)
}
/** Parses a filter string into OR'd groups of AND'd terms: "a b, c" -> [["a","b"], ["c"]] */
export function parseFilterGroups(value: string): string[][] {
return value
.toLowerCase()
.split(",")
.map((group) => group.trim().split(" ").filter((term) => term.length > 0))
.filter((terms) => terms.length > 0)
}
/** True if every term in at least one OR'd group is found in searchString. */
export function matchesFilterGroups(searchString: string, groups: string[][]): boolean {
return groups.some((terms) => terms.every((term) => searchString.includes(term)))
}
/** 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 +533,4 @@ export function secondsToUptimeString(seconds: number): string {
} else {
return secondsToString(seconds, "day")
}
}
}
+204 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ar\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:22\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"
@@ -30,6 +30,12 @@ msgstr "{0} متاح"
msgid "{0} of {1} row(s) selected."
msgstr "تم تحديد {0} من {1} صف"
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "تم تحديد {0}"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# نواة} other {# نواة}}"
@@ -121,6 +127,10 @@ msgstr "الحالة النشطة"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "جميع الأنظمة"
msgid "Are you sure you want to delete {name}?"
msgstr "هل أنت متأكد أنك تريد حذف {name}؟"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "هل أنت متأكد؟"
@@ -245,6 +256,14 @@ msgstr "متوسط ​استخدام {0}"
msgid "Average utilization of GPU engines"
msgstr "متوسط استغلال محركات GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "متوسط زمن الاستجابة والأدنى والأقصى"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "متوسط 1h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "بت (كيلوبت/ثانية، ميجابت/ثانية، جيجابت/
msgid "Boot state"
msgstr "حالة التمهيد"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "إضافة جماعية"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "إضافة جماعية {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "نسخ جماعي"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "يمكن البدء"
msgid "Can stop"
msgstr "يمكن الإيقاف"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "أخطاء المجموع الاختباري"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "أخطاء المجموع الاختباري"
msgid "Clear"
msgstr "مسح"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "مسح الكل"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "مسح النتائج المطابقة"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "انقر على حاوية لعرض مزيد من المعلومات."
@@ -432,6 +474,10 @@ msgstr "تعليمات سطر الأوامر"
msgid "Configure how you receive alert notifications."
msgstr "هيئ التنبيهات الواردة"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "تكوين مراقبة الاستجابة من هذا الوكيل."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "صحة الحاوية"
msgid "Containers"
msgstr "حاويات"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "انسخ أمر التثبيت للوكيل أدناه، أو سجل ا
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "انسخ محتوى <0>docker-compose.yml</0> للوكيل أدناه، أو سجل الوكلاء تلقائياً باستخدام <1>رمز مميز عالمي</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "نسخ إلى النظام"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "نسخ YAML"
@@ -630,6 +681,7 @@ msgstr "افتراضي"
msgid "Default time period"
msgstr "الفترة الزمنية الافتراضية"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "تنزيل"
msgid "Duration"
msgstr "المدة"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "تعديل"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "إضافة {foo}"
@@ -772,6 +826,11 @@ msgstr "أدخل كلمة المرور لمرة واحدة الخاصة بك."
msgid "Ephemeral"
msgstr "مؤقت"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -844,6 +903,11 @@ msgstr "الخدمات الفاشلة"
msgid "Failed to authenticate"
msgstr "فشل في المصادقة"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "فشل حذف أجهزة المراقبة."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "فشل في إرسال إشعار الاختبار"
msgid "Failed to update alert"
msgstr "فشل في تحديث التنبيه"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "فشل تحديث أجهزة المراقبة."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "المراوح"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "صورة"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "يتوفر تحديث للصورة"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "غير نشط"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "الفاصل الزمني"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "الفاصل الزمني (بالثواني)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "عنوان البريد الإشباكي غير صالح."
@@ -1097,6 +1177,15 @@ msgstr "السجلات"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "هل تبحث عن مكان لإنشاء التنبيهات؟ انقر على أيقونات الجرس <0/> في جدول الأنظمة."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "الفقد"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "الفقد 1h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "معرف العملية الرئيسي"
@@ -1115,6 +1204,10 @@ msgstr "تعليمات الإعداد اليدوي"
msgid "Max 1 min"
msgstr "الحد الأقصى دقيقة"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "الأقصى 1h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "استخدام الذاكرة"
msgid "Memory usage of containers"
msgstr "استخدام الذاكرة للحاويات"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "الأدنى 1h"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "الموديل"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "مراقب"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "تم إنشاء أجهزة المراقبة"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "نقطة الربط"
@@ -1163,6 +1270,29 @@ msgstr "الاسم"
msgid "Net"
msgstr "الشبكة"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "الشبكة"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "مراقب الشبكة"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "فقدان مراقب الشبكة"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "مراقبو الشبكة"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "حركة مرور الشبكة للحاويات"
@@ -1197,6 +1327,7 @@ msgstr "لم يتم العثور على نتائج."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "لا توجد نتائج."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "لا توجد سمات S.M.A.R.T. متاحة لهذا الجهاز."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة ف
msgid "One or more containers are unhealthy"
msgstr "حاوية واحدة أو أكثر غير سليمة"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "واحد أو أكثر من أجهزة المراقبة يتجاوز فقدان {0}٪"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "خدمة واحدة أو أكثر في حالة فشل"
@@ -1251,6 +1388,7 @@ msgstr "مرة واحدة"
msgid "One-time password"
msgstr "كلمة مرور لمرة واحدة"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "أخرى"
msgid "Overwrite existing alerts"
msgstr "الكتابة فوق التنبيهات الحالية"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "فقدان الحزم (٪)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "تم استلام طلب إعادة تعيين كلمة المرور"
msgid "Past"
msgstr "الماضي"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "إيقاف مؤقت"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "استخدام مجموعة التخزين"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr "المنفذ"
@@ -1418,6 +1563,11 @@ msgstr "اللغة المفضلة"
msgid "Process started"
msgstr "تم بدء العملية"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "البروتوكول"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "المفتاح العام"
@@ -1503,10 +1653,20 @@ msgstr "إعادة تعيين كلمة المرور"
msgid "Resolved"
msgstr "تم حلها"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "الاستجابة"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "مراقبة زمن الاستجابة من الوكلاء."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "إعادة التشغيل"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "استئناف"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "اختبار S.M.A.R.T. الذاتي"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "حفظ {foo}"
@@ -1575,6 +1736,11 @@ msgstr "بحث"
msgid "Search for systems or settings..."
msgstr "البحث عن الأنظمة أو الإعدادات..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "البحث عن الأنظمة"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "ثواني بين ال pings (الافتراضي: 60)"
@@ -1587,6 +1753,27 @@ msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفي
msgid "Select {foo}"
msgstr "تحديد {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "تحديد نظام"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "تحديد الكل"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "تحديد النتائج المطابقة"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "تحديد الصف"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "تحديد الأنظمة"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "أرسل ping نبضة قلب واحدة للتحقق من أن نقطة النهاية الخاصة بك تعمل."
@@ -1692,6 +1879,8 @@ msgstr "تبديل السمة"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "خدمات systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "الأنظمة"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "تبويبات"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "الهدف"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "المهام"
@@ -1779,6 +1975,7 @@ msgstr "ثم قم بتسجيل الدخول إلى الواجهة الخلفية
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذلك إلى حذف جميع السجلات الحالية لـ {name} من قاعدة البيانات بشكل دائم."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة من قاعدة البيانات بشكل دائم."
@@ -1901,6 +2098,10 @@ msgstr "يتم التفعيل عندما يتجاوز استخدام وحدة م
msgid "Triggers when memory usage exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز استخدام الذاكرة عتبة معينة"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز فقدان الساعة الواحدة حدا معينا"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "يتم التفعيل عندما يتغير الحالة بين التشغيل والإيقاف"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "تحديث"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "نعم"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "تم تحديث إعدادات المستخدم الخاصة بك."
+206 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: bg\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Bulgarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "Версия {0} е налична"
msgid "{0} of {1} row(s) selected."
msgstr "{0} от {1} селектирани."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} избрани"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# ядро} other {# ядра}}"
@@ -121,6 +127,10 @@ msgstr "Активно състояние"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "Всички системи"
msgid "Are you sure you want to delete {name}?"
msgstr "Сигурен ли си, че искаш да изтриеш {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Сигурни ли сте?"
@@ -245,6 +256,14 @@ msgstr "Средно използване на {0}"
msgid "Average utilization of GPU engines"
msgstr "Средно използване на GPU двигатели"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Средно, минимално и максимално време за отговор"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Средно 1h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "Бита (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Състояние при зареждане"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Групово добавяне"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Групово добавяне {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Групово копиране"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "Може да се стартира"
msgid "Can stop"
msgstr "Може да се спре"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Грешки в контролната сума"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Грешки в контролната сума"
msgid "Clear"
msgstr "Изчисти"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Изчисти всичко"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Изчисти съвпаденията"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Кликнете върху контейнер, за да видите повече информация."
@@ -432,6 +474,10 @@ msgstr "Инструкции за командната линия"
msgid "Configure how you receive alert notifications."
msgstr "Настрой как получаваш нотификации за тревоги."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Конфигуриране на мониторинг на отговора от този агент."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Здраве на контейнера"
msgid "Containers"
msgstr "Контейнери"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Копирайте командата за инсталиране на
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Копирайте съдържанието на<0>docker-compose.yml</0> за агента по-долу или регистрирайте агентите автоматично с <1>универсален токен</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Копирай в система"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Копирай YAML"
@@ -630,6 +681,7 @@ msgstr "Подредба"
msgid "Default time period"
msgstr "Времеви диапазон по подразбиране"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "Изтегляне"
msgid "Duration"
msgstr "Продължителност"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Редактирай"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Редактиране на {foo}"
@@ -772,6 +826,11 @@ msgstr "Въведете Вашата еднократна парола."
msgid "Ephemeral"
msgstr "Ефимерен"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -844,6 +903,11 @@ msgstr "Неуспешни услуги"
msgid "Failed to authenticate"
msgstr "Неуспешно удостоверяване"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Неуспешно изтриване на монитори."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Неуспешно изпрати тестова нотификация"
msgid "Failed to update alert"
msgstr "Неуспешно обнови тревога"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Неуспешно обновяване на монитори."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Вентилатори"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -928,7 +997,7 @@ msgstr "Глобален"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -954,7 +1023,7 @@ msgstr "Здраве"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr ""
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Образ"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Налична актуализация на образа"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Неактивен"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Интервал"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Интервал (секунди)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Невалиден имейл адрес."
@@ -1097,6 +1177,15 @@ msgstr "Логове"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Търсиш къде да създадеш тревоги? Натисни емотиконата за звънец <0/> в таблицата за системи."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Загуба"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Загуба 1h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "Главен PID"
@@ -1115,6 +1204,10 @@ msgstr "Инструкции за ръчна настройка"
msgid "Max 1 min"
msgstr "Максимум 1 минута"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Макс. 1h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "Употреба на паметта"
msgid "Memory usage of containers"
msgstr "Използване на паметта от контейнерите"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Мин. 1h"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Модел"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Монитор"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Мониторите са създадени"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Точка на монтиране"
@@ -1163,6 +1270,29 @@ msgstr "Име"
msgid "Net"
msgstr "Мрежа"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Мрежа"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Мрежов монитор"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Загуба на мрежов монитор"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Мрежови монитори"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Мрежов трафик на контейнерите"
@@ -1197,6 +1327,7 @@ msgstr "Няма намерени резултати."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Няма резултати."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Няма налични S.M.A.R.T. атрибути за това устройство."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "На всеки рестарт, системите в датабазат
msgid "One or more containers are unhealthy"
msgstr "Един или повече контейнери са в неизправно състояние"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Един или повече монитори надвишават {0}% загуба"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Една или повече услуги са в състояние на грешка"
@@ -1251,6 +1388,7 @@ msgstr "Еднократен"
msgid "One-time password"
msgstr "Еднократна парола"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Други"
msgid "Overwrite existing alerts"
msgstr "Презапиши съществуващи тревоги"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Загуба на пакети (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "Получено е искането за нулиране на паро
msgid "Past"
msgstr "Минал"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Пауза"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "Използване на пула"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr "Порт"
@@ -1418,6 +1563,11 @@ msgstr "Предпочитан език"
msgid "Process started"
msgstr "Процесът стартира"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Протокол"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Публичен ключ"
@@ -1503,10 +1653,20 @@ msgstr "Нулиране на парола"
msgid "Resolved"
msgstr "Решен"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Отговор"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Мониторинг на времето за отговор от агенти."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Рестартирания"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Възобнови"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Самотест"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Запази {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Търси"
msgid "Search for systems or settings..."
msgstr "Търси за системи или настройки..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Търси системи"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Секунди между пинговете (по подразбиране: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Виж <0>настройките за нотификациите</0> з
msgid "Select {foo}"
msgstr "Избери {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Избери система"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Избери всички"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Избери съвпаденията"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Избери ред"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Избери системи"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Изпратете единичен heartbeat пинг, за да се уверите, че крайната Ви точка работи."
@@ -1692,6 +1879,8 @@ msgstr "Смени темата"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Услуги на systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Системи"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Табове"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Цел"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Задачи"
@@ -1779,6 +1975,7 @@ msgstr "След това влез в backend-а и нулирай парола
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Това действие не може да бъде отменено. Това ще изтрие всички записи за {name} от датабазата."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "Това ще доведе до трайно изтриване на всички избрани записи от базата данни."
@@ -1901,6 +2098,10 @@ msgstr "Задейства се, когато използването на GPU
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Задейства се, когато употребата на паметта надвиши зададен праг"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Сработва, когато загубата за един час надвиши праг"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Задейства се, когато статуса превключва между долу и горе"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Актуализирай"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Настройките за потребителя ти са обновени."
+209 -6
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: cs\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
@@ -30,6 +30,12 @@ msgstr "{0} k dispozici"
msgid "{0} of {1} row(s) selected."
msgstr "{0} z {1} vybraných řádků."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} vybráno"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# jádro} few {# jádra} many {# jader} other {# jader}}"
@@ -61,7 +67,7 @@ msgstr "1 hodina"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr ""
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
@@ -78,7 +84,7 @@ msgstr "12 hodin"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr ""
msgstr "15 min"
#: src/lib/utils.ts
msgid "24 hours"
@@ -91,7 +97,7 @@ msgstr "30 dní"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr ""
msgstr "5 min"
#. Table column
#: src/components/routes/settings/quiet-hours.tsx
@@ -121,6 +127,10 @@ msgstr "Aktivní stav"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -154,7 +164,7 @@ msgstr "Po nastavení proměnných prostředí restartujte hub Beszel, aby se zm
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr ""
msgstr "Agent"
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -188,6 +198,7 @@ msgstr "Všechny systémy"
msgid "Are you sure you want to delete {name}?"
msgstr "Opravdu chcete odstranit {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Jste si jistý?"
@@ -245,6 +256,14 @@ msgstr "Průměrné využití {0}"
msgid "Average utilization of GPU engines"
msgstr "Průměrné využití GPU engine"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Průměrná, minimální a maximální doba odezvy"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Prům. 1h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "Bity (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Stav zavádění"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Hromadné přidání"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Hromadné přidání {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Hromadné kopírování"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "Může spustit"
msgid "Can stop"
msgstr "Může zastavit"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Chyby kontrolního součtu"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Chyby kontrolního součtu"
msgid "Clear"
msgstr "Vymazat"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Vymazat vše"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Vymazat shody"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klikněte na kontejner pro zobrazení dalších informací."
@@ -432,6 +474,10 @@ msgstr "Instrukce příkazového řádku"
msgid "Configure how you receive alert notifications."
msgstr "Konfigurace způsobu přijímání upozornění."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Nakonfigurujte sledování odezvy z tohoto agenta."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Zdraví kontejneru"
msgid "Containers"
msgstr "Kontejnery"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Zkopírujte instalační příkaz pro agenta níže nebo automaticky reg
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Zkopírujte obsah <0>docker-compose.yml</0> pro agenta níže nebo automaticky registrujte agenty s <1>univerzálním token</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Kopírovat do systému"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Kopírovat YAML"
@@ -630,6 +681,7 @@ msgstr "Výchozí"
msgid "Default time period"
msgstr "Výchozí doba"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -647,7 +699,7 @@ msgstr "Popis"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr ""
msgstr "Detail"
#: src/components/routes/system/smart-table.tsx
msgid "Device"
@@ -718,12 +770,14 @@ msgstr "Stažení"
msgid "Duration"
msgstr "Doba trvání"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Upravit"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Upravit {foo}"
@@ -772,6 +826,11 @@ msgstr "Zadejte Vaše jednorázové heslo."
msgid "Ephemeral"
msgstr "Efemérní"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -844,6 +903,11 @@ msgstr "Selhané služby"
msgid "Failed to authenticate"
msgstr "Ověření se nezdařilo"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Nepodařilo se odstranit monitory."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Nepodařilo se odeslat testovací oznámení"
msgid "Failed to update alert"
msgstr "Nepodařilo se aktualizovat upozornění"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Nepodařilo se aktualizovat monitory."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Ventilátory"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Obraz"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "K dispozici je aktualizace obrazu"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Neaktivní"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Interval (sekundy)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Neplatná e-mailová adresa."
@@ -1097,6 +1177,15 @@ msgstr "Logy"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Hledáte místo kde vytvářet upozornění? Klikněte na ikonu zvonku <0/> v systémové tabulce."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Ztráta"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Ztráta 1h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "Hlavní PID"
@@ -1115,6 +1204,10 @@ msgstr "Pokyny k manuálnímu nastavení"
msgid "Max 1 min"
msgstr "Max. 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Max. 1h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "Využití paměti"
msgid "Memory usage of containers"
msgstr "Využití paměti kontejnery"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min. 1h"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Monitory vytvořeny"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Bod připojení"
@@ -1163,6 +1270,29 @@ msgstr "Název"
msgid "Net"
msgstr "Síť"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Síť"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Síťový monitor"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Ztráta síťového monitoru"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Síťové monitory"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Síťový provoz kontejnerů"
@@ -1197,6 +1327,7 @@ msgstr "Nenalezeny žádné výskyty."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Žádné výsledky."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Pro toto zařízení nejsou k dispozici žádné atributy S.M.A.R.T."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Při každém restartu budou systémy v databázi aktualizovány tak, ab
msgid "One or more containers are unhealthy"
msgstr "Jeden nebo více kontejnerů není v pořádku"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Jeden nebo více monitorů překračuje {0}% ztrátu"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Jedna nebo více služeb je ve stavu selhání"
@@ -1251,6 +1388,7 @@ msgstr "Jednorázové"
msgid "One-time password"
msgstr "Jednorázové heslo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Jiné"
msgid "Overwrite existing alerts"
msgstr "Přepsat existující upozornění"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Ztráta paketů (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "Žádost o obnovu hesla byla přijata"
msgid "Past"
msgstr "Minulé"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Pozastavit"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "Využití fondu"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr ""
@@ -1418,6 +1563,11 @@ msgstr "Upřednostňovaný jazyk"
msgid "Process started"
msgstr "Proces spuštěn"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokol"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Veřejný klíč"
@@ -1503,10 +1653,20 @@ msgstr "Obnovit heslo"
msgid "Resolved"
msgstr "Vyřešeno"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Odezva"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Sledování doby odezvy z agentů."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Restarty"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Pokračovat"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Vlastní test"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Uložit {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Hledat"
msgid "Search for systems or settings..."
msgstr "Hledat systémy nebo nastavení..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Hledat systémy"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekundy mezi pingy (výchozí: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak
msgid "Select {foo}"
msgstr "Vybrat {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Vybrat systém"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Vybrat vše"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Vybrat shody"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Vybrat řádek"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Vybrat systémy"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Odešlete jeden heartbeat ping pro ověření funkčnosti vašeho koncového bodu."
@@ -1692,6 +1879,8 @@ msgstr "Přepnout motiv"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Služby systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Systémy"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Karty"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Cíl"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Úlohy"
@@ -1779,6 +1975,7 @@ msgstr "Poté se přihlaste do backendu a obnovte heslo k uživatelskému účtu
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Tuto akci nelze vzít zpět. Tím se z databáze trvale odstraní všechny aktuální záznamy pro {name}."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
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."
@@ -1901,6 +2098,10 @@ msgstr "Spustí se, když využití GPU překročí prahovou hodnotu"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Spustí se, když využití paměti překročí prahovou hodnotu"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Spustí se, když hodinová ztráta překročí prahovou hodnotu"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Spouští se, když se změní dostupnost"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Aktualizovat"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Ano"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Vaše uživatelská nastavení byla aktualizována."
+229 -26
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: da\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Danish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "{0} tilgængelig"
msgid "{0} of {1} row(s) selected."
msgstr "{0} af {1} række(r) valgt."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} valgt"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# kerne} other {# kerner}}"
@@ -48,7 +54,7 @@ msgstr "{count, plural, one {{countString} minut} other {{countString} minutter}
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr ""
msgstr "{diskName} I/O"
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -121,6 +127,10 @@ msgstr "Aktiv tilstand"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -154,7 +164,7 @@ msgstr "Efter indstilling af miljøvariablerne skal du genstarte din Beszel-hub
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr ""
msgstr "Agent"
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -188,6 +198,7 @@ msgstr "Alle systemer"
msgid "Are you sure you want to delete {name}?"
msgstr "Er du sikker på, at du vil slette {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Er du sikker?"
@@ -245,6 +256,14 @@ msgstr "Gennemsnitlig udnyttelse af {0}"
msgid "Average utilization of GPU engines"
msgstr "Gennemsnitlig udnyttelse af GPU-enheder"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Gennemsnitlig, minimum og maksimum svartid"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Gns 1h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -300,16 +319,29 @@ msgstr "Binær"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)"
msgstr ""
msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/systemd-table/systemd-table.tsx
msgid "Boot state"
msgstr "Opstartstilstand"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Tilføj flere"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Tilføj flere {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Kopiér flere"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr ""
msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
@@ -327,6 +359,7 @@ msgstr "Kan starte"
msgid "Can stop"
msgstr "Kan stoppe"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Forsigtig - muligt tab af data"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Kontrolsumfejl"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Kontrolsumfejl"
msgid "Clear"
msgstr "Ryd"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Ryd alle"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Ryd match"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klik på en container for at se mere information."
@@ -432,6 +474,10 @@ msgstr "Instruktioner for kommandolinje"
msgid "Configure how you receive alert notifications."
msgstr "Konfigurer hvordan du modtager advarselsmeddelelser."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Konfigurer responsovervågning fra denne agent."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -447,7 +493,7 @@ msgstr "Forbindelsen er nede"
#: src/lib/alerts.ts
msgid "Container"
msgstr ""
msgstr "Container"
#: src/lib/alerts.ts
msgid "Container Health"
@@ -457,6 +503,7 @@ msgstr "Container-sundhed"
msgid "Containers"
msgstr "Containere"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Kopier installationskommandoen for agenten nedenfor, eller registrer age
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Kopier <0>docker-compose.yml</0> indholdet for agenten nedenfor, eller registrer agenter automatisk med en <1>universalnøgle</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Kopiér til system"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Kopier YAML"
@@ -530,7 +581,7 @@ msgstr "Kerne"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -542,7 +593,7 @@ msgstr "CPU-I/O-ventetid (IOWait)"
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "CPU Peak"
msgstr ""
msgstr "CPU Peak"
#: src/lib/alerts.ts
msgid "CPU Steal Time"
@@ -630,6 +681,7 @@ msgstr "Standard"
msgid "Default time period"
msgstr "Standard tidsperiode"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -661,7 +713,7 @@ msgstr "Aflader"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr ""
msgstr "Disk"
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
@@ -718,12 +770,14 @@ msgstr "Hent ned"
msgid "Duration"
msgstr "Varighed"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Rediger"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Rediger {foo}"
@@ -732,7 +786,7 @@ msgstr "Rediger {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr ""
msgstr "Email"
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,6 +826,11 @@ msgstr "Indtast din engangsadgangskode."
msgid "Ephemeral"
msgstr "Efemer"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -826,7 +885,7 @@ msgstr "Eksporter din nuværende systemkonfiguration."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr ""
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -844,6 +903,11 @@ msgstr "Fejlede tjenester"
msgid "Failed to authenticate"
msgstr "Kunne ikke godkende"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Kunne ikke slette overvågninger."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Afsendelse af testnotifikation mislykkedes"
msgid "Failed to update alert"
msgstr "Kunne ikke opdatere alarm"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Kunne ikke opdatere overvågninger."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Blæsere"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -880,7 +949,7 @@ msgstr "Blæsere"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr ""
msgstr "Filter..."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint"
@@ -888,7 +957,7 @@ msgstr "Fingeraftryk"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr ""
msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -924,7 +993,7 @@ msgstr "Generelt"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr ""
msgstr "Global"
#: src/components/routes/system.tsx
msgid "GPU"
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Billede"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Imageopdatering tilgængelig"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Inaktiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Interval (sekunder)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Ugyldig email adresse."
@@ -1091,12 +1171,21 @@ msgstr "Loginforsøg mislykkedes"
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
msgid "Logs"
msgstr ""
msgstr "Logs"
#: src/components/routes/settings/notifications.tsx
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Leder du i stedet for efter hvor du kan oprette alarmer? Klik på klokken <0/> ikoner i system tabellen."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Tab"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Tab 1h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "Primær PID"
@@ -1115,6 +1204,10 @@ msgstr "Manuel opsætningsvejledning"
msgid "Max 1 min"
msgstr "Maks. 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Maks 1h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,10 +1234,24 @@ msgstr "Hukommelsesforbrug"
msgid "Memory usage of containers"
msgstr "Containeres hukommelsesforbrug"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr ""
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
msgstr "Model"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Overvågning"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Overvågninger oprettet"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
@@ -1161,7 +1268,30 @@ msgstr "Navn"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Net"
msgstr ""
msgstr "Net"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Netværk"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Netværksovervågning"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Tab på netværksovervågning"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Netværksovervågninger"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
@@ -1197,6 +1327,7 @@ msgstr "Ingen resultater fundet."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Ingen resultater."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Ingen S.M.A.R.T.-attributter tilgængelige for denne enhed."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Ved hver genstart vil systemer i databasen blive opdateret til at matche
msgid "One or more containers are unhealthy"
msgstr "En eller flere containere er usunde"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "En eller flere overvågninger overstiger {0}% tab"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "En eller flere tjenester er i en fejlet tilstand"
@@ -1251,6 +1388,7 @@ msgstr "Engangs"
msgid "One-time password"
msgstr "Engangsadgangskode"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Andre"
msgid "Overwrite existing alerts"
msgstr "Overskriv eksisterende alarmer"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Pakketab (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,9 +1452,10 @@ msgstr "Anmodning om nulstilling af adgangskode modtaget"
msgid "Past"
msgstr "Tidligere"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr ""
msgstr "Pause"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Paused"
@@ -1392,8 +1536,9 @@ msgid "Pool Usage"
msgstr "Poolforbrug"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
@@ -1418,6 +1563,11 @@ msgstr "Foretrukket sprog"
msgid "Process started"
msgstr "Proces startet"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokol"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Offentlig nøgle"
@@ -1503,10 +1653,20 @@ msgstr "Nulstil adgangskode"
msgid "Resolved"
msgstr "Løst"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Respons"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Svartidsovervågning fra agenter."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Genstarter"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Genoptag"
@@ -1516,7 +1676,7 @@ msgstr "Genoptag"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr ""
msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. selvtest"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Gem {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Søg"
msgid "Search for systems or settings..."
msgstr "Søg efter systemer eller indstillinger..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Søg efter systemer"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunder mellem pings (standard: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtag
msgid "Select {foo}"
msgstr "Vælg {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Vælg et system"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Vælg alle"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Vælg match"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Vælg række"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Vælg systemer"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Send et enkelt heartbeat-ping for at bekræfte, at dit endpoint fungerer."
@@ -1669,7 +1856,7 @@ msgstr "Tilstand"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr ""
msgstr "Status"
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State"
@@ -1692,6 +1879,8 @@ msgstr "Skift tema"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1701,7 +1890,7 @@ msgstr "Skift tema"
#: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts
msgid "System"
msgstr ""
msgstr "System"
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)"
@@ -1713,9 +1902,11 @@ msgstr "Gennemsnitlig system belastning over tid"
#: src/components/systemd-table/systemd-table.tsx
msgid "Systemd Services"
msgstr ""
msgstr "Systemd Services"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Systemer"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Faner"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Mål"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Opgaver"
@@ -1757,7 +1953,7 @@ msgstr "Temperaturer i systemsensorer"
#: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>"
msgstr ""
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
@@ -1779,6 +1975,7 @@ msgstr "Log derefter ind på backend og nulstil adgangskoden til din brugerkonto
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Denne handling kan ikke fortrydes. Dette vil permanent slette alle aktuelle elementer for {name} fra databasen."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "Dette vil permanent slette alle poster fra databasen."
@@ -1901,6 +2098,10 @@ msgstr "Udløses når GPU-brug overstiger en grænse"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Udløser når hukommelsesforbruget overstiger en tærskel"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Udløses når tab over en time overstiger en tærskel"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Udløser når status skifter mellem op og ned"
@@ -1914,7 +2115,7 @@ msgstr "Udløser når brugen af en disk overstiger en tærskel"
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type"
msgstr ""
msgstr "Type"
#: src/lib/alerts.ts
msgid "Unhealthy"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Opdater"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Dine brugerindstillinger er opdateret."
+228 -25
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:22\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "{0} verfügbar"
msgid "{0} of {1} row(s) selected."
msgstr "{0} von {1} Zeile(n) ausgewählt."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} ausgewählt"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# Kern} other {# Kerne}}"
@@ -121,6 +127,10 @@ msgstr "Aktiver Zustand"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -142,7 +152,7 @@ msgstr "Breite des Hauptlayouts anpassen"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr ""
msgstr "Admin"
#: src/components/systemd-table/systemd-table.tsx
msgid "After"
@@ -154,7 +164,7 @@ msgstr "Starten Sie nach dem Festlegen der Umgebungsvariablen Ihren Beszel-Hub n
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr ""
msgstr "Agent"
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -188,6 +198,7 @@ msgstr "Alle Systeme"
msgid "Are you sure you want to delete {name}?"
msgstr "Möchtest du {name} wirklich löschen?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Bist du sicher?"
@@ -245,10 +256,18 @@ msgstr "Durchschnittliche Auslastung von {0}"
msgid "Average utilization of GPU engines"
msgstr "Durchschnittliche Auslastung der GPU-Engines"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Durchschnittliche, minimale und maximale Antwortzeit"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Ø 1h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
msgstr ""
msgstr "Backups"
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
@@ -300,16 +319,29 @@ msgstr "Binär"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)"
msgstr ""
msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/systemd-table/systemd-table.tsx
msgid "Boot state"
msgstr "Boot-Zustand"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Mehrere hinzufügen"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Mehrere hinzufügen {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Mehrfach kopieren"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr ""
msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
@@ -327,6 +359,7 @@ msgstr "Kann starten"
msgid "Can stop"
msgstr "Kann stoppen"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Vorsicht - potenzieller Datenverlust"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Prüfsummenfehler"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Prüfsummenfehler"
msgid "Clear"
msgstr "Löschen"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Alle löschen"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Treffer löschen"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klicke auf einen Container, um weitere Informationen zu sehen."
@@ -432,6 +474,10 @@ msgstr "Befehlszeilenanweisungen"
msgid "Configure how you receive alert notifications."
msgstr "Konfiguriere, wie du Warnbenachrichtigungen erhältst."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Antwortzeitüberwachung von diesem Agent konfigurieren."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -447,7 +493,7 @@ msgstr "Verbindung unterbrochen"
#: src/lib/alerts.ts
msgid "Container"
msgstr ""
msgstr "Container"
#: src/lib/alerts.ts
msgid "Container Health"
@@ -457,6 +503,7 @@ msgstr "Container-Gesundheit"
msgid "Containers"
msgstr "Container"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Kopiere den Installationsbefehl für den Agent unten oder registriere Ag
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Kopiere den<0>docker-compose.yml</0> Inhalt für den Agent unten oder registriere Agents automatisch mit einem <1>universellen Token</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Auf System kopieren"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "YAML kopieren"
@@ -530,7 +581,7 @@ msgstr "Kern"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -630,6 +681,7 @@ msgstr "Standard"
msgid "Default time period"
msgstr "Standardzeitraum"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "Herunterladen"
msgid "Duration"
msgstr "Dauer"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Bearbeiten"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "{foo} bearbeiten"
@@ -772,6 +826,11 @@ msgstr "Geben Sie Ihr Einmalpasswort ein."
msgid "Ephemeral"
msgstr "Flüchtig"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -826,7 +885,7 @@ msgstr "Exportiere die aktuelle Systemkonfiguration."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr ""
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -844,6 +903,11 @@ msgstr "Fehlgeschlagene Dienste"
msgid "Failed to authenticate"
msgstr "Authentifizierung fehlgeschlagen"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Monitore konnten nicht gelöscht werden."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Testbenachrichtigung konnte nicht gesendet werden"
msgid "Failed to update alert"
msgstr "Warnung konnte nicht aktualisiert werden"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Monitore konnten nicht aktualisiert werden."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Lüfter"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -880,7 +949,7 @@ msgstr "Lüfter"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr ""
msgstr "Filter..."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint"
@@ -924,7 +993,7 @@ msgstr "Allgemein"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr ""
msgstr "Global"
#: src/components/routes/system.tsx
msgid "GPU"
@@ -954,7 +1023,7 @@ msgstr "Gesundheit"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr ""
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -972,7 +1041,7 @@ msgstr "Homebrew-Befehl"
#: src/components/add-system.tsx
msgid "Host / IP"
msgstr ""
msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
@@ -1009,16 +1078,27 @@ msgstr "Wenn du das Passwort für dein Administratorkonto verloren hast, kannst
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr ""
msgstr "Image"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Image-Update verfügbar"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Inaktiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervall"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Intervall (Sekunden)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Ungültige E-Mail-Adresse."
@@ -1097,6 +1177,15 @@ msgstr "Protokolle"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Du möchtest neue Warnungen erstellen? Klicke dafür auf die Glocken-<0/>-Symbole in der Systemtabelle."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Verlust"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Verlust 1h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "Haupt-PID"
@@ -1115,6 +1204,10 @@ msgstr "Anleitung zur manuellen Einrichtung"
msgid "Max 1 min"
msgstr "Max 1 Min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "Arbeitsspeichernutzung"
msgid "Memory usage of containers"
msgstr "Speichernutzung der Container"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr ""
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Modell"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Monitore erstellt"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Einhängepunkt"
@@ -1156,13 +1263,36 @@ msgstr "Einhängepunkt"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Name"
msgstr ""
msgstr "Name"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Net"
msgstr "Netzwerk"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Netzwerk"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Netzwerk-Monitor"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Netzwerk-Monitor-Verlust"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Netzwerk-Monitore"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Netzwerkverkehr der Container"
@@ -1197,6 +1327,7 @@ msgstr "Keine Ergebnisse gefunden."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Keine Ergebnisse."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Für dieses Gerät sind keine S.M.A.R.T.-Attribute verfügbar."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Bei jedem Neustart werden die Systeme in der Datenbank aktualisiert, um
msgid "One or more containers are unhealthy"
msgstr "Ein oder mehrere Container sind fehlerhaft"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Ein oder mehrere Monitore überschreiten {0}% Verlust"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Ein oder mehrere Dienste befinden sich im Fehlerzustand"
@@ -1251,6 +1388,7 @@ msgstr "Einmalig"
msgid "One-time password"
msgstr "Einmalpasswort"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Andere"
msgid "Overwrite existing alerts"
msgstr "Bestehende Warnungen überschreiben"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Paketverlust (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,9 +1452,10 @@ msgstr "Anfrage zum Zurücksetzen des Passworts erhalten"
msgid "Past"
msgstr "Vergangen"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr ""
msgstr "Pause"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Paused"
@@ -1392,13 +1536,14 @@ msgid "Pool Usage"
msgstr "Pool-Auslastung"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr ""
msgstr "Ports"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
@@ -1418,6 +1563,11 @@ msgstr "Bevorzugte Sprache"
msgid "Process started"
msgstr "Prozess gestartet"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokoll"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Öffentlicher Schlüssel"
@@ -1503,10 +1653,20 @@ msgstr "Passwort zurücksetzen"
msgid "Resolved"
msgstr "Gelöst"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Antwort"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Antwortzeitüberwachung durch Agenten."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Neustarts"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Fortsetzen"
@@ -1516,7 +1676,7 @@ msgstr "Fortsetzen"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr ""
msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T.-Selbsttest"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "{foo} speichern"
@@ -1575,6 +1736,11 @@ msgstr "Suche"
msgid "Search for systems or settings..."
msgstr "Nach Systemen oder Einstellungen suchen..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Systeme suchen"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunden zwischen Pings (Standard: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du
msgid "Select {foo}"
msgstr "Auswählen {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Ein System auswählen"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Alle auswählen"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Treffer auswählen"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Zeile auswählen"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Systeme auswählen"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Senden Sie einen einzelnen Heartbeat-Ping, um zu überprüfen, ob Ihr Endpunkt funktioniert."
@@ -1669,7 +1856,7 @@ msgstr "Status"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr ""
msgstr "Status"
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State"
@@ -1692,6 +1879,8 @@ msgstr "Design wechseln"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1701,7 +1890,7 @@ msgstr "Design wechseln"
#: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts
msgid "System"
msgstr ""
msgstr "System"
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)"
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Systemd-Dienste"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Systeme"
@@ -1730,7 +1921,12 @@ msgstr "Tabelle"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr ""
msgstr "Tabs"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Ziel"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
@@ -1757,7 +1953,7 @@ msgstr "Temperaturen der Systemsensoren"
#: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>"
msgstr ""
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
@@ -1779,6 +1975,7 @@ msgstr "Melde dich dann im Backend an und setze dein Benutzerkontopasswort in de
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle aktuellen Datensätze für {name} dauerhaft aus der Datenbank gelöscht."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
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."
@@ -1802,7 +1999,7 @@ msgstr "An E-Mail(s)"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1901,6 +2098,10 @@ msgstr "Löst aus, wenn die GPU-Auslastung einen Schwellenwert überschreitet"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Löst aus, wenn die Arbeitsspeichernutzung einen Schwellenwert überschreitet"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Wird ausgelöst, wenn der Stunden-Verlust einen Schwellenwert überschreitet"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Löst aus, wenn der Status zwischen online und offline wechselt"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Aktualisieren"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Deine Benutzereinstellungen wurden aktualisiert."
+209 -6
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: el\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Greek\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "Διαθέσιμη έκδοση {0}"
msgid "{0} of {1} row(s) selected."
msgstr "{0} από {1} σειρά(ες) επιλέχθηκαν."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} επιλεγμένα"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# πυρήνας} other {# πυρήνες}}"
@@ -52,7 +58,7 @@ msgstr "I/O {diskName}"
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr ""
msgstr "{threads, plural, one {# thread} other {# threads}}"
#: src/lib/utils.ts
msgid "1 hour"
@@ -121,6 +127,10 @@ msgstr "Ενεργή κατάσταση"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "Όλα τα συστήματα"
msgid "Are you sure you want to delete {name}?"
msgstr "Είστε βέβαιοι ότι θέλετε να διαγράψετε το {name};"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Είστε βέβαιοι;"
@@ -245,6 +256,14 @@ msgstr "Μέση χρήση του {0}"
msgid "Average utilization of GPU engines"
msgstr "Μέση χρήση των μηχανών GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Μέσος, ελάχιστος και μέγιστος χρόνος απόκρισης"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Μέσος όρος 1ώρου"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "Bit (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Κατάσταση εκκίνησης"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Μαζική προσθήκη"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Μαζική προσθήκη {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Μαζική αντιγραφή"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "Δυνατότητα εκκίνησης"
msgid "Can stop"
msgstr "Δυνατότητα διακοπής"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Σφάλματα αθροίσματος ελέγχου"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Σφάλματα αθροίσματος ελέγχου"
msgid "Clear"
msgstr "Εκκαθάριση"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Εκκαθάριση όλων"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Εκκαθάριση αντιστοιχιών"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Κάντε κλικ σε ένα κοντέινερ για να δείτε περισσότερες πληροφορίες."
@@ -432,6 +474,10 @@ msgstr "Οδηγίες γραμμής εντολών"
msgid "Configure how you receive alert notifications."
msgstr "Ρυθμίστε τον τρόπο λήψης ειδοποιήσεων συναγερμού."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Διαμορφώστε την παρακολούθηση απόκρισης από αυτόν τον agent."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Υγεία κοντέινερ"
msgid "Containers"
msgstr "Κοντέινερ"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Αντιγράψτε την παρακάτω εντολή εγκατάσ
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Αντιγράψτε το περιεχόμενο του <0>docker-compose.yml</0> για τον παρακάτω πράκτορα ή καταχωρίστε πράκτορες αυτόματα με ένα <1>καθολικό διακριτικό</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Αντιγραφή στο σύστημα"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Αντιγραφή YAML"
@@ -530,7 +581,7 @@ msgstr "Βασικά"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -630,6 +681,7 @@ msgstr "Προεπιλογή"
msgid "Default time period"
msgstr "Προεπιλεγμένη χρονική περίοδος"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "Λήψη"
msgid "Duration"
msgstr "Διάρκεια"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Επεξεργασία"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Επεξεργασία {foo}"
@@ -732,7 +786,7 @@ msgstr "Επεξεργασία {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr ""
msgstr "Email"
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,6 +826,11 @@ msgstr "Εισαγάγετε τον κωδικό μίας χρήσης."
msgid "Ephemeral"
msgstr "Προσωρινό"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -844,6 +903,11 @@ msgstr "Αποτυχημένες υπηρεσίες"
msgid "Failed to authenticate"
msgstr "Η ταυτοποίηση απέτυχε"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Η διαγραφή των monitors απέτυχε."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Αποτυχία αποστολής δοκιμαστικής ειδοπ
msgid "Failed to update alert"
msgstr "Αποτυχία ενημέρωσης ειδοποίησης"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Η ενημέρωση των monitors απέτυχε."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Ανεμιστήρες"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -928,7 +997,7 @@ msgstr "Καθολικό"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -954,7 +1023,7 @@ msgstr "Υγεία"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr ""
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Εικόνα"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Διατίθεται ενημέρωση εικόνας"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Ανενεργό"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Διάστημα"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Διάστημα (δευτερόλεπτα)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Μη έγκυρη διεύθυνση email."
@@ -1097,6 +1177,15 @@ msgstr "Αρχεία καταγραφής"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Ψάχνετε πού μπορείτε να δημιουργήσετε ειδοποιήσεις; Κάντε κλικ στο καμπανάκι <0/> στον πίνακα συστημάτων."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Απώλεια"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Απώλεια 1ώρου"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "Κύριο PID"
@@ -1115,6 +1204,10 @@ msgstr "Οδηγίες χειροκίνητης εγκατάστασης"
msgid "Max 1 min"
msgstr "Μέγ. 1 λ"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Μέγιστο 1ώρου"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "Χρήση μνήμης"
msgid "Memory usage of containers"
msgstr "Χρήση μνήμης των κοντέινερ"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Ελάχιστο 1ώρου"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Μοντέλο"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Monitor"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Τα monitors δημιουργήθηκαν"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Σημείο προσάρτησης"
@@ -1163,6 +1270,29 @@ msgstr "Όνομα"
msgid "Net"
msgstr "Δίκτυο"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Δίκτυο"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Παρακολούθηση δικτύου"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Απώλεια παρακολούθησης δικτύου"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Παρακολουθήσεις δικτύου"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Κίνηση δικτύου των κοντέινερ"
@@ -1197,6 +1327,7 @@ msgstr "Δεν βρέθηκαν αποτελέσματα."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Κανένα αποτέλεσμα."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Δεν υπάρχουν διαθέσιμα χαρακτηριστικά S.M.A.R.T. για αυτήν τη συσκευή."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Σε κάθε επανεκκίνηση, τα συστήματα στη
msgid "One or more containers are unhealthy"
msgstr "Ένα ή περισσότερα κοντέινερ δεν είναι υγιή"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Ένα ή περισσότερα monitors υπερβαίνουν απώλεια {0}%"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Μία ή περισσότερες υπηρεσίες βρίσκονται σε κατάσταση αποτυχίας"
@@ -1251,6 +1388,7 @@ msgstr "Μία φορά"
msgid "One-time password"
msgstr "Κωδικός μίας χρήσης"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Άλλο"
msgid "Overwrite existing alerts"
msgstr "Αντικατάσταση υπαρχουσών ειδοποιήσεων"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Απώλεια πακέτων (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "Το αίτημα επαναφοράς κωδικού πρόσβασης
msgid "Past"
msgstr "Παρελθόν"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Παύση"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "Χρήση pool"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr "Θύρα"
@@ -1418,6 +1563,11 @@ msgstr "Προτιμώμενη γλώσσα"
msgid "Process started"
msgstr "Η διεργασία ξεκίνησε"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Πρωτόκολλο"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Δημόσιο κλειδί"
@@ -1503,10 +1653,20 @@ msgstr "Επαναφορά κωδικού πρόσβασης"
msgid "Resolved"
msgstr "Επιλύθηκε"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Απόκριση"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Παρακολούθηση χρόνου απόκρισης από agents."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Επανεκκινήσεις"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Συνέχιση"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "Αυτοέλεγχος S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Αποθήκευση {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Αναζήτηση"
msgid "Search for systems or settings..."
msgstr "Αναζήτηση συστημάτων ή ρυθμίσεων..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Αναζήτηση συστημάτων"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Δευτερόλεπτα μεταξύ ping (προεπιλογή: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Δείτε τις <0>ρυθμίσεις ειδοποιήσεων</0> γ
msgid "Select {foo}"
msgstr "Επιλογή {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Επιλέξτε ένα σύστημα"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Επιλογή όλων"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Επιλογή αντιστοιχιών"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Επιλογή γραμμής"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Επιλογή συστημάτων"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Στείλτε ένα μεμονωμένο heartbeat για να επαληθεύσετε ότι το τελικό σημείο λειτουργεί."
@@ -1692,6 +1879,8 @@ msgstr "Αλλαγή θέματος"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Υπηρεσίες systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Συστήματα"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Καρτέλες"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Στόχος"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Εργασίες"
@@ -1779,6 +1975,7 @@ msgstr "Στη συνέχεια, συνδεθείτε στο backend και επ
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Θα διαγράψει οριστικά όλες τις τρέχουσες εγγραφές για το {name} από τη βάση δεδομένων."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "Αυτό θα διαγράψει οριστικά όλες τις επιλεγμένες εγγραφές από τη βάση δεδομένων."
@@ -1901,6 +2098,10 @@ msgstr "Ενεργοποιείται όταν η χρήση GPU υπερβαίν
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Ενεργοποιείται όταν η χρήση μνήμης υπερβαίνει ένα όριο"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Ενεργοποιείται όταν η απώλεια μίας ώρας υπερβαίνει ένα όριο"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Ενεργοποιείται όταν η κατάσταση εναλλάσσεται μεταξύ λειτουργίας και εκτός λειτουργίας"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Ενημέρωση"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Ναι"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Οι ρυθμίσεις χρήστη σας ενημερώθηκαν."
+220 -17
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "{0} disponible"
msgid "{0} of {1} row(s) selected."
msgstr "{0} de {1} fila(s) seleccionada(s)."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} seleccionados"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# núcleo} other {# núcleos}}"
@@ -61,7 +67,7 @@ msgstr "1 hora"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr ""
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
@@ -78,7 +84,7 @@ msgstr "12 horas"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr ""
msgstr "15 min"
#: src/lib/utils.ts
msgid "24 hours"
@@ -91,7 +97,7 @@ msgstr "30 días"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr ""
msgstr "5 min"
#. Table column
#: src/components/routes/settings/quiet-hours.tsx
@@ -121,6 +127,10 @@ msgstr "Estado activo"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "Todos los sistemas"
msgid "Are you sure you want to delete {name}?"
msgstr "¿Estás seguro de que deseas eliminar {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "¿Estás seguro?"
@@ -245,6 +256,14 @@ msgstr "Uso promedio de {0}"
msgid "Average utilization of GPU engines"
msgstr "Utilización promedio de motores GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Tiempo de respuesta medio, mínimo y máximo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Prom. 1 h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -258,7 +277,7 @@ msgstr "Ancho de banda"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Bat"
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
@@ -306,6 +325,19 @@ msgstr "Bits (kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Estado de arranque"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Añadir en bloque"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Añadir {foo} en bloque"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Copiar en bloque"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "Puede iniciarse"
msgid "Can stop"
msgstr "Puede detenerse"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Precaución - posible pérdida de datos"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Errores de suma de comprobación"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Errores de suma de comprobación"
msgid "Clear"
msgstr "Limpiar"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Borrar todo"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Borrar coincidencias"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Haz clic en un contenedor para ver más información."
@@ -432,6 +474,10 @@ msgstr "Instrucciones de línea de comandos"
msgid "Configure how you receive alert notifications."
msgstr "Configura cómo recibe las notificaciones de alertas."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Configura la supervisión del tiempo de respuesta desde este agente."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Estado del contenedor"
msgid "Containers"
msgstr "Contenedores"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Copia el comando de instalación del agente a continuación, o registra
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copia el contenido del<0>docker-compose.yml</0> para el agente a continuación, o registra agentes automáticamente con un <1>token universal</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Copiar al sistema"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copiar YAML"
@@ -530,7 +581,7 @@ msgstr "Núcleo"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -630,6 +681,7 @@ msgstr "Predeterminado"
msgid "Default time period"
msgstr "Periodo de tiempo predeterminado"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "Descargar"
msgid "Duration"
msgstr "Duración"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Editar"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Editar {foo}"
@@ -772,6 +826,11 @@ msgstr "Ingrese su contraseña de un solo uso."
msgid "Ephemeral"
msgstr "Efímero"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -783,7 +842,7 @@ msgstr "Efímero"
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Error"
msgstr ""
msgstr "Error"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
@@ -826,7 +885,7 @@ msgstr "Exporta la configuración actual de sus sistemas."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr ""
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -844,6 +903,11 @@ msgstr "Servicios con errores"
msgid "Failed to authenticate"
msgstr "Error al autenticar"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "No se pudieron eliminar los monitores."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Error al enviar la notificación de prueba"
msgid "Failed to update alert"
msgstr "Error al actualizar la alerta"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "No se pudieron actualizar los monitores."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Ventiladores"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -888,7 +957,7 @@ msgstr "Huella dactilar"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr ""
msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -920,15 +989,15 @@ msgstr "Llena"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/layout.tsx
msgid "General"
msgstr ""
msgstr "General"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr ""
msgstr "Global"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Imagen"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Actualización de imagen disponible"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Inactivo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervalo"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Intervalo (segundos)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Dirección de correo electrónico no válida."
@@ -1097,6 +1177,15 @@ msgstr "Registros"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "¿Buscas dónde crear alertas? Haz clic en los iconos de campana <0/> en la tabla de sistemas."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Pérdida"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Pérdida 1 h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "PID principal"
@@ -1115,6 +1204,10 @@ msgstr "Instrucciones manuales de configuración"
msgid "Max 1 min"
msgstr "Máx. 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Máx. 1 h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "Uso de memoria"
msgid "Memory usage of containers"
msgstr "Uso de memoria de los contenedores"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Mín. 1 h"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Modelo"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Monitor"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Monitores creados"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Punto de montaje"
@@ -1163,6 +1270,29 @@ msgstr "Nombre"
msgid "Net"
msgstr "Red"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Red"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Monitor de red"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Pérdida del monitor de red"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Monitores de red"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Tráfico de red de los contenedores"
@@ -1183,7 +1313,7 @@ msgstr "Unidad de red"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "No"
msgstr ""
msgstr "No"
#: src/components/routes/system/storage-pools-table.tsx
msgid "No detail data for this pool."
@@ -1197,6 +1327,7 @@ msgstr "No se encontraron resultados."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Sin resultados."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "No hay atributos S.M.A.R.T. disponibles para este dispositivo."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "En cada reinicio, los sistemas en la base de datos se actualizarán para
msgid "One or more containers are unhealthy"
msgstr "Uno o más contenedores no están en buen estado"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Uno o más monitores superan una pérdida del {0}%"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Uno o más servicios están en estado de error"
@@ -1251,6 +1388,7 @@ msgstr "Una vez"
msgid "One-time password"
msgstr "Contraseña de un solo uso"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Otro"
msgid "Overwrite existing alerts"
msgstr "Sobrescribir alertas existentes"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Pérdida de paquetes (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "Solicitud de restablecimiento de contraseña recibida"
msgid "Past"
msgstr "Pasado"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Pausar"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "Uso del pool"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr "Puerto"
@@ -1418,6 +1563,11 @@ msgstr "Idioma preferido"
msgid "Process started"
msgstr "Proceso iniciado"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protocolo"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Clave pública"
@@ -1503,10 +1653,20 @@ msgstr "Restablecer contraseña"
msgid "Resolved"
msgstr "Resuelto"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Respuesta"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Supervisión del tiempo de respuesta desde los agentes."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Reinicios"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Reanudar"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "Autoprueba S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Guardar {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Buscar"
msgid "Search for systems or settings..."
msgstr "Buscar sistemas o configuraciones..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Buscar sistemas"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Segundos entre pings (predeterminado: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Consulta la <0>configuración de notificaciones</0> para configurar cóm
msgid "Select {foo}"
msgstr "Seleccionar {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Selecciona un sistema"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Seleccionar todo"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Seleccionar coincidencias"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Seleccionar fila"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Seleccionar sistemas"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Envíe un único ping de latido para verificar que su punto de conexión funciona."
@@ -1692,6 +1879,8 @@ msgstr "Cambiar tema"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Servicios de systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Sistemas"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Pestañas"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Objetivo"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Tareas"
@@ -1779,6 +1975,7 @@ msgstr "Luego inicia sesión en el backend y restablece la contraseña de tu cue
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Esta acción no se puede deshacer. Esto eliminará permanentemente todos los registros actuales de {name} de la base de datos."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "Esto eliminará permanentemente todos los registros seleccionados de la base de datos."
@@ -1802,7 +1999,7 @@ msgstr "A correo(s)"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1821,7 +2018,7 @@ msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conex
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr ""
msgstr "Total"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
@@ -1839,7 +2036,7 @@ msgstr "Tiempo total dedicado a lectura/escritura (puede superar el 100 %)"
#. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
msgstr ""
msgstr "Total: {0}"
#: src/components/systemd-table/systemd-table.tsx
msgid "Triggered by"
@@ -1901,6 +2098,10 @@ msgstr "Se activa cuando el uso de GPU supera un umbral"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Se activa cuando el uso de memoria supera un umbral"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Se activa cuando la pérdida de una hora supera un umbral"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Se activa cuando el estado cambia entre activo e inactivo"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Actualizar"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Sí"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Tu configuración de usuario ha sido actualizada."
+204 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fa\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:22\n"
"Last-Translator: \n"
"Language-Team: Persian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "{0} در دسترس است"
msgid "{0} of {1} row(s) selected."
msgstr "{0} از {1} ردیف انتخاب شده است."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} مورد انتخاب شد"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# هسته} other {# هسته}}"
@@ -121,6 +127,10 @@ msgstr "وضعیت فعال"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "همه سیستم‌ها"
msgid "Are you sure you want to delete {name}?"
msgstr "آیا مطمئن هستید که می‌خواهید {name} را حذف کنید؟"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "آیا مطمئن هستید؟"
@@ -245,6 +256,14 @@ msgstr "میانگین استفاده از {0}"
msgid "Average utilization of GPU engines"
msgstr "میانگین استفاده از موتورهای GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "میانگین، حداقل و حداکثر زمان پاسخ"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "میانگین ۱ ساعت"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "بیت (کیلوبیت بر ثانیه، مگابیت بر ثانیه،
msgid "Boot state"
msgstr "وضعیت بوت"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "افزودن گروهی"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "افزودن گروهی {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "کپی گروهی"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "می‌تواند شروع شود"
msgid "Can stop"
msgstr "می‌تواند متوقف شود"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "خطاهای مجموع بررسی"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "خطاهای مجموع بررسی"
msgid "Clear"
msgstr "پاک کردن"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "پاک کردن همه"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "پاک کردن موارد منطبق"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "برای مشاهده اطلاعات بیشتر روی کانتینر کلیک کنید."
@@ -432,6 +474,10 @@ msgstr "دستورالعمل‌های خط فرمان"
msgid "Configure how you receive alert notifications."
msgstr "نحوه دریافت هشدارهای اطلاع‌رسانی را پیکربندی کنید."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "پایش پاسخ را از این ایجنت پیکربندی کنید."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "سلامت کانتینر"
msgid "Containers"
msgstr "کانتینرها"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "دستور نصب عامل زیر را کپی کنید، یا عامل
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "محتوای <0>docker-compose.yml</0> عامل زیر را کپی کنید، یا عامل‌ها را به طور خودکار با <1>توکن جهانی</1> ثبت کنید."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "کپی به سیستم"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "کپی YAML"
@@ -630,6 +681,7 @@ msgstr "پیش‌فرض"
msgid "Default time period"
msgstr "بازه زمانی پیش‌فرض"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "دانلود"
msgid "Duration"
msgstr "مدت زمان"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "ویرایش"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "ویرایش {foo}"
@@ -772,6 +826,11 @@ msgstr "رمز عبور یک‌بار مصرف خود را وارد کنید."
msgid "Ephemeral"
msgstr "گذرا"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -844,6 +903,11 @@ msgstr "سرویس‌های ناموفق"
msgid "Failed to authenticate"
msgstr "احراز هویت ناموفق بود"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "حذف مانیتورها ناموفق بود."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "ارسال اعلان آزمایشی ناموفق بود"
msgid "Failed to update alert"
msgstr "به‌روزرسانی هشدار ناموفق بود"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "به‌روزرسانی مانیتورها ناموفق بود."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "فن‌ها"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "تصویر"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "به‌روزرسانی image موجود است"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "غیرفعال"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "بازه زمانی"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "بازه (ثانیه)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "آدرس ایمیل نامعتبر است."
@@ -1097,6 +1177,15 @@ msgstr "لاگ‌ها"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "به دنبال جایی برای ایجاد هشدار هستید؟ روی آیکون‌های زنگ <0/> در جدول سیستم‌ها کلیک کنید."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "افت"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "افت ۱ ساعت"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "PID اصلی"
@@ -1115,6 +1204,10 @@ msgstr "دستورالعمل‌های راه‌اندازی دستی"
msgid "Max 1 min"
msgstr "حداکثر ۱ دقیقه"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "حداکثر ۱ ساعت"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "میزان استفاده از حافظه"
msgid "Memory usage of containers"
msgstr "میزان استفاده حافظه کانتینرها"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "حداقل ۱ ساعت"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "مدل"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "مانیتور"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "مانیتورها ایجاد شدند"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "نقطه اتصال"
@@ -1163,6 +1270,29 @@ msgstr "نام"
msgid "Net"
msgstr "شبکه"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "شبکه"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "مانیتور شبکه"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "افت مانیتور شبکه"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "مانیتورهای شبکه"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "ترافیک شبکه کانتینرها"
@@ -1197,6 +1327,7 @@ msgstr "هیچ نتیجه‌ای یافت نشد."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "نتیجه‌ای یافت نشد."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "هیچ ویژگی S.M.A.R.T برای این دستگاه موجود نیست."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "در هر بار راه‌اندازی مجدد، سیستم‌های م
msgid "One or more containers are unhealthy"
msgstr "یک یا چند کانتینر در وضعیت ناسالم هستند"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "یک یا چند مانیتور از افت {0}% فراتر رفته‌اند"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "یک یا چند سرویس در وضعیت ناموفق هستند"
@@ -1251,6 +1388,7 @@ msgstr "یک‌بار مصرف"
msgid "One-time password"
msgstr "رمز عبور یک‌بار مصرف"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "سایر"
msgid "Overwrite existing alerts"
msgstr "بازنویسی هشدارهای موجود"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "افت بسته‌ها (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "درخواست بازنشانی رمز عبور دریافت شد"
msgid "Past"
msgstr "گذشته"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "توقف"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "استفاده از استخر"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr "پورت"
@@ -1418,6 +1563,11 @@ msgstr "زبان ترجیحی"
msgid "Process started"
msgstr "فرآیند شروع شد"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "پروتکل"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "کلید عمومی"
@@ -1503,10 +1653,20 @@ msgstr "بازنشانی رمز عبور"
msgid "Resolved"
msgstr "حل شده"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "پاسخ"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "پایش زمان پاسخ از ایجنت‌ها."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "راه‌اندازی مجدد"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "ادامه"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "تست خود S.M.A.R.T"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "ذخیره {foo}"
@@ -1575,6 +1736,11 @@ msgstr "جستجو"
msgid "Search for systems or settings..."
msgstr "جستجو برای سیستم‌ها یا تنظیمات..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "جستجوی سیستم‌ها"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "ثانیه بین پینگ‌ها (پیش‌فرض: ۶۰)"
@@ -1587,6 +1753,27 @@ msgstr "برای پیکربندی نحوه دریافت هشدارها، به <0
msgid "Select {foo}"
msgstr "انتخاب {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "یک سیستم را انتخاب کنید"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "انتخاب همه"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "انتخاب موارد منطبق"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "انتخاب ردیف"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "انتخاب سیستم‌ها"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "یک پینگ ضربان قلب تکی ارسال کنید تا از کارکرد نقطه پایانی خود اطمینان حاصل کنید."
@@ -1692,6 +1879,8 @@ msgstr "تغییر تم"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "خدمات Systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "سیستم‌ها"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "تب‌ها"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "هدف"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "وظایف"
@@ -1779,6 +1975,7 @@ msgstr "سپس وارد بخش پشتیبان شوید و رمز عبور حسا
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "این عمل قابل برگشت نیست. این کار تمام رکوردهای فعلی {name} را برای همیشه از پایگاه داده حذف خواهد کرد."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "این کار تمام رکوردهای انتخاب شده را برای همیشه از پایگاه داده حذف خواهد کرد."
@@ -1901,6 +2098,10 @@ msgstr "هنگامی که میزان استفاده از GPU از یک آستا
msgid "Triggers when memory usage exceeds a threshold"
msgstr "هنگامی که میزان استفاده از حافظه از یک آستانه فراتر رود، فعال می‌شود"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "زمانی فعال می‌شود که افت یک‌ساعته از آستانه بیشتر شود"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "هنگامی که وضعیت بین بالا و پایین تغییر می‌کند، فعال می‌شود"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "به‌روزرسانی"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "بله"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "تنظیمات کاربری شما به‌روزرسانی شد."
+238 -35
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -30,6 +30,12 @@ msgstr "{0} disponible"
msgid "{0} of {1} row(s) selected."
msgstr "{0} sur {1} ligne(s) sectionner."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} sélectionnés"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# cœur} other {# cœurs}}"
@@ -52,7 +58,7 @@ msgstr "E/S {diskName}"
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr ""
msgstr "{threads, plural, one {# thread} other {# threads}}"
#: src/lib/utils.ts
msgid "1 hour"
@@ -61,11 +67,11 @@ msgstr "1 heure"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr ""
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
msgstr ""
msgstr "1 minute"
#: src/lib/utils.ts
msgid "1 week"
@@ -78,7 +84,7 @@ msgstr "12 heures"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr ""
msgstr "15 min"
#: src/lib/utils.ts
msgid "24 hours"
@@ -91,7 +97,7 @@ msgstr "30 jours"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr ""
msgstr "5 min"
#. Table column
#: src/components/routes/settings/quiet-hours.tsx
@@ -100,14 +106,14 @@ msgstr ""
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Actions"
msgstr ""
msgstr "Actions"
#: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Active"
msgstr ""
msgstr "Active"
#: src/components/active-alerts.tsx
msgid "Active Alerts"
@@ -121,6 +127,10 @@ msgstr "État actif"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -142,7 +152,7 @@ msgstr "Ajuster la largeur de la mise en page principale"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr ""
msgstr "Admin"
#: src/components/systemd-table/systemd-table.tsx
msgid "After"
@@ -154,7 +164,7 @@ msgstr "Après avoir défini les variables d'environnement, redémarrez votre hu
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr ""
msgstr "Agent"
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -188,6 +198,7 @@ msgstr "Tous les systèmes"
msgid "Are you sure you want to delete {name}?"
msgstr "Êtes-vous sûr de vouloir supprimer {name} ?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Êtes-vous sûr ?"
@@ -245,6 +256,14 @@ msgstr "Utilisation moyenne de {0}"
msgid "Average utilization of GPU engines"
msgstr "Utilisation moyenne des moteurs GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Temps de réponse moyen, minimal et maximal"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Moy. 1 h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -258,7 +277,7 @@ msgstr "Bande passante"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Bat"
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
@@ -300,16 +319,29 @@ msgstr "Binaire"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)"
msgstr ""
msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/systemd-table/systemd-table.tsx
msgid "Boot state"
msgstr "État de démarrage"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Ajout groupé"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Ajout groupé de {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Copie groupée"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr ""
msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
@@ -327,6 +359,7 @@ msgstr "Peut démarrer"
msgid "Can stop"
msgstr "Peut arrêter"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Attention - perte de données potentielle"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -360,7 +393,7 @@ msgstr "Modifier les options générales de l'application."
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr ""
msgstr "Charge"
#. Context: Battery state
#: src/lib/i18n.ts
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Erreurs de somme de contrôle"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Erreurs de somme de contrôle"
msgid "Clear"
msgstr "Effacer"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Tout effacer"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Effacer les correspondances"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Cliquez sur un conteneur pour voir plus d'informations."
@@ -432,6 +474,10 @@ msgstr "Instructions en ligne de commande"
msgid "Configure how you receive alert notifications."
msgstr "Configurez comment vous recevez les notifications d'alerte."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Configurez la surveillance du temps de réponse depuis cet agent."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Santé du conteneur"
msgid "Containers"
msgstr "Conteneurs"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Copiez la commande d'installation de l'agent ci-dessous, ou enregistrez
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copiez le contenu du<0>docker-compose.yml</0> pour l'agent ci-dessous, ou enregistrez les agents automatiquement avec un <1>token universel</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Copier vers le système"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copier YAML"
@@ -530,7 +581,7 @@ msgstr "Cœur"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -614,7 +665,7 @@ msgstr "État actuel"
#. Power Cycles
#: src/components/routes/system/smart-table.tsx
msgid "Cycles"
msgstr ""
msgstr "Cycles"
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -630,6 +681,7 @@ msgstr "Par défaut"
msgid "Default time period"
msgstr "Période par défaut"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -643,7 +695,7 @@ msgstr "Supprimer l'empreinte"
#: src/components/systemd-table/systemd-table.tsx
msgid "Description"
msgstr ""
msgstr "Description"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
@@ -696,7 +748,7 @@ msgstr "Entrée/Sortie réseau Docker"
#: src/components/command-palette.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Documentation"
msgstr ""
msgstr "Documentation"
#. Context: System is down
#: src/components/alerts-history-columns.tsx
@@ -718,12 +770,14 @@ msgstr "Télécharger"
msgid "Duration"
msgstr "Durée"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Éditer"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Modifier {foo}"
@@ -732,7 +786,7 @@ msgstr "Modifier {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr ""
msgstr "Email"
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,6 +826,11 @@ msgstr "Entrez votre mot de passe à usage unique."
msgid "Ephemeral"
msgstr "Éphémère"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -826,7 +885,7 @@ msgstr "Exportez la configuration actuelle de vos systèmes."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr ""
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -844,6 +903,11 @@ msgstr "Services en échec"
msgid "Failed to authenticate"
msgstr "Échec de l'authentification"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Échec de la suppression des moniteurs."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Échec de l'envoi de la notification de test"
msgid "Failed to update alert"
msgstr "Échec de la mise à jour de l'alerte"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Échec de la mise à jour des moniteurs."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Ventilateurs"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -924,11 +993,11 @@ msgstr "Général"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr ""
msgstr "Global"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -1009,16 +1078,27 @@ msgstr "Si vous avez perdu le mot de passe de votre compte administrateur, vous
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr ""
msgstr "Image"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Mise à jour de limage disponible"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Inactif"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervalle"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Intervalle (secondes)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Adresse email invalide."
@@ -1097,6 +1177,15 @@ msgstr "Journaux"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Vous cherchez plutôt où créer des alertes ? Cliquez sur les icônes de cloche <0/> dans le tableau des systèmes."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Perte"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Perte 1 h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "PID principal"
@@ -1113,7 +1202,11 @@ msgstr "Guide pour une installation manuelle"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr ""
msgstr "Max 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Max. 1 h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
@@ -1141,11 +1234,25 @@ msgstr "Utilisation de la mémoire"
msgid "Memory usage of containers"
msgstr "Utilisation de la mémoire des conteneurs"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min. 1 h"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Modèle"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Moniteur"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Moniteurs créés"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Point de montage"
@@ -1163,6 +1270,29 @@ msgstr "Nom"
msgid "Net"
msgstr "Rés"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Réseau"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Moniteur réseau"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Perte du moniteur réseau"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Moniteurs réseau"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Trafic réseau des conteneurs"
@@ -1197,6 +1327,7 @@ msgstr "Aucun résultat trouvé."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Aucun résultat."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Aucun attribut S.M.A.R.T. disponible pour cet appareil."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1220,7 +1352,7 @@ msgstr "Aucun"
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
msgid "Notifications"
msgstr ""
msgstr "Notifications"
#: src/lib/alerts.ts
msgid "Notifications may include recent container log excerpts."
@@ -1238,6 +1370,11 @@ msgstr "À chaque redémarrage, les systèmes dans la base de données seront mi
msgid "One or more containers are unhealthy"
msgstr "Un ou plusieurs conteneurs sont défaillants"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Un ou plusieurs moniteurs dépassent une perte de {0} %"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Un ou plusieurs services sont en état d’échec"
@@ -1251,6 +1388,7 @@ msgstr "Unique"
msgid "One-time password"
msgstr "Mot de passe à usage unique"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,11 +1410,16 @@ msgstr "Autre"
msgid "Overwrite existing alerts"
msgstr "Écraser les alertes existantes"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Perte de paquets (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Page"
msgstr ""
msgstr "Page"
#. placeholder {0}: table.getState().pagination.pageIndex + 1
#. placeholder {1}: table.getPageCount()
@@ -1309,9 +1452,10 @@ msgstr "Demande de réinitialisation du mot de passe reçue"
msgid "Past"
msgstr "Passé"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr ""
msgstr "Pause"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Paused"
@@ -1340,7 +1484,7 @@ msgstr "Pourcentage de temps passé dans chaque état"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent"
msgstr ""
msgstr "Permanent"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence"
@@ -1392,13 +1536,14 @@ msgid "Pool Usage"
msgstr "Utilisation du pool"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr ""
msgstr "Ports"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
@@ -1418,6 +1563,11 @@ msgstr "Langue préférée"
msgid "Process started"
msgstr "Processus démarré"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protocole"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Clé publique"
@@ -1503,10 +1653,20 @@ msgstr "Réinitialiser le mot de passe"
msgid "Resolved"
msgstr "Résolu"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Réponse"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Surveillance du temps de réponse depuis les agents."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Redémarrages"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Reprendre"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "Auto-test S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Enregistrer {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Recherche"
msgid "Search for systems or settings..."
msgstr "Rechercher des systèmes ou des paramètres..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Rechercher des systèmes"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Secondes entre les pings (par défaut : 60)"
@@ -1587,6 +1753,27 @@ msgstr "Voir les <0>paramètres de notification</0> pour configurer comment vous
msgid "Select {foo}"
msgstr "Sélectionner {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Sélectionner un système"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Tout sélectionner"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Sélectionner les correspondances"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Sélectionner la ligne"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Sélectionner des systèmes"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Envoyez un seul ping heartbeat pour vérifier que votre point de terminaison fonctionne."
@@ -1614,7 +1801,7 @@ msgstr "Détails du service"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr ""
msgstr "Services"
#: src/components/routes/settings/general.tsx
msgid "Set percentage thresholds for meter colors."
@@ -1692,6 +1879,8 @@ msgstr "Changer de thème"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Services systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Systèmes"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Onglets"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Cible"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Tâches"
@@ -1779,6 +1975,7 @@ msgstr "Ensuite, connectez-vous au backend et réinitialisez le mot de passe de
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Cette action ne peut pas être annulée. Cela supprimera définitivement tous les enregistrements actuels pour {name} de la base de données."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
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."
@@ -1802,7 +1999,7 @@ msgstr "Aux email(s)"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1821,7 +2018,7 @@ msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connex
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr ""
msgstr "Total"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
@@ -1901,6 +2098,10 @@ msgstr "Déclenchement lorsque l'utilisation du GPU dépasse un seuil"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Déclenchement lorsque l'utilisation de la mémoire dépasse un seuil"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Se déclenche lorsque la perte sur une heure dépasse un seuil"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Se déclenche lorsque le statut passe de \"Joignable\" à \"Injoignable\""
@@ -1914,7 +2115,7 @@ msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type"
msgstr ""
msgstr "Type"
#: src/lib/alerts.ts
msgid "Unhealthy"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Mettre à jour"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Oui"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Vos paramètres utilisateur ont été mis à jour."
+206 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: he\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Hebrew\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);\n"
@@ -30,6 +30,12 @@ msgstr "{0} זמין"
msgid "{0} of {1} row(s) selected."
msgstr "{0} מתוך {1} שורה(ות) נבחרו."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "נבחרו {0}"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# ליבה} other {# ליבות}}"
@@ -121,6 +127,10 @@ msgstr "מצב פעיל"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "כל המערכות"
msgid "Are you sure you want to delete {name}?"
msgstr "האם אתה בטוח שברצונך למחוק את {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "האם אתה בטוח?"
@@ -245,6 +256,14 @@ msgstr "ניצול ממוצע של {0}"
msgid "Average utilization of GPU engines"
msgstr "ניצול ממוצע של מנועי GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "זמן תגובה ממוצע, מינימלי ומקסימלי"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "ממוצע שעה אחת"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "ביטים (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "מצב אתחול"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "הוספה מרוכזת"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "הוספה מרוכזת של {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "העתקה מרוכזת"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "יכול להתחיל"
msgid "Can stop"
msgstr "יכול לעצור"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "שגיאות סכום ביקורת"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "שגיאות סכום ביקורת"
msgid "Clear"
msgstr "נקה"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "נקה הכול"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "נקה התאמות"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "לחץ על קונטיינר כדי לצפות במידע נוסף."
@@ -432,6 +474,10 @@ msgstr "הוראות שורת פקודה"
msgid "Configure how you receive alert notifications."
msgstr "הגדר כיצד לקבל התראות."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "הגדר ניטור תגובות מסוכן זה."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "בריאות הקונטיינר"
msgid "Containers"
msgstr "קונטיינרים"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "העתק את פקודת ההתקנה עבור הסוכן למטה, או
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "העתק את תוכן ה-<0>docker-compose.yml</0> עבור הסוכן למטה, או רשום סוכנים אוטומטית עם <1>token אוניברסלי</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "העתק למערכת"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "העתק YAML"
@@ -530,7 +581,7 @@ msgstr "ליבה"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -630,6 +681,7 @@ msgstr "ברירת מחדל"
msgid "Default time period"
msgstr "תקופת זמן ברירת מחדל"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "הורדה"
msgid "Duration"
msgstr "משך זמן"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "ערוך"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "ערוך {foo}"
@@ -772,6 +826,11 @@ msgstr "הכנס את הסיסמה החד-פעמית שלך."
msgid "Ephemeral"
msgstr "זמני"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -844,6 +903,11 @@ msgstr "שירותים שנכשלו"
msgid "Failed to authenticate"
msgstr "אימות נכשל"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "מחיקת המנטרים נכשלה."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "שליחת התראת בדיקה נכשלה"
msgid "Failed to update alert"
msgstr "עדכון התראה נכשל"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "עדכון המנטרים נכשל."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "מאווררים"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "תמונה"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "עדכון תמונה זמין"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "לא פעיל"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "מרווח"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "מרווח (שניות)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "כתובת אימייל לא תקינה."
@@ -1097,6 +1177,15 @@ msgstr "יומנים"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "מחפש איפה ליצור התראות? לחץ על סמלי הפעמון <0/> בטבלת המערכות."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "אובדן"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "אובדן שעה אחת"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "PID ראשי"
@@ -1115,6 +1204,10 @@ msgstr "הוראות התקנה ידניות"
msgid "Max 1 min"
msgstr "מקס 1 דק'"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "מקסימום שעה אחת"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "שימוש בזיכרון"
msgid "Memory usage of containers"
msgstr "שימוש בזיכרון של קונטיינרים"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "מינימום שעה אחת"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "דגם"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "מנטר"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "המנטרים נוצרו"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "נקודת עגינה"
@@ -1163,6 +1270,29 @@ msgstr "שם"
msgid "Net"
msgstr "רשת"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "רשת"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "מנטר רשת"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "אובדן מנטר רשת"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "מנטרי רשת"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "תעבורת רשת של קונטיינרים"
@@ -1197,6 +1327,7 @@ msgstr "לא נמצאו תוצאות."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "אין תוצאות."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "אין מאפייני S.M.A.R.T. זמינים עבור התקן זה."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "בכל הפעלה מחדש, המערכות במסד הנתונים יע
msgid "One or more containers are unhealthy"
msgstr "קונטיינר אחד או יותר אינם תקינים"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "מנטר אחד או יותר חורגים מאובדן של {0}%"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "שירות אחד או יותר נמצאים במצב כשל"
@@ -1251,6 +1388,7 @@ msgstr "חד-פעמי"
msgid "One-time password"
msgstr "סיסמה חד-פעמית"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "אחר"
msgid "Overwrite existing alerts"
msgstr "דרוס התראות קיימות"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "אובדן מנות (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "בקשת איפוס סיסמה התקבלה"
msgid "Past"
msgstr "עבר"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "השהה"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "שימוש במאגר"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr "פורט"
@@ -1418,6 +1563,11 @@ msgstr "שפה מועדפת"
msgid "Process started"
msgstr "תהליך התחיל"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "פרוטוקול"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "מפתח ציבורי"
@@ -1503,10 +1653,20 @@ msgstr "איפוס סיסמה"
msgid "Resolved"
msgstr "נפתר"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "תגובה"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "ניטור זמן תגובה מסוכנים."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "הפעלות מחדש"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "המשך"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "בדיקה עצמית S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "שמור {foo}"
@@ -1575,6 +1736,11 @@ msgstr "חיפוש"
msgid "Search for systems or settings..."
msgstr "חפש מערכות או הגדרות..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "חיפוש מערכות"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "שניות בין פינגים (ברירת מחדל: 60)"
@@ -1587,6 +1753,27 @@ msgstr "ראה <0>הגדרות התראות</0> כדי להגדיר כיצד א
msgid "Select {foo}"
msgstr "בחר {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "בחר מערכת"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "בחר הכול"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "בחר התאמות"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "בחר שורה"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "בחר מערכות"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "שלח פינג פעימת לב בודד כדי לוודא שנקודת הקצה שלך עובדת."
@@ -1692,6 +1879,8 @@ msgstr "החלף ערכת נושא"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "שירותי Systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "מערכות"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "לשוניות"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "יעד"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "משימות"
@@ -1779,6 +1975,7 @@ msgstr "לאחר מכן התחבר ל-backend ואפס את סיסמת חשבו
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "פעולה זו לא ניתנת לביטול. פעולה זו תמחק לצמיתות את כל הרשומות הנוכחיות עבור {name} ממסד הנתונים."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "פעולה זו תמחק לצמיתות את כל הרשומות שנבחרו ממסד הנתונים."
@@ -1802,7 +1999,7 @@ msgstr "לאימייל(ים)"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1901,6 +2098,10 @@ msgstr "מופעל כאשר שימוש GPU עולה על סף"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "מופעל כאשר שימוש בזיכרון עולה על סף"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "מופעל כאשר אובדן של שעה חורג מסף"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "מופעל כאשר הסטטוס מתחלף בין למעלה ולמטה"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "עדכן"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "כן"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "הגדרות המשתמש שלך עודכנו."
+219 -16
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: hr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:22\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"
@@ -30,6 +30,12 @@ msgstr "{0} dostupno"
msgid "{0} of {1} row(s) selected."
msgstr "{0} od {1} redaka izabrano."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "Odabrano: {0}"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# jezgra} few {# jezgre} other {# jezgri}}"
@@ -121,6 +127,10 @@ msgstr "Aktivno stanje"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -142,7 +152,7 @@ msgstr "Prilagodite širinu glavnog rasporeda"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr ""
msgstr "Admin"
#: src/components/systemd-table/systemd-table.tsx
msgid "After"
@@ -154,7 +164,7 @@ msgstr "Nakon postavljanja varijabli okruženja, ponovno pokrenite svoj Beszel h
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr ""
msgstr "Agent"
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -188,6 +198,7 @@ msgstr "Svi Sustavi"
msgid "Are you sure you want to delete {name}?"
msgstr "Jeste li sigurni da želite izbrisati {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Jeste li sigurni?"
@@ -245,6 +256,14 @@ msgstr "Prosječna iskorištenost {0}"
msgid "Average utilization of GPU engines"
msgstr "Prosječna iskorištenost grafičkih procesora"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Prosječno, minimalno i maksimalno vrijeme odziva"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Prosjek 1 h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -258,7 +277,7 @@ msgstr "Mrežna Propusnost"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr ""
msgstr "Baterija"
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
@@ -306,6 +325,19 @@ msgstr "Bitovi (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Stanje pokretanja"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Skupno dodavanje"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Skupno dodavanje: {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Skupno kopiranje"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "Može se pokrenuti"
msgid "Can stop"
msgstr "Može se zaustaviti"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Oprez - mogući gubitak podataka"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Pogreške kontrolnog zbroja"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Pogreške kontrolnog zbroja"
msgid "Clear"
msgstr "Očisti"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Izbriši sve"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Izbriši podudaranja"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Kliknite na spremnik za prikaz više informacija."
@@ -432,6 +474,10 @@ msgstr "Upute za naredbeni redak"
msgid "Configure how you receive alert notifications."
msgstr "Konfigurirajte način primanja obavijesti upozorenja."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Konfigurirajte praćenje odziva s ovog agenta."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Zdravlje kontejnera"
msgid "Containers"
msgstr "Kontejneri"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Kopirajte instalacijsku komandu za opisanog agenta ili automatski regist
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Kopirajte sadržaj <0>docker-compose.yml</0> datoteke za opisanog agenta ili automatski registrirajte agenta uz pomoć <1>sveopćeg tokena</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Kopiraj u sustav"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Kopiraj YAML"
@@ -630,6 +681,7 @@ msgstr "Zadano"
msgid "Default time period"
msgstr "Zadano vremensko razdoblje"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -661,7 +713,7 @@ msgstr "Prazni se"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr ""
msgstr "Disk"
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
@@ -718,12 +770,14 @@ msgstr "Preuzmi"
msgid "Duration"
msgstr "Trajanje"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Uredi"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Uredi {foo}"
@@ -732,7 +786,7 @@ msgstr "Uredi {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr ""
msgstr "Email"
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,6 +826,11 @@ msgstr "Unesite jednokratnu lozinku."
msgid "Ephemeral"
msgstr "Efemeran"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -844,6 +903,11 @@ msgstr "Neuspjele usluge"
msgid "Failed to authenticate"
msgstr "Neuspješna provjera autentičnosti"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Brisanje monitora nije uspjelo."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Neuspješno slanje probne obavijesti"
msgid "Failed to update alert"
msgstr "Neuspješno ažuriranje upozorenja"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Ažuriranje monitora nije uspjelo."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Ventilatori"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -928,7 +997,7 @@ msgstr "Globalno"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -954,7 +1023,7 @@ msgstr "Zdravlje"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr ""
msgstr "Otkucaj"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -972,7 +1041,7 @@ msgstr "Homebrew naredba"
#: src/components/add-system.tsx
msgid "Host / IP"
msgstr ""
msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
@@ -1011,13 +1080,24 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Slika"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Dostupno je ažuriranje slike"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Neaktivno"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr ""
msgstr "Interval"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Interval (sekunde)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
@@ -1097,6 +1177,15 @@ msgstr "Zapisi"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Tražite gdje stvoriti upozorenja? Kliknite ikonu zvona <0/> u tablici sustava."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Gubitak"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Gubitak 1 h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "Glavni PID"
@@ -1115,6 +1204,10 @@ msgstr "Upute za ručno postavljanje"
msgid "Max 1 min"
msgstr "Maksimalno 1 minuta"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Maks. 1 h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,10 +1234,24 @@ msgstr "Iskorištenost memorije"
msgid "Memory usage of containers"
msgstr "Upotreba memorije spremnika"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min. 1 h"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
msgstr "Model"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Monitor"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Monitori su izrađeni"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
@@ -1163,6 +1270,29 @@ msgstr "Ime"
msgid "Net"
msgstr "Mreža"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Mreža"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Mrežni monitor"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Gubitak mrežnog monitora"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Mrežni monitori"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Mrežni promet spremnika"
@@ -1197,6 +1327,7 @@ msgstr "Nema rezultata."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Nema rezultata."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Nema dostupnih S.M.A.R.T. atributa za ovaj uređaj."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Prilikom svakog ponovnog pokretanja, sustavi u bazi podataka bit će až
msgid "One or more containers are unhealthy"
msgstr "Jedan ili više spremnika nije ispravno"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Jedan ili više monitora prelaze gubitak od {0}%"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Jedna ili više usluga je u stanju neuspjeha"
@@ -1251,6 +1388,7 @@ msgstr "Jednokratno"
msgid "One-time password"
msgstr "Jednokratna lozinka"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Ostalo"
msgid "Overwrite existing alerts"
msgstr "Prebriši postojeća upozorenja"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Gubitak paketa (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "Zahtjev za ponovno postavljanje lozinke zaprimljen"
msgid "Past"
msgstr "Prošlost"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Pauza"
@@ -1392,8 +1536,9 @@ msgid "Pool Usage"
msgstr "Iskorištenost spremišta"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
@@ -1418,6 +1563,11 @@ msgstr "Preferirani jezik"
msgid "Process started"
msgstr "Proces pokrenut"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokol"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Javni ključ"
@@ -1503,10 +1653,20 @@ msgstr "Resetiraj Lozinku"
msgid "Resolved"
msgstr "Razrješeno"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Odziv"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Praćenje vremena odziva agenata."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Ponovna pokretanja"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Nastavi"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Samotestiranje"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Spremi {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Pretraži"
msgid "Search for systems or settings..."
msgstr "Pretraži sustave ili postavke..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Pretraži sustave"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Sekunde između pingova (zadano: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Pogledajte <0>postavke obavijesti</0> kako biste konfigurirali način pr
msgid "Select {foo}"
msgstr "Odaberi {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Odaberite sustav"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Odaberi sve"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Odaberi podudaranja"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Odaberi redak"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Odaberi sustave"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Pošaljite jedan heartbeat ping kako biste provjerili radi li vaša krajnja točka."
@@ -1669,7 +1856,7 @@ msgstr "Stanje"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr ""
msgstr "Status"
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State"
@@ -1692,6 +1879,8 @@ msgstr "Promijeni temu"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Systemd servisi"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Sustavi"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Kartice"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Cilj"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Zadaci"
@@ -1740,7 +1936,7 @@ msgstr "Zadaci"
#: src/components/routes/system/smart-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Temp"
msgstr ""
msgstr "Temp"
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
@@ -1779,6 +1975,7 @@ msgstr "Zatim se prijavite u backend i resetirajte lozinku korisničkog računa
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Ova radnja ne može se poništiti. Svi trenutni zapisi za {name} bit će trajno izbrisani iz baze podataka."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "Ovom radnjom će se trajno izbrisati svi odabrani zapisi iz baze podataka."
@@ -1802,7 +1999,7 @@ msgstr "Primaoci emaila"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1901,6 +2098,10 @@ msgstr "Pokreće se kada iskorištenost GPU-a premaši prag"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Pokreće se kada iskorištenost memorije premaši prag"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Pokreće se kada jednosatni gubitak prijeđe prag"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Pokreće se kada se status sustava promijeni"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Ažuriraj"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Da"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Vaše korisničke postavke su ažurirane."
+214 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: hu\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Hungarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "{0} elérhető"
msgid "{0} of {1} row(s) selected."
msgstr "{0} a(z) {1} sorból kiválasztva."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} kiválasztva"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# mag} other {# mag}}"
@@ -48,7 +54,7 @@ msgstr "{count, plural, one {{countString} perc} few {{countString} perc} many {
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr ""
msgstr "{diskName} I/O"
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -121,6 +127,10 @@ msgstr "Aktív állapot"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "Minden rendszer"
msgid "Are you sure you want to delete {name}?"
msgstr "Biztosan törölni szeretnéd {name}-t?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Biztos vagy benne?"
@@ -245,6 +256,14 @@ msgstr "{0} átlagos kihasználtsága"
msgid "Average utilization of GPU engines"
msgstr "GPU-k átlagos kihasználtsága"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Átlagos, minimális és maximális válaszidő"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Átl. 1 óra"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "Bitek (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Indítási állapot"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Tömeges hozzáadás"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "{foo} tömeges hozzáadása"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Tömeges másolás"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "Indítható"
msgid "Can stop"
msgstr "Leállítható"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Figyelem - potenciális adatvesztés"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Ellenőrzőösszeg-hibák"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Ellenőrzőösszeg-hibák"
msgid "Clear"
msgstr "Törlés"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Összes törlése"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Egyezések törlése"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Kattintson egy konténerre a további információk megtekintéséhez."
@@ -432,6 +474,10 @@ msgstr "Parancssori utasítások"
msgid "Configure how you receive alert notifications."
msgstr "Konfiguráld, hogyan kapod az értesítéseket."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Válaszfigyelés beállítása erről az agentről."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Konténer állapota"
msgid "Containers"
msgstr "Konténerek"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Másold az alábbi ügynök telepítési parancsát, vagy regisztráld a
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Másold az alábbi ügynök <0>docker-compose.yml</0> tartalmát, vagy regisztráld az ügynököket automatikusan egy <1>univerzális tokennel</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Másolás a rendszerbe"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "YAML másolása"
@@ -530,7 +581,7 @@ msgstr "Mag"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -630,6 +681,7 @@ msgstr "Alapértelmezett"
msgid "Default time period"
msgstr "Alapértelmezett időszak"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -718,12 +770,14 @@ msgstr "Letöltés"
msgid "Duration"
msgstr "Időtartam"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Szerkesztés"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Szerkesztés {foo}"
@@ -732,7 +786,7 @@ msgstr "Szerkesztés {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr ""
msgstr "Email"
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,6 +826,11 @@ msgstr "Adja meg az egyszeri jelszavát."
msgid "Ephemeral"
msgstr "Ideiglenes"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -826,7 +885,7 @@ msgstr "Exportálja a jelenlegi rendszerkonfigurációt."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr ""
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -844,6 +903,11 @@ msgstr "Sikertelen szolgáltatások"
msgid "Failed to authenticate"
msgstr "Hitelesítés sikertelen"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "A monitorok törlése sikertelen."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Teszt értesítés elküldése sikertelen"
msgid "Failed to update alert"
msgstr "Nem sikerült frissíteni a riasztást"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "A monitorok frissítése sikertelen."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Ventilátorok"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -888,7 +957,7 @@ msgstr "Ujjlenyomat"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr ""
msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -928,7 +997,7 @@ msgstr "Globális"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -954,7 +1023,7 @@ msgstr "Egészség"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr ""
msgstr "Szívverés"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Kép"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Képfrissítés érhető el"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Inaktív"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervallum"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Intervallum (másodperc)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Érvénytelen e-mail cím."
@@ -1097,6 +1177,15 @@ msgstr "Naplók"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Inkább azt keresi, hogy hol hozhat létre riasztásokat? Kattintson a csengő <0/> ikonokra a rendszerek táblázatában."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Veszteség"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Veszteség 1 óra"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "Fő PID"
@@ -1115,6 +1204,10 @@ msgstr "Manuális beállítási lépések"
msgid "Max 1 min"
msgstr "Maximum 1 perc"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Max. 1 óra"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,11 +1234,25 @@ msgstr "Memóriahasználat"
msgid "Memory usage of containers"
msgstr "Konténerek memóriahasználata"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min. 1 óra"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Modell"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Monitor"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Monitorok létrehozva"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Csatolási pont"
@@ -1163,6 +1270,29 @@ msgstr "Név"
msgid "Net"
msgstr "Hálózat"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Hálózat"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Hálózati monitor"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Hálózati monitor vesztesége"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Hálózati monitorok"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Konténerek hálózati forgalma"
@@ -1197,6 +1327,7 @@ msgstr "Nincs találat."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Nincs találat."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Ehhez az eszközhöz nem állnak rendelkezésre S.M.A.R.T. attribútumok."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Minden újraindításkor az adatbázisban lévő rendszerek frissítésr
msgid "One or more containers are unhealthy"
msgstr "Egy vagy több konténer állapota nem megfelelő"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Egy vagy több monitor vesztesége meghaladja a(z) {0}%-ot"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Egy vagy több szolgáltatás sikertelen állapotban van"
@@ -1251,6 +1388,7 @@ msgstr "Egyszeri"
msgid "One-time password"
msgstr "Egyszeri jelszó"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Egyéb"
msgid "Overwrite existing alerts"
msgstr "Felülírja a meglévő riasztásokat"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Csomagvesztés (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "Jelszó-visszaállítási kérelmet kaptunk"
msgid "Past"
msgstr "Múlt"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Szüneteltetés"
@@ -1392,8 +1536,9 @@ msgid "Pool Usage"
msgstr "Tárkészlet kihasználtsága"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
@@ -1418,6 +1563,11 @@ msgstr "Preferált nyelv"
msgid "Process started"
msgstr "Folyamat elindítva"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokoll"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Nyilvános kulcs"
@@ -1503,10 +1653,20 @@ msgstr "Jelszó visszaállítása"
msgid "Resolved"
msgstr "Megoldva"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Válasz"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Válaszidő figyelése az agentektől."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Újraindítások"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Folytatás"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Önteszt"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "{foo} mentése"
@@ -1575,6 +1736,11 @@ msgstr "Keresés"
msgid "Search for systems or settings..."
msgstr "Keresés rendszerek vagy beállítások után..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Rendszerek keresése"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Pingek közötti másodpercek (alapértelmezett: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Lásd <0>az értesítési beállításokat</0>, hogy konfigurálja, hogy
msgid "Select {foo}"
msgstr "{foo} kiválasztása"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Válasszon rendszert"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Összes kijelölése"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Egyezések kijelölése"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Sor kijelölése"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Rendszerek kijelölése"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Küldjön egyetlen heartbeat pinget a végpont működésének ellenőrzéséhez."
@@ -1692,6 +1879,8 @@ msgstr "Téma váltása"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Systemd szolgáltatások"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Rendszer"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Lapok"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Cél"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Feladatok"
@@ -1779,6 +1975,7 @@ msgstr "Ezután jelentkezzen be a backendbe, és állítsa vissza a felhasznál
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Ezt a műveletet nem lehet visszavonni! Véglegesen törli a {name} összes jelenlegi rekordját az adatbázisból!"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
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."
@@ -1802,7 +1999,7 @@ msgstr "E-mailben"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1901,6 +2098,10 @@ msgstr "Riaszt, ha a GPU használat túllép egy küszöbértéket"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Riaszt, ha a memóriahasználat túllép egy küszöbértéket"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Akkor aktiválódik, ha az egyórás veszteség meghalad egy küszöbértéket"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Riaszt, amikor a rendszer online állapota változik"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Frissítés"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Igen"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "A felhasználói beállítások frissítésre kerültek."
+229 -26
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: id\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:22\n"
"Last-Translator: \n"
"Language-Team: Indonesian\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -30,6 +30,12 @@ msgstr "{0} tersedia"
msgid "{0} of {1} row(s) selected."
msgstr "{0} dari {1} baris terpilih."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} dipilih"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# inti} other {# inti}}"
@@ -121,6 +127,10 @@ msgstr "Status aktif"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -142,7 +152,7 @@ msgstr "Sesuaikan lebar layar utama"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr ""
msgstr "Admin"
#: src/components/systemd-table/systemd-table.tsx
msgid "After"
@@ -188,6 +198,7 @@ msgstr "Semua Sistem"
msgid "Are you sure you want to delete {name}?"
msgstr "Apakah anda yakin ingin menghapus {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Apakah anda yakin?"
@@ -245,6 +256,14 @@ msgstr "Rata-rata utilisasi {0}"
msgid "Average utilization of GPU engines"
msgstr "Rata-rata utilisasi GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Waktu respons rata-rata, minimum, dan maksimum"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Rata-rata 1 jam"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "Bit (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Status memulai"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Tambah massal"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Tambah {foo} secara massal"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Salin massal"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -313,7 +345,7 @@ msgstr "Byte (KB/s, MB/s, GB/s)"
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr ""
msgstr "Cache / Buffers"
#: src/components/systemd-table/systemd-table.tsx
msgid "Can reload"
@@ -327,6 +359,7 @@ msgstr "Dapat dimulai"
msgid "Can stop"
msgstr "Dapat diberhentikan"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Perhatian - potensi kehilangan data"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Kesalahan checksum"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Kesalahan checksum"
msgid "Clear"
msgstr "Bersihkan"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Hapus semua"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Hapus kecocokan"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Klik pada kontainer untuk melihat informasi lebih banyak."
@@ -432,6 +474,10 @@ msgstr "Instruksi command line"
msgid "Configure how you receive alert notifications."
msgstr "Konfigurasi bagaimana anda menerima notifikasi peringatan."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Konfigurasikan pemantauan respons dari agen ini."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -457,6 +503,7 @@ msgstr "Kesehatan Kontainer"
msgid "Containers"
msgstr "Kontainer"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Salin perintah instalasi untuk agen di bawah, atau daftarkan agen secara
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Salin konten <0>docker-compose.yml</0> untuk agen di bawah, atau daftarkan agen secara otomatis dengan <1>universal token</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Salin ke sistem"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Salin YAML"
@@ -530,7 +581,7 @@ msgstr "Inti"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -624,12 +675,13 @@ msgstr "Harian"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr ""
msgstr "Bawaan"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "Standar waktu"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -647,7 +699,7 @@ msgstr "Deskripsi"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr ""
msgstr "Detail"
#: src/components/routes/system/smart-table.tsx
msgid "Device"
@@ -661,7 +713,7 @@ msgstr "Sedang tidak di charge"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr ""
msgstr "Disk"
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
@@ -718,12 +770,14 @@ msgstr "Unduh"
msgid "Duration"
msgstr "Durasi"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Ubah"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Ubah {foo}"
@@ -732,7 +786,7 @@ msgstr "Ubah {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr ""
msgstr "Email"
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,6 +826,11 @@ msgstr "Masukkan otp anda."
msgid "Ephemeral"
msgstr "Sementara"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -783,7 +842,7 @@ msgstr "Sementara"
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Error"
msgstr ""
msgstr "Kesalahan"
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
@@ -826,7 +885,7 @@ msgstr "Export konfigurasi sistem anda saat ini."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr ""
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -844,6 +903,11 @@ msgstr "Layanan gagal"
msgid "Failed to authenticate"
msgstr "Gagal mengautentikasi"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Gagal menghapus monitor."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Gagal mengirim tes notifikasi"
msgid "Failed to update alert"
msgstr "Gagal memperbarui peringatan"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Gagal memperbarui monitor."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Kipas"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -888,7 +957,7 @@ msgstr "Sidik jari"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr ""
msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -924,11 +993,11 @@ msgstr "Umum"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr ""
msgstr "Global"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -954,7 +1023,7 @@ msgstr "Kesehatan"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr ""
msgstr "Denyut jantung"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -972,7 +1041,7 @@ msgstr "Perintah Homebrew"
#: src/components/add-system.tsx
msgid "Host / IP"
msgstr ""
msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
@@ -985,7 +1054,7 @@ 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 ""
msgstr "I/O Await"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O total time spent on read/write"
@@ -1000,7 +1069,7 @@ msgstr "Utilisasi I/O"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
msgstr ""
msgstr "Menganggur"
#: src/components/login/forgot-pass-form.tsx
msgid "If you've lost the password to your admin account, you may reset it using the following command."
@@ -1011,13 +1080,24 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Gambar"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Pembaruan gambar tersedia"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Tidak aktif"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr ""
msgstr "Interval"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Interval (detik)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
@@ -1097,6 +1177,15 @@ msgstr "Log"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Mencari tempat untuk membuat peringatan? Klik ikon lonceng <0/> di tabel sistem."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Kehilangan paket"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Kehilangan paket 1 jam"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "PID utama"
@@ -1115,6 +1204,10 @@ msgstr "Instruksi setup manual"
msgid "Max 1 min"
msgstr "Maks 1 mnt"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Maks. 1 jam"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -1141,10 +1234,24 @@ msgstr "Penggunaan Memori"
msgid "Memory usage of containers"
msgstr "Penggunaan memori container"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min. 1 jam"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
msgstr "Model"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Monitor"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Monitor dibuat"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
@@ -1163,6 +1270,29 @@ msgstr "Nama"
msgid "Net"
msgstr "Jaringan"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Jaringan"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Monitor jaringan"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Kehilangan paket monitor jaringan"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Monitor jaringan"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Lalu lintas jaringan container"
@@ -1197,6 +1327,7 @@ msgstr "Tidak ada hasil ditemukan."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Tidak ada hasil."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Tidak ada atribut S.M.A.R.T. yang tersedia untuk perangkat ini."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Pada setiap restart, sistem dalam database akan diperbarui untuk mencoco
msgid "One or more containers are unhealthy"
msgstr "Satu atau beberapa kontainer tidak sehat"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Satu atau beberapa monitor melebihi kehilangan paket {0}%"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Satu atau beberapa layanan berada dalam status gagal"
@@ -1251,6 +1388,7 @@ msgstr "Sekali pakai"
msgid "One-time password"
msgstr "Kata sandi sekali pakai (OTP)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Lainnya"
msgid "Overwrite existing alerts"
msgstr "Timpa peringatan yang ada"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Kehilangan paket (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1309,6 +1452,7 @@ msgstr "Permintaan reset kata sandi diterima"
msgid "Past"
msgstr "Lalu"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Jeda"
@@ -1392,8 +1536,9 @@ msgid "Pool Usage"
msgstr "Penggunaan pool"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr ""
msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
@@ -1418,6 +1563,11 @@ msgstr "Pilihan Bahasa"
msgid "Process started"
msgstr "Proses dimulai"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokol"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Kunci publik"
@@ -1503,10 +1653,20 @@ msgstr "Reset Kata Sandi"
msgid "Resolved"
msgstr "Diselesaikan"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Respons"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Pemantauan waktu respons dari agen."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Restart"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Lanjutkan"
@@ -1516,7 +1676,7 @@ msgstr "Lanjutkan"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr ""
msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "Self-Test S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Simpan {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Cari"
msgid "Search for systems or settings..."
msgstr "Cari sistem atau pengaturan..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Cari sistem"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Detik di antara ping (default: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Lihat <0>pengaturan notifikasi</0> untuk mengkonfigurasi bagaimana anda
msgid "Select {foo}"
msgstr "Pilih {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Pilih sistem"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Pilih semua"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Pilih kecocokan"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Pilih baris"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Pilih sistem"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Kirim satu ping heartbeat untuk memverifikasi titik akhir Anda berfungsi."
@@ -1669,7 +1856,7 @@ msgstr "Status"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr ""
msgstr "Status"
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State"
@@ -1692,6 +1879,8 @@ msgstr "Ganti tema"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Layanan Systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Sistem"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Tab"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Target"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Tugas"
@@ -1779,6 +1975,7 @@ msgstr "Kemudian masuk ke backend dan reset kata sandi akun pengguna anda di tab
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Aksi ini tidak dapat di kembalikan. ini akan menghapus permanen semua record {name} dari database"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "Ini akan menghapus secara permanen semua record yang dipilih dari database."
@@ -1802,7 +1999,7 @@ msgstr "Ke email"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1821,7 +2018,7 @@ msgstr "Token dan Fingerprint digunakan untuk mengautentikasi koneksi WebSocket
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr ""
msgstr "Total"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
@@ -1839,7 +2036,7 @@ msgstr "Total waktu yang dihabiskan untuk baca/tulis (dapat melebihi 100%)"
#. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
msgstr ""
msgstr "Total: {0}"
#: src/components/systemd-table/systemd-table.tsx
msgid "Triggered by"
@@ -1901,6 +2098,10 @@ msgstr "Dipicu ketika penggunaan GPU melebihi ambang batas"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Dipicu ketika penggunaan memori melebihi ambang batas"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Terpicu saat kehilangan paket selama satu jam melebihi ambang batas"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Dipicu ketika status beralih antara up dan down"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Perbarui"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Ya"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Pengaturan pengguna anda telah diperbarui."
+223 -20
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -30,6 +30,12 @@ msgstr "{0} disponibile"
msgid "{0} of {1} row(s) selected."
msgstr "{0} di {1} righe selezionate."
#. placeholder {0}: selectedSystemIds.size
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "{0} selected"
msgstr "{0} selezionati"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# core} other {# core}}"
@@ -61,7 +67,7 @@ msgstr "1 ora"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr ""
msgstr "1 min"
#: src/lib/utils.ts
msgid "1 minute"
@@ -78,7 +84,7 @@ msgstr "12 ore"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr ""
msgstr "15 min"
#: src/lib/utils.ts
msgid "24 hours"
@@ -91,7 +97,7 @@ msgstr "30 giorni"
#. Load average
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr ""
msgstr "5 min"
#. Table column
#: src/components/routes/settings/quiet-hours.tsx
@@ -121,6 +127,10 @@ msgstr "Stato attivo"
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
@@ -188,6 +198,7 @@ msgstr "Tutti i Sistemi"
msgid "Are you sure you want to delete {name}?"
msgstr "Sei sicuro di voler eliminare {name}?"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Are you sure?"
msgstr "Sei sicuro?"
@@ -245,6 +256,14 @@ msgstr "Utilizzo medio di {0}"
msgid "Average utilization of GPU engines"
msgstr "Utilizzo medio dei motori GPU"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Average, minimum, and maximum response time"
msgstr "Tempo di risposta medio, minimo e massimo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Media 1 h"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
@@ -306,6 +325,19 @@ msgstr "Bit (Kbps, Mbps, Gbps)"
msgid "Boot state"
msgstr "Stato di avvio"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add"
msgstr "Aggiunta in blocco"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Bulk Add {foo}"
msgstr "Aggiunta in blocco di {foo}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Bulk copy"
msgstr "Copia in blocco"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
@@ -327,6 +359,7 @@ msgstr "Può avviare"
msgid "Can stop"
msgstr "Può fermare"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -348,7 +381,7 @@ msgstr "Attenzione - possibile perdita di dati"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr ""
msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -396,6 +429,7 @@ msgid "Checksum errors"
msgstr "Errori di checksum"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
@@ -403,6 +437,14 @@ msgstr "Errori di checksum"
msgid "Clear"
msgstr "Cancella"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear all"
msgstr "Cancella tutto"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches"
msgstr "Cancella corrispondenze"
#: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information."
msgstr "Fare clic su un contenitore per visualizzare ulteriori informazioni."
@@ -432,6 +474,10 @@ msgstr "Istruzioni da riga di comando"
msgid "Configure how you receive alert notifications."
msgstr "Configura come ricevere le notifiche di avviso."
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Configure response monitoring from this agent."
msgstr "Configura il monitoraggio della risposta da questo agente."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Confirm password"
@@ -447,7 +493,7 @@ msgstr "La connessione è interrotta"
#: src/lib/alerts.ts
msgid "Container"
msgstr ""
msgstr "Container"
#: src/lib/alerts.ts
msgid "Container Health"
@@ -457,6 +503,7 @@ msgstr "Stato del container"
msgid "Containers"
msgstr "Container"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -517,6 +564,10 @@ msgstr "Copia il comando di installazione per l'agente qui sotto, o registra gli
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
msgstr "Copia il contenuto<0>docker-compose.yml</0> per l'agente qui sotto, o registra gli agenti automaticamente con un <1>token universale</1>."
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Copy to system"
msgstr "Copia nel sistema"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy YAML"
msgstr "Copia YAML"
@@ -530,7 +581,7 @@ msgstr "Interne"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr ""
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -630,6 +681,7 @@ msgstr "Predefinito"
msgid "Default time period"
msgstr "Periodo di tempo predefinito"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
@@ -679,7 +731,7 @@ msgstr "Utilizzo del disco di {extraFsName}"
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr ""
msgstr "Display"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
@@ -718,12 +770,14 @@ msgstr "Scarica"
msgid "Duration"
msgstr "Durata"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Edit"
msgstr "Modifica"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Modifica {foo}"
@@ -732,7 +786,7 @@ msgstr "Modifica {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr ""
msgstr "Email"
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -772,6 +826,11 @@ msgstr "Inserisci la tua password monouso."
msgid "Ephemeral"
msgstr "Effimero"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
#: src/components/routes/settings/heartbeat.tsx
@@ -826,7 +885,7 @@ msgstr "Esporta la configurazione attuale dei tuoi sistemi."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr ""
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -844,6 +903,11 @@ msgstr "Servizi non riusciti"
msgid "Failed to authenticate"
msgstr "Autenticazione fallita"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to delete monitors."
msgstr "Impossibile eliminare i monitor."
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -862,6 +926,10 @@ msgstr "Invio della notifica di test fallito"
msgid "Failed to update alert"
msgstr "Aggiornamento dell'avviso fallito"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Failed to update monitors."
msgstr "Impossibile aggiornare i monitor."
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
#: src/components/systemd-table/systemd-table.tsx
msgid "Failed: {0}"
@@ -872,6 +940,7 @@ msgid "Fans"
msgstr "Ventole"
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
@@ -888,7 +957,7 @@ msgstr "Impronta digitale"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr ""
msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -928,7 +997,7 @@ msgstr "Globale"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
@@ -954,7 +1023,7 @@ msgstr "Stato"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr ""
msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -972,7 +1041,7 @@ msgstr "Comando Homebrew"
#: src/components/add-system.tsx
msgid "Host / IP"
msgstr ""
msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
@@ -1011,14 +1080,25 @@ msgctxt "Docker image"
msgid "Image"
msgstr "Immagine"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image update available"
msgstr "Aggiornamento dellimmagine disponibile"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
msgstr "Inattivo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Intervallo"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Interval (seconds)"
msgstr "Intervallo (secondi)"
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
msgstr "Indirizzo email non valido."
@@ -1097,6 +1177,15 @@ msgstr "Log"
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
msgstr "Cerchi invece dove creare avvisi? Clicca sulle icone della campana <0/> nella tabella dei sistemi."
#: src/components/routes/system/charts/monitors-charts.tsx
msgctxt "Packet loss"
msgid "Loss"
msgstr "Perdita"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Perdita 1 h"
#: src/components/systemd-table/systemd-table.tsx
msgid "Main PID"
msgstr "PID principale"
@@ -1113,7 +1202,11 @@ msgstr "Istruzioni di configurazione manuale"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr ""
msgstr "Max 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Max 1 h"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
@@ -1141,11 +1234,25 @@ msgstr "Utilizzo Memoria"
msgid "Memory usage of containers"
msgstr "Utilizzo della memoria dei container"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min 1 h"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Modello"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitor"
msgstr "Monitor"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Monitors created"
msgstr "Monitor creati"
#: src/components/routes/system/storage-pools-table.tsx
msgid "Mountpoint"
msgstr "Punto di montaggio"
@@ -1163,6 +1270,29 @@ msgstr "Nome"
msgid "Net"
msgstr "Rete"
#: src/components/routes/system.tsx
msgid "Network"
msgstr "Rete"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Network Monitor"
msgstr "Monitor di rete"
#: src/lib/alerts.ts
msgid "Network Monitor Loss"
msgstr "Perdita del monitor di rete"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/monitors.tsx
msgid "Network Monitors"
msgstr "Monitor di rete"
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers"
msgstr "Traffico di rete dei container"
@@ -1183,7 +1313,7 @@ msgstr "Unità rete"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "No"
msgstr ""
msgstr "No"
#: src/components/routes/system/storage-pools-table.tsx
msgid "No detail data for this pool."
@@ -1197,6 +1327,7 @@ msgstr "Nessun risultato trovato."
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1207,6 +1338,7 @@ msgstr "Nessun risultato."
msgid "No S.M.A.R.T. attributes available for this device."
msgstr "Nessun attributo S.M.A.R.T. disponibile per questo dispositivo."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -1238,6 +1370,11 @@ msgstr "Ad ogni riavvio, i sistemi nel database verranno aggiornati per corrispo
msgid "One or more containers are unhealthy"
msgstr "Uno o più container non sono integri"
#. placeholder {0}: alert.value
#: src/components/active-alerts.tsx
msgid "One or more monitors exceed {0}% loss"
msgstr "Uno o più monitor superano una perdita del {0}%"
#: src/lib/alerts.ts
msgid "One or more services are in a failed state"
msgstr "Uno o più servizi sono in stato di errore"
@@ -1251,6 +1388,7 @@ msgstr "Una volta"
msgid "One-time password"
msgstr "Password monouso"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1272,6 +1410,11 @@ msgstr "Altro"
msgid "Overwrite existing alerts"
msgstr "Sovrascrivi avvisi esistenti"
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Packet loss (%)"
msgstr "Perdita di pacchetti (%)"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
@@ -1291,7 +1434,7 @@ msgstr "Pagine / Impostazioni"
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
msgid "Password"
msgstr ""
msgstr "Password"
#: src/components/login/auth-form.tsx
msgid "Password must be at least 8 characters."
@@ -1309,6 +1452,7 @@ msgstr "Richiesta di reimpostazione password ricevuta"
msgid "Past"
msgstr "Passato"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Pausa"
@@ -1392,6 +1536,7 @@ msgid "Pool Usage"
msgstr "Utilizzo del pool"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Port"
msgstr "Porta"
@@ -1418,6 +1563,11 @@ msgstr "Lingua Preferita"
msgid "Process started"
msgstr "Processo avviato"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protocollo"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key"
msgstr "Chiave pubblica"
@@ -1503,10 +1653,20 @@ msgstr "Reimposta Password"
msgid "Resolved"
msgstr "Risolto"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
msgstr "Risposta"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Response time monitoring from agents."
msgstr "Monitoraggio del tempo di risposta dagli agenti."
#: src/components/systemd-table/systemd-table.tsx
msgid "Restarts"
msgstr "Riavvii"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Resume"
msgstr "Riprendi"
@@ -1516,7 +1676,7 @@ msgstr "Riprendi"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr ""
msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1539,6 +1699,7 @@ msgid "S.M.A.R.T. Self-Test"
msgstr "Autotest S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Salva {foo}"
@@ -1575,6 +1736,11 @@ msgstr "Cerca"
msgid "Search for systems or settings..."
msgstr "Cerca sistemi o impostazioni..."
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Search systems"
msgstr "Cerca sistemi"
#: src/components/routes/settings/heartbeat.tsx
msgid "Seconds between pings (default: 60)"
msgstr "Secondi tra i ping (predefinito: 60)"
@@ -1587,6 +1753,27 @@ msgstr "Vedi <0>impostazioni di notifica</0> per configurare come ricevere gli a
msgid "Select {foo}"
msgstr "Seleziona {foo}"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select a system"
msgstr "Seleziona un sistema"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select all"
msgstr "Seleziona tutto"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select matches"
msgstr "Seleziona corrispondenze"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Select row"
msgstr "Seleziona riga"
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Select systems"
msgstr "Seleziona sistemi"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Invia un singolo ping di heartbeat per verificare che l'endpoint funzioni."
@@ -1692,6 +1879,8 @@ msgstr "Cambia tema"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1716,6 +1905,8 @@ msgid "Systemd Services"
msgstr "Servizi Systemd"
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Systems"
msgstr "Sistemi"
@@ -1732,6 +1923,11 @@ msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Schede"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Destinazione"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Attività"
@@ -1757,7 +1953,7 @@ msgstr "Temperature dei sensori di sistema"
#: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>"
msgstr ""
msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
@@ -1779,6 +1975,7 @@ msgstr "Quindi accedi al backend e reimposta la password del tuo account utente
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "Questa azione non può essere annullata. Questo eliminerà permanentemente tutti i record attuali per {name} dal database."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "This will permanently delete all selected records from the database."
msgstr "Questo eliminerà permanentemente tutti i record selezionati dal database."
@@ -1802,7 +1999,7 @@ msgstr "A email(s)"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr ""
msgstr "Token"
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1901,6 +2098,10 @@ msgstr "Si attiva quando l'utilizzo della GPU supera una soglia"
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Attiva quando l'utilizzo della memoria supera una soglia"
#: src/lib/alerts.ts
msgid "Triggers when one hour loss exceeds a threshold"
msgstr "Si attiva quando la perdita di unora supera una soglia"
#: src/lib/alerts.ts
msgid "Triggers when status switches between up and down"
msgstr "Attiva quando lo stato passa tra up e down"
@@ -1960,6 +2161,7 @@ msgid "Update"
msgstr "Aggiorna"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2103,3 +2305,4 @@ msgstr "Sì"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Le impostazioni utente sono state aggiornate."

Some files were not shown because too many files have changed in this diff Show More