Compare commits

...
12 Commits
Author SHA1 Message Date
henrygd 5b0952ffcf ui: persist log timestamp toggle and add tooltips to log buttons 2026-09-30 21:26:28 -04:00
Jeroen De WildeandGitHub 2502dd9626 feat: add toggle for log timestamps (#2471) 2026-09-30 21:14:13 -04:00
hankandGitHub 8a91a0b5ab New Crowdin updates (#2412) 2026-09-30 21:12:07 -04:00
henrygd 6ff9b4cfea feat(ui): add MB/s and GB/s unit select to bandwidth alert
Raises the bandwidth threshold max to 5 GB/s without making the MB/s
slider less precise. Values are still stored in MB/s, and the unit is
inferred from the stored value when the alert sheet opens.
2026-09-30 20:24:04 -04:00
Santhi PrakashandGitHub 65e3e2e768 fix(agent): count ZFS snapshot reads in pool I/O stats (#2474) 2026-09-30 18:22:51 -04:00
Sven van GinkelandGitHub 87f1efdfd9 fix(ui): reject IP targets for DNS monitors (#2483) 2026-09-30 18:12:14 -04:00
Bartu ÖzenandGitHub dd3a769d18 Widen NVMe power-cycle SMART fields to uint (#2484) 2026-09-30 17:50:12 -04:00
henrygd 266f45f104 fix(agent): skip first disk I/O sample of an interval when its window is too short
Right after agent start the hub requests stats immediately, and the first
sample of the interval was measured from the baseline taken at startup, only
a second or so earlier. A burst of startup I/O was then stored as the rate
for the whole minute, causing large spikes in disk I/O charts.

The first sample of an interval now must span at least half the interval.
Otherwise it only stores the snapshot, and the next sample is measured from it.
2026-09-30 13:02:29 -04:00
henrygd e925fafd7b refactor(agent): label windows wifi interfaces by adapter name instead of GUID 2026-09-30 12:50:17 -04:00
Sven van GinkelandGitHub 15c29fee92 feat(site): allow dismissing the active alerts banner and soften its red (#2478) 2026-09-30 12:17:02 -04:00
Sven van GinkelandGitHub 2ca5e727f9 refactor(ui): remove "Copy to system" from network monitor actions (#2480) 2026-09-30 12:13:31 -04:00
e01e2a20e9 feat(ui): compare network monitor targets within the same system (#2477)
Co-authored-by: henrygd <hank@henrygd.me>
2026-09-29 19:56:56 -04:00
62 changed files with 1420 additions and 331 deletions
+10 -4
View File
@@ -679,11 +679,11 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
} }
// Previous snapshot for this interval and device // Previous snapshot for this interval and device
prev, hasPrev := a.diskPrev[cacheTimeMs][name] prev, ok := a.diskPrev[cacheTimeMs][name]
if !hasPrev { firstSample := !ok
if firstSample {
// Seed from the latest counters of any interval, else seed from current // Seed from the latest counters of any interval, else seed from current
prev, hasPrev = a.diskBaseline[name] if prev, ok = a.diskBaseline[name]; !ok {
if !hasPrev {
prev = prevDiskFromCounter(d, now) prev = prevDiskFromCounter(d, now)
} }
} }
@@ -697,6 +697,12 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
if msElapsed < 100 { if msElapsed < 100 {
continue continue
} }
// The first sample of an interval must span at least half the interval.
// Right after agent start the baseline is only a second or so old, and a
// burst of startup I/O would be recorded as the rate for the whole interval.
if firstSample && msElapsed < uint64(cacheTimeMs)/2 {
continue
}
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
+57 -8
View File
@@ -75,17 +75,30 @@ func TestUpdateDiskIoTimeCounterWrap(t *testing.T) {
} }
} }
// The first sample of a cache interval has no snapshot of its own. It must // backdateDiskBaseline moves the baseline of a device into the past so the
// measure the time counters from the same baseline as the byte counters. // next seeded sample of an interval spans d.
func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) { func backdateDiskBaseline(a *Agent, name string, d time.Duration) {
b := a.diskBaseline[name]
b.at = time.Now().Add(-d)
a.diskBaseline[name] = b
}
// setupDiskstats points gopsutil at a temp dir and returns a writer for its diskstats file.
func setupDiskstats(t *testing.T) func(line string) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("HOST_PROC", dir) t.Setenv("HOST_PROC", dir)
t.Setenv("HOST_SYS", dir) t.Setenv("HOST_SYS", dir)
t.Setenv("HOST_DEV", dir) t.Setenv("HOST_DEV", dir)
t.Setenv("HOST_RUN", dir) t.Setenv("HOST_RUN", dir)
writeDiskstats := func(line string) { return func(line string) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
} }
}
// The first sample of a cache interval has no snapshot of its own. It must
// measure the time counters from the same baseline as the byte counters.
func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
writeDiskstats := setupDiskstats(t)
writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n") writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n")
counters, err := disk.IOCounters("sda") counters, err := disk.IOCounters("sda")
@@ -97,9 +110,7 @@ func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
diskPrev: map[uint16]map[string]prevDisk{}, diskPrev: map[uint16]map[string]prevDisk{},
} }
a.initializeDiskIoStats(counters) a.initializeDiskIoStats(counters)
backdateDiskBaseline(a, "sda", 60*time.Second)
// updateDiskIo skips samples less than 100ms apart.
time.Sleep(150 * time.Millisecond)
// Deltas: read 300ms / 10 ops, write 400ms / 20 ops, io time 1200ms, weighted io 3000ms. // Deltas: read 300ms / 10 ops, write 400ms / 20 ops, io time 1200ms, weighted io 3000ms.
writeDiskstats(" 8 0 sda 1010 0 21200 1200 520 0 10400 1100 0 1600 3000\n") writeDiskstats(" 8 0 sda 1010 0 21200 1200 520 0 10400 1100 0 1600 3000\n")
@@ -115,7 +126,7 @@ func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
assert.NotZero(t, fs.DiskIoStats[5], "weighted io") assert.NotZero(t, fs.DiskIoStats[5], "weighted io")
// A second interval starts from the latest counters, not from the ones at start. // A second interval starts from the latest counters, not from the ones at start.
time.Sleep(150 * time.Millisecond) backdateDiskBaseline(a, "sda", time.Second)
// Deltas: read 100ms / 10 ops, write 100ms / 20 ops. // Deltas: read 100ms / 10 ops, write 100ms / 20 ops.
writeDiskstats(" 8 0 sda 1020 0 22400 1300 540 0 10800 1200 0 1800 3500\n") writeDiskstats(" 8 0 sda 1020 0 22400 1300 540 0 10800 1200 0 1800 3500\n")
a.updateDiskIo(1000, &stats) a.updateDiskIo(1000, &stats)
@@ -123,3 +134,41 @@ func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
assert.InDelta(t, 10, fs.DiskIoStats[3], 0.01, "r_await") assert.InDelta(t, 10, fs.DiskIoStats[3], 0.01, "r_await")
assert.InDelta(t, 5, fs.DiskIoStats[4], 0.01, "w_await") assert.InDelta(t, 5, fs.DiskIoStats[4], 0.01, "w_await")
} }
// Right after agent start the baseline is too recent to stand for a whole
// interval. The first sample only stores a snapshot, and the next one is
// measured from it.
func TestUpdateDiskIoSkipsShortSeededSample(t *testing.T) {
writeDiskstats := setupDiskstats(t)
writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n")
counters, err := disk.IOCounters("sda")
require.NoError(t, err)
fs := &system.FsStats{Root: true}
a := &Agent{
fsStats: map[string]*system.FsStats{"sda": fs},
diskPrev: map[uint16]map[string]prevDisk{},
}
a.initializeDiskIoStats(counters)
backdateDiskBaseline(a, "sda", 2*time.Second)
// 1000 MB read in the 2s after start.
writeDiskstats(" 8 0 sda 2000 0 2068000 900 500 0 10000 700 0 400 0\n")
var stats system.Stats
a.updateDiskIo(60000, &stats)
assert.Zero(t, fs.DiskReadBytes)
assert.Zero(t, stats.DiskIO[0])
require.Contains(t, a.diskPrev[60000], "sda", "snapshot is stored")
// Next sample is measured from the stored snapshot: 60 MB over 60s.
snap := a.diskPrev[60000]["sda"]
snap.at = time.Now().Add(-60 * time.Second)
a.diskPrev[60000]["sda"] = snap
writeDiskstats(" 8 0 sda 3000 0 2190880 900 500 0 10000 700 0 400 0\n")
stats = system.Stats{}
a.updateDiskIo(60000, &stats)
assert.InDelta(t, 1_048_576, float64(stats.DiskIO[0]), 20_000)
}
+4 -3
View File
@@ -866,9 +866,10 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
// getLogs fetches the logs for a container // getLogs fetches the logs for a container
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) { func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
query := url.Values{ query := url.Values{
"stdout": []string{"1"}, "timestamps": []string{"1"},
"stderr": []string{"1"}, "stdout": []string{"1"},
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)}, "stderr": []string{"1"},
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
} }
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query) endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
if err != nil { if err != nil {
+2 -1
View File
@@ -7,5 +7,6 @@ interval only; real-time requests reuse the last snapshot.
- Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`. - Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`.
- macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings. - macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings.
- Windows: native WLAN API, keyed by interface GUID. - Windows: native WLAN API, keyed by adapter alias (e.g. `Wi-Fi`), falling back
to interface GUID.
- Other platforms: unsupported. - Other platforms: unsupported.
+22 -1
View File
@@ -17,6 +17,10 @@ var wlanEnum = wlan.NewProc("WlanEnumInterfaces")
var wlanQuery = wlan.NewProc("WlanQueryInterface") var wlanQuery = wlan.NewProc("WlanQueryInterface")
var wlanFree = wlan.NewProc("WlanFreeMemory") var wlanFree = wlan.NewProc("WlanFreeMemory")
var iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
var guidToLuid = iphlpapi.NewProc("ConvertInterfaceGuidToLuid")
var luidToAlias = iphlpapi.NewProc("ConvertInterfaceLuidToAlias")
type wlanInterface struct { type wlanInterface struct {
GUID windows.GUID GUID windows.GUID
Description [256]uint16 Description [256]uint16
@@ -84,7 +88,7 @@ func collect(ctx context.Context) map[string]system.WiFi {
} }
wlanFree.Call(uintptr(data)) wlanFree.Call(uintptr(data))
} }
result[iface.GUID.String()] = reading result[interfaceName(&iface.GUID)] = reading
} }
return result return result
} }
@@ -97,3 +101,20 @@ func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsaf
} }
return data, size return data, size
} }
// interfaceName returns the adapter alias ("Wi-Fi"), matching the names used by
// network interface stats. Falls back to the GUID if the alias is unavailable.
func interfaceName(guid *windows.GUID) string {
if guidToLuid.Find() == nil && luidToAlias.Find() == nil {
var luid uint64
if rc, _, _ := guidToLuid.Call(uintptr(unsafe.Pointer(guid)), uintptr(unsafe.Pointer(&luid))); rc == 0 {
var alias [windows.IF_MAX_STRING_SIZE + 1]uint16
if rc, _, _ := luidToAlias.Call(uintptr(unsafe.Pointer(&luid)), uintptr(unsafe.Pointer(&alias[0])), uintptr(len(alias))); rc == 0 {
if name := windows.UTF16ToString(alias[:]); name != "" {
return name
}
}
}
}
return guid.String()
}
+77 -5
View File
@@ -18,6 +18,11 @@ var (
devZfsPath = "/dev/zfs" devZfsPath = "/dev/zfs"
) )
// errNoPoolIOStats marks an "iostats" file that predates OpenZFS 2.3 and
// reports no pool read/write counters (TRIM statistics only), so callers may
// fall back to older counter sources.
var errNoPoolIOStats = errors.New("no pool I/O counters")
func ARCSize() (uint64, error) { func ARCSize() (uint64, error) {
file, err := os.Open(filepath.Join(procZfsPath, "arcstats")) file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
if err != nil { if err != nil {
@@ -61,7 +66,7 @@ func checkZfsDevice() error {
// ZFS collector and avoid keeping a `zpool iostat` subprocess alive. // ZFS collector and avoid keeping a `zpool iostat` subprocess alive.
func PoolKernelStats() ([]PoolKernelStat, error) { func PoolKernelStats() ([]PoolKernelStat, error) {
poolDirs := make(map[string]struct{}) poolDirs := make(map[string]struct{})
for _, filename := range []string{"state", "io", "objset-*"} { for _, filename := range []string{"state", "io", "iostats", "objset-*"} {
paths, err := filepath.Glob(filepath.Join(procZfsPath, "*", filename)) paths, err := filepath.Glob(filepath.Join(procZfsPath, "*", filename))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -97,11 +102,25 @@ func PoolKernelStats() ([]PoolKernelStat, error) {
return pools, nil return pools, nil
} }
// readPoolCounters supports both ZFS kernel interfaces. OpenZFS through 2.3 // readPoolCounters supports the ZFS kernel interfaces. OpenZFS 2.3+ exposes
// exposes aggregate vdev counters in "io". When that file is unavailable, sum // pool-wide read/write counters in "iostats" that cover every objset,
// the logical I/O counters exposed for each dataset in the pool. // including mounted snapshots, which never get an "objset-*" kstat. OpenZFS
// through 2.0 exposes aggregate vdev counters in "io". When neither pool-level
// interface is usable, sum the logical I/O counters exposed for each dataset.
func readPoolCounters(poolDir string) (uint64, uint64, error) { func readPoolCounters(poolDir string) (uint64, uint64, error) {
nread, nwrite, err := readPoolIO(filepath.Join(poolDir, "io")) nread, nwrite, err := readPoolIOStats(filepath.Join(poolDir, "iostats"))
if err == nil {
return nread, nwrite, nil
}
// Older sources are tried only when "iostats" is absent or predates
// OpenZFS 2.3. A read or parse failure on a usable file is returned:
// silently switching counter sources would feed kernelStats counters
// from different interfaces under the same pool name and produce a
// false I/O spike once "iostats" recovers.
if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errNoPoolIOStats) {
return 0, 0, err
}
nread, nwrite, err = readPoolIO(filepath.Join(poolDir, "io"))
if err == nil || !errors.Is(err, os.ErrNotExist) { if err == nil || !errors.Is(err, os.ErrNotExist) {
return nread, nwrite, err return nread, nwrite, err
} }
@@ -144,6 +163,59 @@ func readPoolIO(path string) (uint64, uint64, error) {
return 0, 0, fmt.Errorf("I/O counters not found in %s", path) return 0, 0, fmt.Errorf("I/O counters not found in %s", path)
} }
// readPoolIOStats reads the pool I/O counters exported by OpenZFS 2.3+ in the
// "iostats" kstat. Reads are counted on ARC misses at their compressed size, so
// reads served from the ARC are excluded; writes are counted at their logical
// size. The file exists on earlier releases but then only reports TRIM
// statistics, so all four byte counters are required for the file to be usable.
func readPoolIOStats(path string) (uint64, uint64, error) {
file, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer file.Close()
var arcRead, arcWrite, directRead, directWrite uint64
var found uint8
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
var target *uint64
var bit uint8
switch fields[0] {
case "arc_read_bytes":
target, bit = &arcRead, 1<<0
case "direct_read_bytes":
target, bit = &directRead, 1<<1
case "arc_write_bytes":
target, bit = &arcWrite, 1<<2
case "direct_write_bytes":
target, bit = &directWrite, 1<<3
default:
continue
}
value, err := strconv.ParseUint(fields[2], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("parsing %s in %s: %w", fields[0], path, err)
}
*target = value
found |= bit
}
if err := scanner.Err(); err != nil {
return 0, 0, err
}
if found == 0 {
return 0, 0, fmt.Errorf("%w in %s", errNoPoolIOStats, path)
}
if found != 0x0f {
return 0, 0, fmt.Errorf("incomplete pool I/O counters in %s", path)
}
return arcRead + directRead, arcWrite + directWrite, nil
}
func readPoolObjsets(poolDir string) (uint64, uint64, error) { func readPoolObjsets(poolDir string) (uint64, uint64, error) {
paths, err := filepath.Glob(filepath.Join(poolDir, "objset-*")) paths, err := filepath.Glob(filepath.Join(poolDir, "objset-*"))
if err != nil { if err != nil {
+121
View File
@@ -66,6 +66,111 @@ func TestPoolKernelStatsOpenZfs24(t *testing.T) {
}, stats[0]) }, stats[0])
} }
func TestPoolKernelStatsOpenZfs23(t *testing.T) {
root := t.TempDir()
oldPath := procZfsPath
procZfsPath = root
t.Cleanup(func() { procZfsPath = oldPath })
// OpenZFS 2.3+ exposes logical pool read/write counters in "iostats".
// They cover every objset, including mounted snapshots, which never get
// an "objset-*" kstat, so the objset sum alone under-reports reads.
poolDir := filepath.Join(root, "tank")
require.NoError(t, os.MkdirAll(poolDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("ONLINE\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "iostats"), []byte(
"15 1 0x01 26 5176 227423 2127661979232\n"+
"name type data\n"+
"trim_extents_written 4 0\n"+
"arc_read_count 4 12\n"+
"arc_read_bytes 4 5000\n"+
"arc_write_count 4 7\n"+
"arc_write_bytes 4 3000\n"+
"direct_read_count 4 1\n"+
"direct_read_bytes 4 500\n"+
"direct_write_count 4 1\n"+
"direct_write_bytes 4 200\n",
), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x1"), []byte(
"34 1 0x01 28 7872 0 0\n"+
"name type data\n"+
"dataset_name 7 tank\n"+
"nwritten 4 2000\n"+
"nread 4 1000\n",
), 0o644))
stats, err := PoolKernelStats()
require.NoError(t, err)
require.Len(t, stats, 1)
assert.Equal(t, PoolKernelStat{
Name: "tank", Health: "ONLINE", NRead: 5500, NWrite: 3200,
}, stats[0])
}
func TestPoolKernelStatsIOStatsTrimOnly(t *testing.T) {
root := t.TempDir()
oldPath := procZfsPath
procZfsPath = root
t.Cleanup(func() { procZfsPath = oldPath })
// Before OpenZFS 2.3 the "iostats" file only reports TRIM counters, so
// the per-dataset "objset-*" files remain the only usable source.
poolDir := filepath.Join(root, "tank")
require.NoError(t, os.MkdirAll(poolDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("ONLINE\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "iostats"), []byte(
"15 1 0x01 18 3736 227423 2127661979232\n"+
"name type data\n"+
"trim_extents_written 4 10\n"+
"trim_bytes_written 4 4096\n",
), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x1"), []byte(
"34 1 0x01 28 7872 0 0\n"+
"name type data\n"+
"dataset_name 7 tank\n"+
"nwritten 4 2000\n"+
"nread 4 1000\n",
), 0o644))
stats, err := PoolKernelStats()
require.NoError(t, err)
require.Len(t, stats, 1)
assert.Equal(t, PoolKernelStat{
Name: "tank", Health: "ONLINE", NRead: 1000, NWrite: 2000,
}, stats[0])
}
func TestPoolKernelStatsIOStatsErrorDoesNotFallback(t *testing.T) {
root := t.TempDir()
oldPath := procZfsPath
procZfsPath = root
t.Cleanup(func() { procZfsPath = oldPath })
// A malformed "iostats" on a kernel that supports it must surface an
// error. Silently switching to the per-dataset sum would drop snapshot
// reads and, when "iostats" recovers, make kernelStats report a false
// I/O spike by comparing counters from two different interfaces.
poolDir := filepath.Join(root, "tank")
require.NoError(t, os.MkdirAll(poolDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("ONLINE\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "iostats"), []byte(
"arc_read_bytes 4 notanumber\n"+
"arc_write_bytes 4 3000\n"+
"direct_read_bytes 4 500\n"+
"direct_write_bytes 4 200\n",
), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x1"), []byte(
"34 1 0x01 28 7872 0 0\n"+
"name type data\n"+
"dataset_name 7 tank\n"+
"nwritten 4 2000\n"+
"nread 4 1000\n",
), 0o644))
_, err := PoolKernelStats()
require.Error(t, err)
}
func TestPoolKernelStatsNoZfs(t *testing.T) { func TestPoolKernelStatsNoZfs(t *testing.T) {
oldPath := procZfsPath oldPath := procZfsPath
procZfsPath = t.TempDir() procZfsPath = t.TempDir()
@@ -89,6 +194,22 @@ func TestReadObjsetIORequiresAllCounters(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
func TestReadPoolIOStatsRequiresAllCounters(t *testing.T) {
path := filepath.Join(t.TempDir(), "iostats")
require.NoError(t, os.WriteFile(path, []byte(
"arc_read_bytes 4 10\ndirect_read_bytes 4 5\narc_write_bytes 4 7\n"), 0o644))
_, _, err := readPoolIOStats(path)
require.Error(t, err)
}
func TestReadPoolIOStatsTrimOnlySignalsFallback(t *testing.T) {
path := filepath.Join(t.TempDir(), "iostats")
require.NoError(t, os.WriteFile(path, []byte(
"trim_extents_written 4 10\ntrim_bytes_written 4 4096\n"), 0o644))
_, _, err := readPoolIOStats(path)
assert.ErrorIs(t, err, errNoPoolIOStats)
}
func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) { func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) {
root := t.TempDir() root := t.TempDir()
oldDevZfsPath := devZfsPath oldDevZfsPath := devZfsPath
+2 -2
View File
@@ -475,7 +475,7 @@ type NVMeSmartHealthInformationLog struct {
HostReads uint `json:"host_reads"` HostReads uint `json:"host_reads"`
HostWrites uint `json:"host_writes"` HostWrites uint `json:"host_writes"`
ControllerBusyTime uint `json:"controller_busy_time"` ControllerBusyTime uint `json:"controller_busy_time"`
PowerCycles uint16 `json:"power_cycles"` PowerCycles uint `json:"power_cycles"`
PowerOnHours uint32 `json:"power_on_hours"` PowerOnHours uint32 `json:"power_on_hours"`
UnsafeShutdowns uint16 `json:"unsafe_shutdowns"` UnsafeShutdowns uint16 `json:"unsafe_shutdowns"`
MediaErrors uint `json:"media_errors"` MediaErrors uint `json:"media_errors"`
@@ -506,7 +506,7 @@ type SmartInfoForNvme struct {
SmartStatus SmartStatusInfoNvme `json:"smart_status"` SmartStatus SmartStatusInfoNvme `json:"smart_status"`
NVMeSmartHealthInformationLog *NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"` NVMeSmartHealthInformationLog *NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
Temperature TemperatureInfoNvme `json:"temperature"` Temperature TemperatureInfoNvme `json:"temperature"`
PowerCycleCount uint16 `json:"power_cycle_count"` PowerCycleCount uint `json:"power_cycle_count"`
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"` PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
} }
+23 -3
View File
@@ -1,10 +1,11 @@
import { alertInfo } from "@/lib/alerts" import { alertInfo } from "@/lib/alerts"
import { $alerts, $allSystemsById } from "@/lib/stores" import { $alerts, $allSystemsById } from "@/lib/stores"
import { useBrowserStorage } from "@/lib/utils"
import type { AlertRecord } from "@/types" import type { AlertRecord } from "@/types"
import { Plural, Trans } from "@lingui/react/macro" import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { AlertBannerSheet, AlertBannerSheetItem } from "./alert-banner-sheet" import { AlertBannerSheet, AlertBannerSheetItem } from "./alert-banner-sheet"
import { $router } from "./router" import { $router } from "./router"
@@ -49,6 +50,9 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
const alerts = useStore($alerts) const alerts = useStore($alerts)
const systems = useStore($allSystemsById) const systems = useStore($allSystemsById)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// ids of the alerts that were active when the banner was last dismissed.
// session storage because a retrigger while the page is closed keeps the same id.
const [dismissedIds, setDismissedIds] = useBrowserStorage<string[]>("dismissedAlerts", [], sessionStorage)
const { activeAlerts, systemCount, alertsKey } = useMemo(() => { const { activeAlerts, systemCount, alertsKey } = useMemo(() => {
const activeAlerts: AlertRecord[] = [] const activeAlerts: AlertRecord[] = []
@@ -69,9 +73,24 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
return { activeAlerts, systemCount: systemIds.size, alertsKey: alertsKey.join("") } return { activeAlerts, systemCount: systemIds.size, alertsKey: alertsKey.join("") }
}, [alerts]) }, [alerts])
// forget dismissed alerts once they resolve so they show again if they retrigger.
// skipped while alerts are still loading so a reload doesn't clear the dismissal,
// and re-run once loaded in case they resolved while the page was closed.
const alertsLoaded = Object.keys(alerts).length > 0
useEffect(() => {
if (!alertsLoaded) {
return
}
const activeIds = new Set(activeAlerts.map((alert) => alert.id))
if (dismissedIds.some((id) => !activeIds.has(id))) {
setDismissedIds(dismissedIds.filter((id) => activeIds.has(id)))
}
}, [alertsKey, alertsLoaded])
return useMemo(() => { return useMemo(() => {
const alertCount = activeAlerts.length const alertCount = activeAlerts.length
if (alertCount === 0) { // stay hidden after dismissing until an alert triggers that wasn't active at the time
if (alertCount === 0 || activeAlerts.every((alert) => dismissedIds.includes(alert.id))) {
return null return null
} }
// name the alert directly in the banner when there is only one // name the alert directly in the banner when there is only one
@@ -80,6 +99,7 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
<AlertBannerSheet <AlertBannerSheet
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
onDismiss={() => setDismissedIds(activeAlerts.map((alert) => alert.id))}
className={className} className={className}
title={ title={
alertCount === 1 ? ( alertCount === 1 ? (
@@ -117,5 +137,5 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
})} })}
</AlertBannerSheet> </AlertBannerSheet>
) )
}, [alertsKey, systemCount, systems, open, className]) }, [alertsKey, systemCount, systems, open, className, dismissedIds])
} }
@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon } from "lucide-react" import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon, XIcon } from "lucide-react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Link } from "./router" import { Link } from "./router"
@@ -20,6 +20,7 @@ export function AlertBannerSheet({
sheetTitle, sheetTitle,
sheetDescription, sheetDescription,
icon: Icon = TriangleAlertIcon, icon: Icon = TriangleAlertIcon,
onDismiss,
className, className,
children, children,
}: { }: {
@@ -31,9 +32,12 @@ export function AlertBannerSheet({
sheetTitle: ReactNode sheetTitle: ReactNode
sheetDescription?: ReactNode sheetDescription?: ReactNode
icon?: LucideIcon icon?: LucideIcon
/** Shows a dismiss button when provided */
onDismiss?: () => void
className?: string className?: string
children: ReactNode children: ReactNode
}) { }) {
const { t } = useLingui()
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<Alert variant="destructive" className={cn("flex items-center gap-3 py-3 max-sm:flex-wrap", className)}> <Alert variant="destructive" className={cn("flex items-center gap-3 py-3 max-sm:flex-wrap", className)}>
@@ -42,7 +46,7 @@ export function AlertBannerSheet({
<div className="min-w-0"> <div className="min-w-0">
<AlertTitle className="m-0">{title}</AlertTitle> <AlertTitle className="m-0">{title}</AlertTitle>
{description && ( {description && (
<AlertDescription className="text-destructive/80 dark:text-red-500/80">{description}</AlertDescription> <AlertDescription className="text-destructive/80 dark:text-red-400/80">{description}</AlertDescription>
)} )}
</div> </div>
</div> </div>
@@ -50,12 +54,24 @@ export function AlertBannerSheet({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0 bg-transparent border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive dark:text-red-500 dark:hover:bg-destructive/15 dark:hover:text-red-500 max-sm:w-full" className="shrink-0 bg-transparent border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive dark:text-red-400 dark:hover:bg-destructive/15 dark:hover:text-red-400 max-sm:w-full max-sm:order-last"
> >
{buttonLabel ?? <Trans>View details</Trans>} {buttonLabel ?? <Trans>View details</Trans>}
<ChevronRightIcon className="size-4 ms-1 -me-1" /> <ChevronRightIcon className="size-4 ms-1 -me-1" />
</Button> </Button>
</SheetTrigger> </SheetTrigger>
{onDismiss && (
<Button
variant="ghost"
size="icon"
onClick={onDismiss}
aria-label={t`Dismiss`}
title={t`Dismiss`}
className="size-8 shrink-0 -me-1.5 text-destructive/70 hover:bg-destructive/10 hover:text-destructive dark:text-red-400/70 dark:hover:bg-destructive/15 dark:hover:text-red-400"
>
<XIcon className="size-4" />
</Button>
)}
</Alert> </Alert>
<SheetContent className="w-140 !max-w-full gap-0"> <SheetContent className="w-140 !max-w-full gap-0">
<SheetHeader className="p-4 sm:p-6 pb-3 sm:pb-4 border-b"> <SheetHeader className="p-4 sm:p-6 pb-3 sm:pb-4 border-b">
@@ -88,7 +104,7 @@ export function AlertBannerSheetItem({
onClick={onClick} onClick={onClick}
className="group flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-accent/60" className="group flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-accent/60"
> >
<div className="rounded-md bg-destructive/10 p-2 text-destructive shrink-0"> <div className="rounded-md bg-destructive/10 p-2 text-destructive dark:text-red-400 shrink-0">
<Icon className="size-4" /> <Icon className="size-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -10,6 +10,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
@@ -17,7 +18,7 @@ import { alertInfo } from "@/lib/alerts"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
import { $alerts, $systems } from "@/lib/stores" import { $alerts, $systems } from "@/lib/stores"
import { cn, debounce } from "@/lib/utils" import { cn, debounce } from "@/lib/utils"
import type { AlertInfo, AlertRecord, SystemRecord } from "@/types" import type { AlertInfo, AlertRecord, AlertUnit, SystemRecord } from "@/types"
const Slider = lazy(() => import("@/components/ui/slider")) const Slider = lazy(() => import("@/components/ui/slider"))
@@ -27,6 +28,24 @@ const alertDebounce = 400
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[] const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
/** Round to a multiple of step, trimming float noise like 0.30000000000000004 */
const roundToStep = (val: number, step: number) => Number((Math.round(val / step) * step).toFixed(6))
/** Number input step and minimum for a unit, which may be finer than the slider */
const getInputRange = ({ min, step, inputStep = step }: AlertUnit) => ({ step: inputStep, min: Math.min(min, inputStep) })
/** Index of the unit to display a stored value in: the largest unit whose input range and step fit it */
function getUnitIndex(units: AlertUnit[], value: number) {
for (let i = units.length - 1; i > 0; i--) {
const { min, step } = getInputRange(units[i])
const val = value / units[i].factor
if (val >= min && val <= units[i].max && Math.abs(roundToStep(val, step) - val) < 1e-6) {
return i
}
}
return 0
}
const failedUpdateToast = (error: unknown) => { const failedUpdateToast = (error: unknown) => {
console.error(error) console.error(error)
toast({ toast({
@@ -245,7 +264,17 @@ export function AlertContent({
const [checked, setChecked] = useState(global ? false : !!alert) const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || (noDuration ? 0 : 10)) const [min, setMin] = useState(alert?.min || (noDuration ? 0 : 10))
const [value, setValue] = useState(alert?.value ?? (noThreshold ? 0 : (alertData.start ?? 80))) const { units } = alertData
const storedValue = alert?.value ?? (noThreshold ? 0 : (alertData.start ?? 80))
const [unitIndex, setUnitIndex] = useState(() => (units ? getUnitIndex(units, storedValue) : 0))
/** Selected threshold unit, if the alert has selectable units */
const unit = units?.[unitIndex]
const factor = unit?.factor ?? 1
const unitLabel = unit?.unit ?? alertData.unit
const { min: valueMin, max: valueMax, step = 1 } = unit ?? alertData
const { min: inputMin, step: inputStep } = unit ? getInputRange(unit) : { min: valueMin, step }
/** Threshold value in the selected unit */
const [value, setValue] = useState(() => Number((storedValue / factor).toFixed(6)))
const Icon = alertData.icon const Icon = alertData.icon
@@ -267,12 +296,12 @@ export function AlertContent({
return systemIds return systemIds
} }
function sendUpsert(min: number, value: number) { function sendUpsert(min: number, value: number, valueFactor = factor) {
const systems = getSystemIds() const systems = getSystemIds()
systems.length && systems.length &&
upsertAlerts({ upsertAlerts({
name: alertKey, name: alertKey,
value, value: Number((value * valueFactor).toFixed(6)),
min, min,
systems, systems,
}) })
@@ -326,7 +355,7 @@ export function AlertContent({
Average drops below{" "} Average drops below{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{value} {value}
{alertData.unit} {unitLabel}
</strong> </strong>
</Trans> </Trans>
) : ( ) : (
@@ -334,7 +363,7 @@ export function AlertContent({
Average exceeds{" "} Average exceeds{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{value} {value}
{alertData.unit} {unitLabel}
</strong> </strong>
</Trans> </Trans>
)} )}
@@ -345,9 +374,9 @@ export function AlertContent({
value={[value]} value={[value]}
onValueCommit={(val) => sendUpsert(min, val[0])} onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])} onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1} step={step}
min={alertData.min ?? 1} min={valueMin ?? 1}
max={alertData.max ?? 99} max={valueMax ?? 99}
/> />
<Input <Input
type="number" type="number"
@@ -355,17 +384,45 @@ export function AlertContent({
onChange={(e) => { onChange={(e) => {
let val = parseFloat(e.target.value) let val = parseFloat(e.target.value)
if (!Number.isNaN(val)) { if (!Number.isNaN(val)) {
if (alertData.max != null) val = Math.min(val, alertData.max) // keep values on the step grid so the unit can be inferred on reload
if (alertData.min != null) val = Math.max(val, alertData.min) if (unit) val = roundToStep(val, inputStep)
if (valueMax != null) val = Math.min(val, valueMax)
if (inputMin != null) val = Math.max(val, inputMin)
setValue(val) setValue(val)
sendUpsert(min, val) sendUpsert(min, val)
} }
}} }}
step={alertData.step ?? 1} step={inputStep}
min={alertData.min ?? 1} min={inputMin ?? 1}
max={alertData.max ?? 99} max={valueMax ?? 99}
className="w-16 h-8 text-center px-1" className="w-16 h-8 text-center px-1"
/> />
{units && (
<Select
value={String(unitIndex)}
onValueChange={(index) => {
const next = units[Number(index)]
const nextInput = getInputRange(next)
// convert the current threshold to the new unit, clamped to its range
const val = roundToStep((value * factor) / next.factor, nextInput.step)
const clamped = Math.min(Math.max(val, nextInput.min), next.max)
setUnitIndex(Number(index))
setValue(clamped)
sendUpsert(min, clamped, next.factor)
}}
>
<SelectTrigger className="w-auto shrink-0 h-8 gap-1.5 px-2.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map(({ unit }, i) => (
<SelectItem key={unit} value={String(i)}>
{unit.trim()}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
</div> </div>
)} )}
@@ -32,7 +32,8 @@ import { Separator } from "../ui/separator"
import { $router, Link } from "../router" import { $router, Link } from "../router"
import { listenKeys } from "nanostores" import { listenKeys } from "nanostores"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display" import { LogsDisplay, LogsFullscreenDialog, LogsIconButton, LogsTimestampToggle } from "@/components/logs-display"
import { getLogTimestampDecorations } from "@/lib/logs"
const syntaxTheme = "github-dark-dimmed" const syntaxTheme = "github-dark-dimmed"
@@ -282,7 +283,13 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
container: container.id, container: container.id,
}), }),
]) ])
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.` return logsHtml.logs
? highlighter.codeToHtml(logsHtml.logs, {
lang: "log",
theme: syntaxTheme,
decorations: getLogTimestampDecorations(logsHtml.logs),
})
: t`No results.`
} catch (error) { } catch (error) {
console.error(error) console.error(error)
return "" return ""
@@ -410,20 +417,15 @@ function ContainerSheet({
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start"> <div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
<div className="flex items-center w-full"> <div className="flex items-center w-full">
<h3>{t`Logs`}</h3> <h3>{t`Logs`}</h3>
<Button <LogsTimestampToggle className="ms-auto" />
variant="ghost" <LogsIconButton label={t`Refresh`} onClick={refreshLogs} disabled={isRefreshingLogs}>
size="sm"
onClick={refreshLogs}
className="h-8 w-8 p-0 ms-auto"
disabled={isRefreshingLogs}
>
<RefreshCwIcon <RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`} className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`}
/> />
</Button> </LogsIconButton>
<Button variant="ghost" size="sm" onClick={() => setLogsFullscreenOpen(true)} className="h-8 w-8 p-0"> <LogsIconButton label={t`Fullscreen`} onClick={() => setLogsFullscreenOpen(true)}>
<MaximizeIcon className="size-4" /> <MaximizeIcon className="size-4" />
</Button> </LogsIconButton>
</div> </div>
<LogsDisplay logsDisplay={logsDisplay} containerRef={logsContainerRef} /> <LogsDisplay logsDisplay={logsDisplay} containerRef={logsContainerRef} />
<div className="flex items-center w-full"> <div className="flex items-center w-full">
+67 -12
View File
@@ -1,8 +1,12 @@
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: log HTML is generated locally by Shiki */ /** biome-ignore-all lint/security/noDangerouslySetInnerHtml: log HTML is generated locally by Shiki */
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import { RefreshCwIcon } from "lucide-react" import { useStore } from "@nanostores/react"
import { type RefObject, useEffect, useRef } from "react" import { ClockIcon, RefreshCwIcon } from "lucide-react"
import { type ComponentProps, type FocusEvent, type RefObject, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogTitle, dialogIconButtonClassName } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogTitle, dialogIconButtonClassName } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { $showLogTimestamps, toggleLogTimestamps } from "@/lib/stores"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type LogsDisplayProps = { type LogsDisplayProps = {
@@ -10,13 +14,51 @@ type LogsDisplayProps = {
containerRef: RefObject<HTMLDivElement | null> containerRef: RefObject<HTMLDivElement | null>
} }
// Radix opens a tooltip on any focus, including when a sheet or dialog moves focus to
// the button on open / close. Limit it to keyboard focus.
function keyboardFocusOnly(e: FocusEvent<HTMLButtonElement>) {
if (!e.currentTarget.matches(":focus-visible")) {
e.preventDefault()
}
}
// Icon button with a tooltip, used in the log panel headers.
export function LogsIconButton({ label, className, ...props }: { label: string } & ComponentProps<typeof Button>) {
return (
<Tooltip>
<TooltipTrigger asChild onFocus={keyboardFocusOnly}>
<Button variant="ghost" size="sm" aria-label={label} className={cn("h-8 w-8 p-0", className)} {...props} />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
// Timestamp visibility is a single persisted preference shared by all log views.
export function LogsTimestampToggle({ className }: { className?: string }) {
const showTimestamps = useStore($showLogTimestamps)
return (
<LogsIconButton
label={showTimestamps ? t`Hide timestamps` : t`Show timestamps`}
onClick={toggleLogTimestamps}
className={cn(showTimestamps && "bg-accent text-accent-foreground", className)}
aria-label={t`Show timestamps`}
aria-pressed={showTimestamps}
>
<ClockIcon className="size-4" />
</LogsIconButton>
)
}
// Shared by Docker and systemd service sheets so logs behave identically. // Shared by Docker and systemd service sheets so logs behave identically.
export function LogsDisplay({ logsDisplay, containerRef }: LogsDisplayProps) { export function LogsDisplay({ logsDisplay, containerRef }: LogsDisplayProps) {
const showTimestamps = useStore($showLogTimestamps)
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn( className={cn(
"max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm", "max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
!showTimestamps && "[&_.log-timestamp]:hidden",
!logsDisplay && ["animate-pulse", "h-full"] !logsDisplay && ["animate-pulse", "h-full"]
)} )}
> >
@@ -40,6 +82,7 @@ export function LogsFullscreenDialog({
onRefresh: () => void | Promise<void> onRefresh: () => void | Promise<void>
isRefreshing: boolean isRefreshing: boolean
}) { }) {
const showTimestamps = useStore($showLogTimestamps)
const outerContainerRef = useRef<HTMLDivElement>(null) const outerContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@@ -57,19 +100,31 @@ export function LogsFullscreenDialog({
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white"> <DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
<DialogTitle className="sr-only">{name} logs</DialogTitle> <DialogTitle className="sr-only">{name} logs</DialogTitle>
<div ref={outerContainerRef} className="absolute inset-x-0 bottom-0 top-12 overflow-auto"> <div ref={outerContainerRef} className="absolute inset-x-0 bottom-0 top-12 overflow-auto">
<div className="min-h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm"> <div
className={cn(
"min-h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm",
!showTimestamps && "[&_.log-timestamp]:hidden"
)}
>
<div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} /> <div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
</div> </div>
</div> </div>
<button <LogsTimestampToggle className="absolute end-18 top-2 hover:bg-white/10 hover:text-white aria-pressed:bg-white/15 aria-pressed:text-white" />
onClick={onRefresh} <Tooltip>
className={cn("absolute end-11 top-3 opacity-60 hover:opacity-100", dialogIconButtonClassName)} <TooltipTrigger asChild onFocus={keyboardFocusOnly}>
disabled={isRefreshing} <button
title={t`Refresh`} onClick={onRefresh}
aria-label={t`Refresh`} className={cn("absolute end-11 top-3 opacity-60 hover:opacity-100", dialogIconButtonClassName)}
> disabled={isRefreshing}
<RefreshCwIcon className={cn("size-4 transition-transform duration-300", isRefreshing && "animate-spin")} /> aria-label={t`Refresh`}
</button> >
<RefreshCwIcon
className={cn("size-4 transition-transform duration-300", isRefreshing && "animate-spin")}
/>
</button>
</TooltipTrigger>
<TooltipContent>{t`Refresh`}</TooltipContent>
</Tooltip>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react" import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
@@ -23,10 +23,11 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { ChevronDownIcon, ListIcon, PlusIcon, SearchIcon, ServerIcon } from "lucide-react" import { ChevronDownIcon, GlobeIcon, ListIcon, type LucideIcon, PlusIcon, SearchIcon, ServerIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores" import { $systems } from "@/lib/stores"
import { cn, supportsNetworkMonitors } from "@/lib/utils" import { cn, supportsNetworkMonitors } from "@/lib/utils"
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import type { NetworkMonitorRecord } from "@/types" import type { NetworkMonitorRecord } from "@/types"
import * as v from "valibot" import * as v from "valibot"
@@ -94,9 +95,22 @@ const NormalizedMonitorValuesSchema = v.pipe(
return Number.isInteger(input.port) && input.port >= 1 && input.port <= 65535 return Number.isInteger(input.port) && input.port >= 1 && input.port <= 65535
}, "Port must be between 1 and 65535"), }, "Port must be between 1 and 65535"),
["port"] ["port"]
),
// Resolving an IP literal returns it without querying anything, so the check would measure nothing.
v.forward(
v.check(
(input) => input.protocol !== "dns" || !isIpAddress(input.target),
"DNS target must be a domain name; put the resolver's IP in DNS Server"
),
["target"]
) )
) )
function isIpAddress(value: string) {
// Hostnames never contain ":", so any colon means an IPv6 literal (optionally bracketed).
return /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(":")
}
// Bulk parsing only trims raw CSV fields. Inference, defaults, and protocol- // Bulk parsing only trims raw CSV fields. Inference, defaults, and protocol-
// specific validation still go through the shared normalization schema above. // specific validation still go through the shared normalization schema above.
const BulkMonitorSchema = v.object({ const BulkMonitorSchema = v.object({
@@ -202,6 +216,7 @@ export function SystemMultiSelect({
className, className,
systemIds, systemIds,
placeholder, placeholder,
canSelectMore,
}: { }: {
id: string id: string
selectedSystemIds: Set<string> selectedSystemIds: Set<string>
@@ -211,8 +226,107 @@ export function SystemMultiSelect({
/** Limit the options to these systems. Defaults to all systems that support network monitors. */ /** Limit the options to these systems. Defaults to all systems that support network monitors. */
systemIds?: string[] systemIds?: string[]
placeholder?: string placeholder?: string
canSelectMore?: boolean
}) { }) {
const systems = useStore($systems) const systems = useStore($systems)
const { t } = useLingui()
const options = systems
.filter((system) => (systemIds ? systemIds.includes(system.id) : supportsNetworkMonitors(system)))
.map((system) => ({ id: system.id, label: system.name }))
return (
<MultiSelect
id={id}
options={options}
selectedIds={selectedSystemIds}
onChange={onChange}
disabled={disabled}
className={className}
icon={ServerIcon}
canSelectMore={canSelectMore}
placeholder={placeholder ?? t`Select systems`}
searchPlaceholder={t`Search systems`}
emptyText={<Trans>No systems found.</Trans>}
/>
)
}
/** Pick monitors by target, e.g. other targets on the same system to compare against. */
export function MonitorMultiSelect({
id,
monitors,
selectedMonitorIds,
onChange,
disabled,
className,
placeholder,
canSelectMore,
}: {
id: string
monitors: NetworkMonitorRecord[]
selectedMonitorIds: Set<string>
onChange: (ids: Set<string>) => void
disabled?: boolean
className?: string
placeholder?: string
canSelectMore?: boolean
}) {
const { t } = useLingui()
const options = monitors
.map((monitor) => ({ id: monitor.id, label: getMonitorTarget(monitor), server: monitor.server }))
.sort((a, b) => a.label.localeCompare(b.label))
return (
<MultiSelect
id={id}
options={options}
selectedIds={selectedMonitorIds}
onChange={onChange}
disabled={disabled}
className={cn("ps-9.5", className)}
icon={GlobeIcon}
canSelectMore={canSelectMore}
placeholder={placeholder ?? t`Select targets`}
searchPlaceholder={t`Search targets`}
emptyText={<Trans>No targets found.</Trans>}
renderOption={(option) => (
<>
<span className="truncate">{option.label}</span>
{option.server && <span className="ms-auto shrink-0 text-xs text-muted-foreground">{option.server}</span>}
</>
)}
/>
)
}
type MultiSelectOption = { id: string; label: string }
function MultiSelect<T extends MultiSelectOption>({
id,
options,
selectedIds,
onChange,
disabled,
className,
icon: Icon,
placeholder,
searchPlaceholder,
emptyText,
renderOption = (option) => <span className="truncate">{option.label}</span>,
canSelectMore = true,
}: {
id: string
options: T[]
selectedIds: Set<string>
onChange: (ids: Set<string>) => void
disabled?: boolean
className?: string
icon: LucideIcon
placeholder: string
searchPlaceholder: string
emptyText: ReactNode
renderOption?: (option: T) => ReactNode
/** False once the selection is full; only already selected options can then be toggled. */
canSelectMore?: boolean
}) {
const { t } = useLingui() const { t } = useLingui()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const searchRef = useRef<HTMLInputElement>(null) const searchRef = useRef<HTMLInputElement>(null)
@@ -225,19 +339,15 @@ export function SystemMultiSelect({
}, []) }, [])
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
const query = search.trim().toLocaleLowerCase() const query = search.trim().toLocaleLowerCase()
const filteredSystems = systems.filter( const filteredOptions = options.filter((option) => option.label.toLocaleLowerCase().includes(query))
(system) => const allSelected = filteredOptions.every((option) => selectedIds.has(option.id))
(systemIds ? systemIds.includes(system.id) : supportsNetworkMonitors(system)) && const anySelected = filteredOptions.some((option) => selectedIds.has(option.id))
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 selectFiltered = (selected: boolean) => {
const next = new Set(selectedSystemIds) const next = new Set(selectedIds)
for (const system of filteredSystems) { for (const option of filteredOptions) {
if (selected) next.add(system.id) if (selected) next.add(option.id)
else next.delete(system.id) else next.delete(option.id)
} }
onChange(next) onChange(next)
} }
@@ -251,13 +361,13 @@ export function SystemMultiSelect({
variant="outline" variant="outline"
className={cn("relative w-full min-w-0 ps-10 pe-10 justify-start font-normal text-start", className)} 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" /> <Icon className="size-3.5 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<span className="truncate"> <span className="truncate">
{selectedSystemIds.size === 0 {selectedIds.size === 0
? (placeholder ?? t`Select systems`) ? placeholder
: selectedSystemIds.size === 1 : selectedIds.size === 1
? systems.find((s) => selectedSystemIds.has(s.id))?.name ? options.find((option) => selectedIds.has(option.id))?.label
: t`${selectedSystemIds.size} selected`} : t`${selectedIds.size} selected`}
</span> </span>
<ChevronDownIcon className="size-4 absolute end-4 top-1/2 -translate-y-1/2 opacity-50" /> <ChevronDownIcon className="size-4 absolute end-4 top-1/2 -translate-y-1/2 opacity-50" />
</Button> </Button>
@@ -280,8 +390,8 @@ export function SystemMultiSelect({
ref={focusSearchOnMount} ref={focusSearchOnMount}
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder={t`Search systems`} placeholder={searchPlaceholder}
aria-label={t`Search systems`} aria-label={searchPlaceholder}
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" 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) => { onKeyDown={(event) => {
if (event.key === "Escape") return if (event.key === "Escape") return
@@ -303,7 +413,7 @@ export function SystemMultiSelect({
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenuItem <DropdownMenuItem
className="px-1.5 py-1 text-xs text-muted-foreground" className="px-1.5 py-1 text-xs text-muted-foreground"
disabled={!filteredSystems.length || allSelected} disabled={!filteredOptions.length || allSelected || !canSelectMore}
onSelect={(event) => { onSelect={(event) => {
event.preventDefault() event.preventDefault()
selectFiltered(true) selectFiltered(true)
@@ -325,32 +435,29 @@ export function SystemMultiSelect({
{query ? <Trans>Clear matches</Trans> : <Trans>Clear all</Trans>} {query ? <Trans>Clear matches</Trans> : <Trans>Clear all</Trans>}
</DropdownMenuItem> </DropdownMenuItem>
</div> </div>
<span className="px-1.5 text-xs tabular-nums text-muted-foreground"> <span className="px-1.5 text-xs tabular-nums text-muted-foreground">{t`${selectedIds.size} selected`}</span>
{t`${selectedSystemIds.size} selected`}
</span>
</div> </div>
</div> </div>
<div className="min-h-0 overflow-y-auto"> <div className="min-h-0 overflow-y-auto">
{filteredSystems.length === 0 && ( {filteredOptions.length === 0 && (
<output className="block px-2.5 py-3 text-sm text-muted-foreground"> <output className="block px-2.5 py-3 text-sm text-muted-foreground">{emptyText}</output>
<Trans>No systems found.</Trans>
</output>
)} )}
{filteredSystems.map((sys) => ( {filteredOptions.map((option) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={sys.id} key={option.id}
checked={selectedSystemIds.has(sys.id)} checked={selectedIds.has(option.id)}
disabled={!canSelectMore && !selectedIds.has(option.id)}
onSelect={(event) => event.preventDefault()} onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const next = new Set(selectedSystemIds) const next = new Set(selectedIds)
if (checked) next.add(sys.id) if (checked) next.add(option.id)
else next.delete(sys.id) else next.delete(option.id)
onChange(next) onChange(next)
}} }}
className="group min-w-0 gap-2.5 py-2 ps-2.5" 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" 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> {renderOption(option)}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
</div> </div>
@@ -620,6 +727,7 @@ function MonitorDialogContent({
const { toast } = useToast() const { toast } = useToast()
const { t } = useLingui() const { t } = useLingui()
const isEditing = !!monitor const isEditing = !!monitor
const dnsTargetIsIp = protocol === "dns" && isIpAddress(target.trim())
// When the dialog is opened, initialize form fields with monitor values (if editing) or defaults (if adding). // When the dialog is opened, initialize form fields with monitor values (if editing) or defaults (if adding).
useEffect(() => { useEffect(() => {
@@ -733,8 +841,14 @@ function MonitorDialogContent({
value={target} value={target}
onChange={(e) => setTarget(e.target.value)} onChange={(e) => setTarget(e.target.value)}
placeholder={protocol === "http" ? "http://localhost:8090" : protocol === "dns" ? "example.com" : "1.1.1.1"} placeholder={protocol === "http" ? "http://localhost:8090" : protocol === "dns" ? "example.com" : "1.1.1.1"}
aria-invalid={dnsTargetIsIp}
required required
/> />
{dnsTargetIsIp && (
<p className="text-xs text-destructive">
<Trans>Enter a domain name to look up. Put the resolver's IP in DNS Server.</Trans>
</p>
)}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
@@ -811,7 +925,9 @@ function MonitorDialogContent({
)} )}
<Button <Button
type="submit" type="submit"
disabled={loading || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))} disabled={
loading || dnsTargetIsIp || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))
}
> >
{isEditing ? ( {isEditing ? (
<Trans>Save {{ foo: t`Monitor` }}</Trans> <Trans>Save {{ foo: t`Monitor` }}</Trans>
@@ -15,7 +15,6 @@ import {
PauseCircleIcon, PauseCircleIcon,
PlayCircleIcon, PlayCircleIcon,
CopyIcon, CopyIcon,
CopyPlusIcon,
ShieldCheckIcon, ShieldCheckIcon,
} from "lucide-react" } from "lucide-react"
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
@@ -25,9 +24,6 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Plural, Trans } from "@lingui/react/macro" import { Plural, Trans } from "@lingui/react/macro"
@@ -40,7 +36,6 @@ import { useMemo } from "react"
import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog" import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog"
import { Badge } from "../ui/badge" import { Badge } from "../ui/badge"
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils" import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
import { pb } from "@/lib/api"
const certExpiryDotColors = { ok: "bg-green-500", warning: "bg-yellow-500", critical: "bg-red-500" } const certExpiryDotColors = { ok: "bg-green-500", warning: "bg-yellow-500", critical: "bg-red-500" }
@@ -308,11 +303,6 @@ export function getMonitorColumns(
const isBulkAction = actionRows.length > 1 const isBulkAction = actionRows.length > 1
const shouldPause = actionRows.some((monitor) => monitor.enabled) const shouldPause = actionRows.some((monitor) => monitor.enabled)
const bulkCopyContent = actionRows.map((monitor) => formatBulkMonitorLine(monitor)).join("\n") 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 ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -357,31 +347,8 @@ export function getMonitorColumns(
}} }}
> >
<CopyIcon className="me-2.5 size-4" /> <CopyIcon className="me-2.5 size-4" />
<Trans>Bulk copy</Trans> <Trans>Copy bulk config</Trans>
</DropdownMenuItem> </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 /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
@@ -39,7 +39,7 @@ import { SystemStatus } from "@/lib/enums"
import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores" import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores"
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils" import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
import type { ChartOptions, MonitorCertInfo, NetworkMonitorRecord } from "@/types" import type { ChartOptions, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
import { AddMonitorDialog, EditMonitorDialog, SystemMultiSelect } from "./monitor-dialog" import { AddMonitorDialog, EditMonitorDialog, MonitorMultiSelect, SystemMultiSelect } from "./monitor-dialog"
import { import {
ArrowDownIcon, ArrowDownIcon,
ArrowLeftRightIcon, ArrowLeftRightIcon,
@@ -67,7 +67,8 @@ import {
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import ChartTimeSelect from "@/components/charts/chart-time-select" import ChartTimeSelect from "@/components/charts/chart-time-select"
import { LossChart, AvgMinMaxResponseChart, ResponseChart } from "@/components/routes/system/charts/monitors-charts" import { LossChart, AvgMinMaxResponseChart, ResponseChart } from "@/components/routes/system/charts/monitors-charts"
import { useMatchingMonitors, useNetworkMonitorStats } from "@/lib/use-network-monitors" import { getMonitorCompareState } from "@/lib/monitor-compare"
import { useCompareMonitors, useNetworkMonitorStats } from "@/lib/use-network-monitors"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { atom } from "nanostores" import { atom } from "nanostores"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
@@ -470,6 +471,7 @@ export default function NetworkMonitorsTableNew({
visibleColumnsKey={visibleColumnsKey} visibleColumnsKey={visibleColumnsKey}
rowSelection={rowSelection} rowSelection={rowSelection}
isLoading={isLoading} isLoading={isLoading}
includesAllSystems={!systemId}
/> />
</div> </div>
</Card> </Card>
@@ -483,6 +485,7 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
visibleColumnsKey, visibleColumnsKey,
rowSelection, rowSelection,
isLoading, isLoading,
includesAllSystems,
}: { }: {
table: TableType<NetworkMonitorRecord> table: TableType<NetworkMonitorRecord>
rows: Row<NetworkMonitorRecord>[] rows: Row<NetworkMonitorRecord>[]
@@ -490,6 +493,8 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
visibleColumnsKey: string visibleColumnsKey: string
rowSelection: RowSelectionState rowSelection: RowSelectionState
isLoading: boolean isLoading: boolean
/** The table lists every system's monitors, so the sheet can compare without fetching. */
includesAllSystems: boolean
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
@@ -560,6 +565,8 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
setSheetOpen(nextOpen) setSheetOpen(nextOpen)
}} }}
monitor={activeMonitor} monitor={activeMonitor}
monitors={table.options.data}
includesAllSystems={includesAllSystems}
/> />
</div> </div>
) )
@@ -630,16 +637,29 @@ function NetworkMonitorSheet({
open, open,
onOpenChange, onOpenChange,
monitor, monitor,
monitors,
includesAllSystems,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
monitor?: NetworkMonitorRecord monitor?: NetworkMonitorRecord
monitors: NetworkMonitorRecord[]
includesAllSystems: boolean
}) { }) {
if (!monitor) { if (!monitor) {
return null return null
} }
return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} /> return (
<NetworkMonitorSheetContent
key={monitor.system}
open={open}
onOpenChange={onOpenChange}
monitor={monitor}
monitors={monitors}
includesAllSystems={includesAllSystems}
/>
)
} }
const certExpiryTextColors = { ok: "", warning: "text-yellow-600 dark:text-yellow-500", critical: "text-red-500" } const certExpiryTextColors = { ok: "", warning: "text-yellow-600 dark:text-yellow-500", critical: "text-red-500" }
@@ -676,10 +696,16 @@ function NetworkMonitorSheetContent({
open, open,
onOpenChange, onOpenChange,
monitor, monitor,
monitors,
includesAllSystems,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
monitor: NetworkMonitorRecord monitor: NetworkMonitorRecord
/** Table monitors; used to find other targets on the same system to compare against. */
monitors: NetworkMonitorRecord[]
/** Whether `monitors` covers every system, so other systems' monitors needn't be fetched. */
includesAllSystems: boolean
}) { }) {
// Keep monitor exploration independent of the system charts' time range. // Keep monitor exploration independent of the system charts' time range.
const [chartTimeStore] = useState(() => { const [chartTimeStore] = useState(() => {
@@ -691,8 +717,7 @@ function NetworkMonitorSheetContent({
const systems = useStore($allSystemsById) const systems = useStore($allSystemsById)
const system = systems[monitor.system] const system = systems[monitor.system]
// Same target probed from other systems, for side-by-side comparison (#2385). const [compareTargetIds, setCompareTargetIds] = useState<Set<string>>(() => new Set())
const matchingMonitors = useMatchingMonitors(monitor, open)
const [compareSystemIds, setCompareSystemIds] = useState<Set<string>>(() => new Set()) const [compareSystemIds, setCompareSystemIds] = useState<Set<string>>(() => new Set())
// Scoped to this sheet so a filter doesn't carry over to other monitors' sheets. // Scoped to this sheet so a filter doesn't carry over to other monitors' sheets.
const [compareFilterStore, setCompareFilterStore] = useState(() => atom("")) const [compareFilterStore, setCompareFilterStore] = useState(() => atom(""))
@@ -701,16 +726,25 @@ function NetworkMonitorSheetContent({
if (compareMonitorId !== monitor.id) { if (compareMonitorId !== monitor.id) {
setCompareMonitorId(monitor.id) setCompareMonitorId(monitor.id)
setCompareSystemIds(new Set()) setCompareSystemIds(new Set())
setCompareTargetIds(new Set())
setCompareFilterStore(atom("")) setCompareFilterStore(atom(""))
} }
const matchingSystemIds = useMemo(() => matchingMonitors.map((m) => m.system), [matchingMonitors]) // Other systems' monitors come from the table when it lists every system, otherwise from one fetch.
// The opened system is always charted; the picker only adds other systems to compare against. const fetchedMonitors = useCompareMonitors(monitor.system, monitor.protocol, open && !includesAllSystems)
const compareMonitors = useMemo( const compare = useMemo(
() => [monitor, ...matchingMonitors.filter((m) => compareSystemIds.has(m.system))], () =>
[monitor, matchingMonitors, compareSystemIds] getMonitorCompareState({
monitor,
localMonitors: monitors,
otherMonitors: includesAllSystems ? monitors : fetchedMonitors,
selectedSystemIds: compareSystemIds,
selectedTargetIds: compareTargetIds,
getSystemName: (id) => systems[id]?.name ?? id,
}),
[monitor, monitors, includesAllSystems, fetchedMonitors, compareSystemIds, compareTargetIds, systems]
) )
const { compareMonitors } = compare
const comparing = compareMonitors.length > 1 const comparing = compareMonitors.length > 1
const getSystemName = useCallback((m: NetworkMonitorRecord) => systems[m.system]?.name ?? m.system, [systems])
const monitorStats = useNetworkMonitorStats({ const monitorStats = useNetworkMonitorStats({
systemId: monitor.system, systemId: monitor.system,
@@ -766,21 +800,31 @@ function NetworkMonitorSheetContent({
<div className="grid gap-4"> <div className="grid gap-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<ChartTimeSelect <ChartTimeSelect
className="bg-card flex-1 basis-48" className="bg-card flex-1 min-w-0 basis-full sm:basis-0"
agentVersion={chartData.agentVersion} agentVersion={chartData.agentVersion}
chartTimeStore={chartTimeStore} chartTimeStore={chartTimeStore}
allowRealtime={false} allowRealtime={false}
/> />
{matchingMonitors.length > 0 && ( <MonitorMultiSelect
<SystemMultiSelect id="monitor-compare-targets"
id="monitor-compare-systems" className="flex-1 min-w-0 basis-full sm:basis-0 bg-card"
className="w-full sm:w-1/3 shrink-0 bg-card" monitors={compare.targetOptions}
systemIds={matchingSystemIds} selectedMonitorIds={compare.selectedTargetIds}
selectedSystemIds={compareSystemIds} onChange={setCompareTargetIds}
onChange={setCompareSystemIds} disabled={compare.targetOptions.length === 0}
placeholder={t`Compare with other systems`} canSelectMore={compare.canAddTarget}
/> placeholder={t`Compare with other targets`}
)} />
<SystemMultiSelect
id="monitor-compare-systems"
className="flex-1 min-w-0 basis-full sm:basis-0 bg-card"
systemIds={compare.systemOptions}
selectedSystemIds={compare.selectedSystemIds}
onChange={setCompareSystemIds}
disabled={compare.systemOptions.length === 0}
canSelectMore={compare.canAddSystem}
placeholder={t`Compare with other systems`}
/>
</div> </div>
{comparing ? ( {comparing ? (
<> <>
@@ -790,7 +834,7 @@ function NetworkMonitorSheetContent({
monitors={compareMonitors} monitors={compareMonitors}
chartData={chartData} chartData={chartData}
empty={!hasMonitorStats} empty={!hasMonitorStats}
getLabel={getSystemName} getLabel={compare.getLabel}
filterStore={compareFilterStore} filterStore={compareFilterStore}
/> />
<LossChart <LossChart
@@ -799,7 +843,7 @@ function NetworkMonitorSheetContent({
monitors={compareMonitors} monitors={compareMonitors}
chartData={chartData} chartData={chartData}
empty={!hasMonitorStats} empty={!hasMonitorStats}
getLabel={getSystemName} getLabel={compare.getLabel}
filterStore={compareFilterStore} filterStore={compareFilterStore}
/> />
</> </>
@@ -19,9 +19,9 @@ import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "reac
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns" import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Card, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display" import { LogsDisplay, LogsFullscreenDialog, LogsIconButton, LogsTimestampToggle } from "@/components/logs-display"
import { getLogTimestampDecorations } from "@/lib/logs"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
@@ -41,7 +41,13 @@ async function getSystemdLogsHtml(systemId: string, serviceName: string): Promis
query: { system: systemId, service: serviceName }, query: { system: systemId, service: serviceName },
}), }),
]) ])
return logs ? highlighter.codeToHtml(logs, { lang: "log", theme: syntaxTheme }) : "" return logs
? highlighter.codeToHtml(logs, {
lang: "log",
theme: syntaxTheme,
decorations: getLogTimestampDecorations(logs),
})
: ""
} }
export default function SystemdTable({ systemId }: { systemId?: string }) { export default function SystemdTable({ systemId }: { systemId?: string }) {
@@ -526,28 +532,15 @@ function SystemdSheet({
<h3 className="text-sm font-medium"> <h3 className="text-sm font-medium">
<Trans>Logs</Trans> <Trans>Logs</Trans>
</h3> </h3>
<Button <LogsTimestampToggle className="ms-auto" />
variant="ghost" <LogsIconButton label={t`Refresh`} onClick={loadLogs} disabled={isLoadingLogs}>
size="sm"
onClick={loadLogs}
className="h-8 w-8 p-0 ms-auto"
disabled={isLoadingLogs}
aria-label={t`Refresh`}
>
<RefreshCwIcon <RefreshCwIcon
className={cn("size-4 transition-transform duration-300", isLoadingLogs && "animate-spin")} className={cn("size-4 transition-transform duration-300", isLoadingLogs && "animate-spin")}
/> />
</Button> </LogsIconButton>
<Button <LogsIconButton label={t`Fullscreen`} onClick={() => setLogsFullscreenOpen(true)} disabled={!logs}>
variant="ghost"
size="sm"
onClick={() => setLogsFullscreenOpen(true)}
className="h-8 w-8 p-0"
aria-label={t`Logs`}
disabled={!logs}
>
<MaximizeIcon className="size-4" /> <MaximizeIcon className="size-4" />
</Button> </LogsIconButton>
</div> </div>
{logs ? ( {logs ? (
<LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} /> <LogsDisplay logsDisplay={logs} containerRef={logsContainerRef} />
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: { variant: {
default: "bg-background text-foreground [&>svg]:text-foreground", default: "bg-background text-foreground [&>svg]:text-foreground",
destructive: destructive:
"bg-destructive/5 border-destructive/50 text-destructive dark:bg-destructive/6 dark:text-red-500 dark:border-destructive/70 [&>svg]:text-destructive", "bg-destructive/5 border-destructive/50 text-destructive dark:bg-destructive/6 dark:text-red-400 dark:border-destructive/50 [&>svg]:text-destructive dark:[&>svg]:text-red-400",
}, },
}, },
defaultVariants: { defaultVariants: {
+5
View File
@@ -53,6 +53,11 @@ export const alertInfo: Record<string, AlertInfo> = {
icon: EthernetIcon, icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`, desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 250, max: 250,
// 1024 to match the hub, which converts bandwidth with 1024 * 1024
units: [
{ unit: " MB/s", factor: 1, min: 1, max: 250, step: 1, inputStep: 0.1 },
{ unit: " GB/s", factor: 1024, min: 0.1, max: 5, step: 0.1 },
],
}, },
NetworkMonitorLoss: { NetworkMonitorLoss: {
name: () => t`Network Monitor Loss`, name: () => t`Network Monitor Loss`,
+13
View File
@@ -0,0 +1,13 @@
import type { DecorationItem } from "shiki/core"
// Docker uses RFC3339Nano; journalctl short-iso uses an offset such as +0200.
// Only mark the first timestamp, leaving dates in the log message untouched.
export function getLogTimestampDecorations(logs: string): DecorationItem[] {
const timestamps = logs.matchAll(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}) /gm)
return Array.from(timestamps, (match) => ({
start: match.index,
end: match.index + match[0].length,
properties: { class: "log-timestamp" },
alwaysWrap: true,
}))
}
@@ -0,0 +1,173 @@
import { expect, test } from "bun:test"
import type { NetworkMonitorRecord } from "@/types"
import {
getMonitorCompareState,
getMonitorIdentityKey,
getMonitorTarget,
MAX_COMPARE_MONITORS,
} from "./monitor-compare"
function mon(id: string, system: string, target: string, extra: Partial<NetworkMonitorRecord> = {}) {
return { id, system, target, protocol: "icmp", port: 0, server: "", interval: 30, ...extra } as NetworkMonitorRecord
}
const systemNames: Record<string, string> = { a: "Alpha", b: "Bravo", c: "Charlie" }
function state(
monitor: NetworkMonitorRecord,
monitors: NetworkMonitorRecord[],
selectedSystemIds: string[] = [],
selectedTargetIds: string[] = []
) {
return getMonitorCompareState({
monitor,
localMonitors: monitors,
otherMonitors: monitors,
selectedSystemIds: new Set(selectedSystemIds),
selectedTargetIds: new Set(selectedTargetIds),
getSystemName: (id) => systemNames[id] ?? id,
})
}
const ids = (monitors: NetworkMonitorRecord[]) => monitors.map((m) => m.id)
// a probes one and two; b probes one and two; c probes only one
const a1 = mon("a1", "a", "one.example")
const a2 = mon("a2", "a", "two.example")
const b1 = mon("b1", "b", "one.example")
const b2 = mon("b2", "b", "two.example")
const c1 = mon("c1", "c", "one.example")
const all = [a1, a2, b1, b2, c1]
test("formats tcp targets with their port", () => {
expect(getMonitorTarget({ target: "example.com", protocol: "icmp", port: 0 })).toBe("example.com")
expect(getMonitorTarget({ target: "example.com", protocol: "tcp", port: 443 })).toBe("example.com:443")
expect(getMonitorTarget({ target: "::1", protocol: "tcp", port: 22 })).toBe("[::1]:22")
})
test("identity ignores the system but not protocol, port or server", () => {
expect(getMonitorIdentityKey(a1)).toBe(getMonitorIdentityKey(b1))
expect(getMonitorIdentityKey(a1)).not.toBe(getMonitorIdentityKey({ ...a1, protocol: "http" }))
expect(getMonitorIdentityKey(a1)).not.toBe(getMonitorIdentityKey({ ...a1, port: 80 }))
expect(getMonitorIdentityKey(a1)).not.toBe(getMonitorIdentityKey({ ...a1, server: "1.1.1.1" }))
})
test("with nothing selected only the opened monitor is charted", () => {
const s = state(a1, all)
expect(s.systemOptions).toEqual(["b", "c"])
expect(ids(s.targetOptions)).toEqual(["a2"])
expect(ids(s.compareMonitors)).toEqual(["a1"])
})
test("only offers targets and systems with the same protocol", () => {
const aHttp = mon("aHttp", "a", "https://two.example", { protocol: "http" })
const bTcp = mon("bTcp", "b", "one.example", { protocol: "tcp", port: 443 })
const s = state(a1, [a1, a2, aHttp, bTcp, c1])
expect(ids(s.targetOptions)).toEqual(["a2"])
expect(s.systemOptions).toEqual(["c"])
})
test("comparing targets charts them on the opened system, labelled by target", () => {
const s = state(a1, all, [], ["a2"])
expect(ids(s.compareMonitors)).toEqual(["a1", "a2"])
expect(s.getLabel(a1)).toBe("one.example")
expect(s.getLabel(a2)).toBe("two.example")
})
test("comparing systems charts the opened target on them, labelled by system", () => {
const s = state(a1, all, ["b", "c"])
expect(ids(s.compareMonitors)).toEqual(["a1", "b1", "c1"])
expect(s.getLabel(a1)).toBe("Alpha")
expect(s.getLabel(c1)).toBe("Charlie")
})
test("each compared target is charted on every selected system", () => {
const s = state(a1, all, ["b"], ["a2"])
expect(ids(s.compareMonitors)).toEqual(["a1", "a2", "b1", "b2"])
expect(s.getLabel(b2)).toBe("Bravo · two.example")
})
test("selecting a system limits targets to ones it also probes", () => {
const s = state(a1, all, ["c"])
expect(ids(s.targetOptions)).toEqual([])
})
test("selecting a target limits systems to ones that also probe it", () => {
const s = state(a1, all, [], ["a2"])
expect(s.systemOptions).toEqual(["b"])
})
test("the pickers can't produce conflicting selections, but targets win if they conflict", () => {
const s = state(a1, all, ["c"], ["a2"])
expect(s.selectedSystemIds.size).toBe(0)
expect([...s.selectedTargetIds]).toEqual(["a2"])
expect(ids(s.compareMonitors)).toEqual(["a1", "a2"])
})
test("selected systems that are no longer options are ignored", () => {
const s = state(a1, all, ["gone"])
expect(s.selectedSystemIds.size).toBe(0)
expect(ids(s.compareMonitors)).toEqual(["a1"])
})
test("DNS lookups of the same name against different servers get the server in their label", () => {
const d1 = mon("d1", "a", "example.com", { protocol: "dns", server: "1.1.1.1" })
const d2 = mon("d2", "a", "example.com", { protocol: "dns", server: "8.8.8.8" })
const s = state(d1, [d1, d2], [], ["d2"])
expect(s.getLabel(d1)).toBe("example.com (1.1.1.1)")
expect(s.getLabel(d2)).toBe("example.com (8.8.8.8)")
})
test("uses separate local and fetched monitor lists on single-system pages", () => {
const s = getMonitorCompareState({
monitor: a1,
localMonitors: [a1, a2],
otherMonitors: [b1, b2],
selectedSystemIds: new Set(["b"]),
selectedTargetIds: new Set(["a2"]),
getSystemName: (id) => systemNames[id],
})
expect(ids(s.compareMonitors)).toEqual(["a1", "a2", "b1", "b2"])
})
test("duplicate labels without a server are left alone", () => {
const b1Dupe = mon("b1Dupe", "b", "one.example")
const s = state(a1, [...all, b1Dupe], ["b"])
expect(ids(s.compareMonitors)).toEqual(["a1", "b1", "b1Dupe"])
expect(s.getLabel(b1)).toBe("Bravo")
expect(s.getLabel(b1Dupe)).toBe("Bravo")
})
// Every system probes every target, so selections multiply into targets × systems lines.
function grid(systemCount: number, targetCount: number) {
const monitors: NetworkMonitorRecord[] = []
for (let s = 0; s < systemCount; s++) {
for (let t = 0; t < targetCount; t++) monitors.push(mon(`s${s}t${t}`, `s${s}`, `t${t}.example`))
}
return monitors
}
test("selecting everything is trimmed to the line limit, keeping targets and earlier picks", () => {
const monitors = grid(30, 5)
const allSystems = Array.from({ length: 29 }, (_, i) => `s${i + 1}`)
const allTargets = ["s0t1", "s0t2", "s0t3", "s0t4"]
const s = state(monitors[0], monitors, allSystems, allTargets)
expect([...s.selectedTargetIds]).toEqual(allTargets)
// 5 targets fit on 4 systems (the opened one plus 3) within the limit
expect([...s.selectedSystemIds]).toEqual(["s1", "s2", "s3"])
expect(s.compareMonitors.length).toBe(20)
expect(s.canAddSystem).toBe(false)
})
test("more can be added only while the next pick fits the line limit", () => {
const monitors = grid(12, 3)
const s = state(monitors[0], monitors, ["s1", "s2", "s3", "s4", "s5", "s6"])
// 1 target × 7 systems; another target makes 14, another system makes 8
expect(s.canAddTarget).toBe(true)
expect(s.canAddSystem).toBe(true)
const full = state(monitors[0], monitors, ["s1", "s2", "s3", "s4", "s5", "s6", "s7"], ["s0t1", "s0t2"])
// 3 targets × 8 systems = 24
expect(full.compareMonitors.length).toBe(MAX_COMPARE_MONITORS)
expect(full.canAddTarget).toBe(false)
expect(full.canAddSystem).toBe(false)
})
+128
View File
@@ -0,0 +1,128 @@
import type { NetworkMonitorRecord } from "@/types"
// Kept free of UI and store imports so it can be unit tested with bun.
/** Most lines a comparison charts, to keep it readable and the stats request's filter short. */
export const MAX_COMPARE_MONITORS = 24
type MonitorTarget = Pick<NetworkMonitorRecord, "target" | "protocol" | "port">
export function getMonitorTarget(monitor: MonitorTarget) {
if (monitor.protocol !== "tcp") return monitor.target
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
return `${host}:${monitor.port}`
}
/** Identifies what a monitor probes, regardless of which system probes it. */
export function getMonitorIdentityKey({
protocol,
target,
port,
server,
}: Pick<NetworkMonitorRecord, "protocol" | "target" | "port" | "server">) {
return JSON.stringify([protocol, target, port, server])
}
interface MonitorCompareInput {
/** The monitor whose sheet is open; always charted. */
monitor: NetworkMonitorRecord
/** Monitors that may include other targets on the opened monitor's system. */
localMonitors: NetworkMonitorRecord[]
/** Monitors that may include other systems' monitors. */
otherMonitors: NetworkMonitorRecord[]
selectedSystemIds: Set<string>
selectedTargetIds: Set<string>
getSystemName: (systemId: string) => string
}
/**
* Works out what the monitor sheet can compare and what it charts. Comparisons are limited to the opened
* monitor's protocol, since response time and loss mean different things per protocol.
*/
export function getMonitorCompareState({
monitor,
localMonitors,
otherMonitors,
selectedSystemIds,
selectedTargetIds,
getSystemName,
}: MonitorCompareInput) {
const sameProtocol = (m: NetworkMonitorRecord) => m.protocol === monitor.protocol
const systemTargets = localMonitors.filter(
(m) => m.system === monitor.system && m.id !== monitor.id && sameProtocol(m)
)
const systemMonitors = otherMonitors.filter((m) => m.system !== monitor.system && sameProtocol(m))
const keysBySystem = new Map<string, Set<string>>()
for (const m of systemMonitors) {
const keys = keysBySystem.get(m.system) ?? new Set<string>()
keys.add(getMonitorIdentityKey(m))
keysBySystem.set(m.system, keys)
}
// Each picker only offers what fits the other's selection, so every pick charts a line per system:
// systems must probe the opened target and every selected target, and targets must be probed by
// every selected system. Selections outside the options (e.g. a monitor deleted while the sheet is
// open) are ignored, and ones past MAX_COMPARE_MONITORS lines are dropped, keeping targets over
// systems and earlier picks over later ones.
const systemTargetsById = new Map(systemTargets.map((m) => [m.id, m]))
const pickedTargets = [...selectedTargetIds]
.flatMap((id) => systemTargetsById.get(id) ?? [])
.slice(0, MAX_COMPARE_MONITORS - 1)
const targetMonitors = [monitor, ...pickedTargets]
const requiredKeys = targetMonitors.map(getMonitorIdentityKey)
const systemOptions = [...keysBySystem]
.filter(([, keys]) => requiredKeys.every((key) => keys.has(key)))
.map(([id]) => id)
const systemOptionSet = new Set(systemOptions)
const systemIds = [...selectedSystemIds]
.filter((id) => systemOptionSet.has(id))
.slice(0, Math.floor(MAX_COMPARE_MONITORS / targetMonitors.length) - 1)
const targetOptions = systemTargets.filter((m) => {
const key = getMonitorIdentityKey(m)
return systemIds.every((id) => keysBySystem.get(id)?.has(key))
})
const targetIds = new Set(pickedTargets.map((m) => m.id))
// Every charted target is also charted for each selected system.
const targetKeys = new Set(targetMonitors.map(getMonitorIdentityKey))
const selectedSystems = new Set(systemIds)
const compareMonitors = [
...targetMonitors,
...systemMonitors.filter((m) => selectedSystems.has(m.system) && targetKeys.has(getMonitorIdentityKey(m))),
]
// Label series by whatever differs between them: system, target, or both.
const multiSystem = systemIds.length > 0
const multiTarget = targetIds.size > 0
const labels = new Map<string, string>()
const counts = new Map<string, number>()
for (const m of compareMonitors) {
const systemName = getSystemName(m.system)
const target = getMonitorTarget(m)
const label = multiSystem && multiTarget ? `${systemName} · ${target}` : multiTarget ? target : systemName
labels.set(m.id, label)
counts.set(label, (counts.get(label) ?? 0) + 1)
}
// DNS lookups of the same name against different servers would otherwise share a label.
for (const m of compareMonitors) {
const label = labels.get(m.id) as string
if ((counts.get(label) ?? 0) > 1 && m.server) labels.set(m.id, `${label} (${m.server})`)
}
return {
/** Systems that can be selected to compare against. */
systemOptions,
selectedSystemIds: new Set(systemIds),
/** Other targets on the opened monitor's system that can be selected. */
targetOptions,
selectedTargetIds: targetIds,
/** Whether one more target or system still fits within MAX_COMPARE_MONITORS lines. */
canAddTarget: (targetMonitors.length + 1) * (systemIds.length + 1) <= MAX_COMPARE_MONITORS,
canAddSystem: targetMonitors.length * (systemIds.length + 2) <= MAX_COMPARE_MONITORS,
/** Monitors to chart, starting with the opened one. */
compareMonitors,
getLabel: (m: NetworkMonitorRecord) => labels.get(m.id) ?? getMonitorTarget(m),
}
}
@@ -98,11 +98,7 @@ export function mergeMonitorStats(rawRecords: RawMonitorStatsRecord[], bucketMs
.map(([created, stats]) => ({ created, stats })) .map(([created, stats]) => ({ created, stats }))
} }
export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" | "protocol" | "port">) { export { getMonitorTarget } from "./monitor-compare"
if (monitor.protocol !== "tcp") return monitor.target
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
return `${host}:${monitor.port}`
}
/** Whole days until the certificate expires; negative once expired. */ /** Whole days until the certificate expires; negative once expired. */
export function getCertDaysLeft(cert: Pick<MonitorCertInfo, "expires">, now = Date.now()) { export function getCertDaysLeft(cert: Pick<MonitorCertInfo, "expires">, now = Date.now()) {
+11
View File
@@ -40,6 +40,17 @@ export const $chartTime = atom<ChartTimes>(defaultChartTime)
/** Whether to display average or max chart values */ /** Whether to display average or max chart values */
export const $maxValues = atom(false) export const $maxValues = atom(false)
const logTimestampsKey = "besz-log-ts"
/** Whether to show timestamps in Docker and systemd logs */
export const $showLogTimestamps = atom<boolean>(JSON.parse(localStorage.getItem(logTimestampsKey) ?? "true"))
export function toggleLogTimestamps() {
const next = !$showLogTimestamps.get()
$showLogTimestamps.set(next)
localStorage.setItem(logTimestampsKey, JSON.stringify(next))
}
// export const UserSettingsSchema = v.object({ // export const UserSettingsSchema = v.object({
// chartTime: v.picklist(["1h", "12h", "24h", "1w", "30d"]), // chartTime: v.picklist(["1h", "12h", "24h", "1w", "30d"]),
// emails: v.optional(v.array(v.pipe(v.string(), v.email())), [pb?.authStore?.record?.email ?? ""]), // emails: v.optional(v.array(v.pipe(v.string(), v.email())), [pb?.authStore?.record?.email ?? ""]),
+16 -16
View File
@@ -323,38 +323,38 @@ export function useNetworkMonitorStats(props: UseNetworkMonitorStatsProps) {
}, [monitorStats, cacheKey, interval, chartTime]) }, [monitorStats, cacheKey, interval, chartTime])
} }
/** Only what comparison charts and labels need. */
const COMPARE_MONITOR_FIELDS = "id,system,target,protocol,port,server,interval,resAvg1h"
/** /**
* Monitors on other systems that probe the same target (same protocol, target, port, and DNS server). * Monitors of one protocol on all systems except the given one, to compare against (#2385).
* Fetched once per open so it also works in single-system tables, which only hold one system's monitors. * Fetched per open so it also works in single-system tables, which only hold one system's monitors.
*/ */
export function useMatchingMonitors(monitor: NetworkMonitorRecord, enabled = true) { export function useCompareMonitors(system: string, protocol: string, enabled = true) {
const [matches, setMatches] = useState<NetworkMonitorRecord[]>([]) const key = `${system}:${protocol}`
const { id, system, protocol, target, port, server } = monitor const [result, setResult] = useState<{ key: string; monitors: NetworkMonitorRecord[] }>({ key, monitors: [] })
useEffect(() => { useEffect(() => {
setMatches([])
if (!enabled) return if (!enabled) return
let cancelled = false let cancelled = false
pb.collection<NetworkMonitorRecord>("network_monitors") pb.collection<NetworkMonitorRecord>("network_monitors")
.getFullList({ .getFullList({
fields: NETWORK_MONITOR_FIELDS, fields: COMPARE_MONITOR_FIELDS,
filter: pb.filter( filter: pb.filter("system!={:system} && protocol={:protocol}", { system, protocol }),
"id!={:id} && system!={:system} && protocol={:protocol} && target={:target} && port={:port} && server={:server}",
{ id, system, protocol, target, port, server }
),
}) })
.then((records) => { .then((monitors) => {
if (!cancelled) setMatches(records) if (!cancelled) setResult({ key: `${system}:${protocol}`, monitors })
}) })
.catch((error) => { .catch((error) => {
if (!cancelled) console.error("Failed to fetch matching monitors:", error) if (!cancelled) console.error("Failed to fetch compare monitors:", error)
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id, system, protocol, target, port, server, enabled]) }, [system, protocol, enabled])
return matches // Keep showing the last result while reopening refreshes it, but never another monitor's.
return result.key === key ? result.monitors : []
} }
async function fetchMonitors(system?: string) { async function fetchMonitors(system?: string) {
+2 -2
View File
@@ -229,14 +229,14 @@ function getStorageValue(key: string, defaultValue: unknown, storageInterface: S
/** Hook to sync value in local or session storage */ /** Hook to sync value in local or session storage */
export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterface: Storage = localStorage) { export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterface: Storage = localStorage) {
key = `besz-${key}` key = `besz-${key}`
const [value, setValue] = useState(() => { const [value, setValue] = useState<T>(() => {
return getStorageValue(key, defaultValue, storageInterface) return getStorageValue(key, defaultValue, storageInterface)
}) })
useEffect(() => { useEffect(() => {
storageInterface?.setItem(key, JSON.stringify(value)) storageInterface?.setItem(key, JSON.stringify(value))
}, [key, value]) }, [key, value])
return [value, setValue] return [value, setValue] as const
} }
/** Format temperature to user's preferred unit */ /** Format temperature to user's preferred unit */
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ar\n" "Language: ar\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:16\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Arabic\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" "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"
@@ -2359,3 +2359,4 @@ msgstr "نعم"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "تم تحديث إعدادات المستخدم الخاصة بك." msgstr "تم تحديث إعدادات المستخدم الخاصة بك."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: bg\n" "Language: bg\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:28\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Bulgarian\n" "Language-Team: Bulgarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Настройките за потребителя ти са обновени." msgstr "Настройките за потребителя ти са обновени."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: cs\n" "Language: cs\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:28\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Czech\n" "Language-Team: Czech\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n" "Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
@@ -2359,3 +2359,4 @@ msgstr "Ano"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Vaše uživatelská nastavení byla aktualizována." msgstr "Vaše uživatelská nastavení byla aktualizována."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: da\n" "Language: da\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:28\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Danish\n" "Language-Team: Danish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Dine brugerindstillinger er opdateret." msgstr "Dine brugerindstillinger er opdateret."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n" "Language: de\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:16\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: German\n" "Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Deine Benutzereinstellungen wurden aktualisiert." msgstr "Deine Benutzereinstellungen wurden aktualisiert."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: el\n" "Language: el\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Greek\n" "Language-Team: Greek\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Ναι"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Οι ρυθμίσεις χρήστη σας ενημερώθηκαν." msgstr "Οι ρυθμίσεις χρήστη σας ενημερώθηκαν."
+8
View File
@@ -2354,3 +2354,11 @@ msgstr "Yes"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Your user settings have been updated." msgstr "Your user settings have been updated."
#: src/components/logs-display.tsx
msgid "Show timestamps"
msgstr "Show timestamps"
#: src/components/logs-display.tsx
msgid "Hide timestamps"
msgstr "Hide timestamps"
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n" "Language: es\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:28\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Spanish\n" "Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Sí"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Tu configuración de usuario ha sido actualizada." msgstr "Tu configuración de usuario ha sido actualizada."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fa\n" "Language: fa\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:16\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Persian\n" "Language-Team: Persian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "بله"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "تنظیمات کاربری شما به‌روزرسانی شد." msgstr "تنظیمات کاربری شما به‌روزرسانی شد."
+10 -9
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n" "Language: fr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-25 14:03\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: French\n" "Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} minute} other {{countString} minutes}
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}" msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr "" msgstr "{daysLeft, plural, one {# jour} other {# jours}}"
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
@@ -62,7 +62,7 @@ msgstr "E/S {diskName}"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}" msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr "" msgstr "{numFailed, plural, one {# service en échec} other {# services en échec}}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# thread} other {# threads}}"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}" msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr "" msgstr "{totalCount, plural, one {# service} other {# services}}"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -137,7 +137,7 @@ msgstr "État actif"
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add" msgid "Add"
msgstr "" msgstr "Ajouter"
#: 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
@@ -406,15 +406,15 @@ msgstr "Celsius (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate" msgid "Certificate"
msgstr "" msgstr "Certificat"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}" msgid "Certificate expired {expires}"
msgstr "" msgstr "Certificat expiré {expires}"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}" msgid "Certificate expires {expires}"
msgstr "" msgstr "Le certificat expire le {expires}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -905,7 +905,7 @@ msgstr "Sorti actif"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired" msgid "Expired"
msgstr "" msgstr "Expiré"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
@@ -2359,3 +2359,4 @@ msgstr "Oui"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Vos paramètres utilisateur ont été mis à jour." msgstr "Vos paramètres utilisateur ont été mis à jour."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: he\n" "Language: he\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Hebrew\n" "Language-Team: Hebrew\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);\n"
@@ -2359,3 +2359,4 @@ msgstr "כן"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "הגדרות המשתמש שלך עודכנו." msgstr "הגדרות המשתמש שלך עודכנו."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: hr\n" "Language: hr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:16\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Croatian\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" "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"
@@ -2359,3 +2359,4 @@ msgstr "Da"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Vaše korisničke postavke su ažurirane." msgstr "Vaše korisničke postavke su ažurirane."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: hu\n" "Language: hu\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Hungarian\n" "Language-Team: Hungarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Igen"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "A felhasználói beállítások frissítésre kerültek." msgstr "A felhasználói beállítások frissítésre kerültek."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: id\n" "Language: id\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:16\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Indonesian\n" "Language-Team: Indonesian\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -2359,3 +2359,4 @@ msgstr "Ya"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Pengaturan pengguna anda telah diperbarui." msgstr "Pengaturan pengguna anda telah diperbarui."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n" "Language: it\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Italian\n" "Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Sì"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Le impostazioni utente sono state aggiornate." msgstr "Le impostazioni utente sono state aggiornate."
+18 -17
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ja\n" "Language: ja\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-10-01 01:10\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Japanese\n" "Language-Team: Japanese\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} 分} few {{countString} 分} many {{c
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}" msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr "" msgstr "{daysLeft, plural, one {# 日} other {# 日}}"
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
@@ -62,7 +62,7 @@ msgstr "{diskName} の I/O"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}" msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr "" msgstr "{numFailed, plural, one {# 失敗したサービス} other {# 失敗したサービス}}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# スレッド} other {# スレッド}}"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}" msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr "" msgstr "{totalCount, plural, one {# サービス} other {# サービス}}"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -137,7 +137,7 @@ msgstr "アクティブ状態"
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add" msgid "Add"
msgstr "" 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 #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -189,7 +189,7 @@ msgstr "エージェント"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Alert History" msgid "Alert History"
msgstr "アラート履歴" msgstr "通知履歴"
#: src/components/alerts/alert-button.tsx #: src/components/alerts/alert-button.tsx
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
@@ -406,15 +406,15 @@ msgstr "摂氏 (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate" msgid "Certificate"
msgstr "" msgstr "証明書"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}" msgid "Certificate expired {expires}"
msgstr "" msgstr "証明書は{expires}に期限が切れました"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}" msgid "Certificate expires {expires}"
msgstr "" msgstr "証明書は{expires}に期限が切れます"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -435,7 +435,7 @@ msgstr "充電中"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Chart options" msgid "Chart options"
msgstr "チャートオプション" msgstr "チャートのオプション"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "Chart width" msgid "Chart width"
@@ -476,7 +476,7 @@ msgstr "すべてクリア"
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Clear matches" msgid "Clear matches"
msgstr "一致をクリア" msgstr "一致する項目をクリア"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
msgid "Click on a container to view more information." msgid "Click on a container to view more information."
@@ -905,7 +905,7 @@ msgstr "アクティブ状態で終了"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired" msgid "Expired"
msgstr "" msgstr "期限切れ"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
@@ -1019,7 +1019,7 @@ msgstr "FreeBSD コマンド"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Full" msgid "Full"
msgstr "満充電" msgstr "フル"
#. Context: General settings #. Context: General settings
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
@@ -1180,7 +1180,7 @@ msgstr "負荷平均 (5分)"
#. Short label for load average #. Short label for load average
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Load Avg" msgid "Load Avg"
msgstr "負荷平均" msgstr "平均負荷"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Load state" msgid "Load state"
@@ -1977,7 +1977,7 @@ 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 #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]" msgid "target[,protocol[,port[,interval]]]"
msgstr "" msgstr "target[,protocol[,port[,interval]]]"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -2155,7 +2155,7 @@ msgstr "1時間のパケットロス率がしきい値を超えるとトリガ
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when status switches between up and down" msgid "Triggers when status switches between up and down"
msgstr "ステータスが稼働中と停止中の間で切り替わったときにトリガーされます" msgstr "システムが開始または停止したときトリガーされます"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Triggers when usage of any disk exceeds a threshold" msgid "Triggers when usage of any disk exceeds a threshold"
@@ -2291,7 +2291,7 @@ msgstr "表示列"
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display" msgid "Waiting for enough records to display"
msgstr "表示するのに十分なレコードを待っています" msgstr "表示に必要なデータが揃うまで待機中"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details." msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
@@ -2359,3 +2359,4 @@ msgstr "はい"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "ユーザー設定が更新されました。" msgstr "ユーザー設定が更新されました。"
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ko\n" "Language: ko\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Korean\n" "Language-Team: Korean\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -2359,3 +2359,4 @@ msgstr "예"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "사용자 설정이 업데이트되었습니다." msgstr "사용자 설정이 업데이트되었습니다."
+9 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: nl\n" "Language: nl\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Dutch\n" "Language-Team: Dutch\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,11 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Je gebruikersinstellingen zijn bijgewerkt." msgstr "Je gebruikersinstellingen zijn bijgewerkt."
#: src/components/logs-display.tsx
msgid "Show timestamps"
msgstr "Tijdstempels tonen"
#: src/components/logs-display.tsx
msgid "Hide timestamps"
msgstr "Tijdstempels verbergen"
+11 -10
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: no\n" "Language: no\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 18:40\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Norwegian\n" "Language-Team: Norwegian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} minutt} other {{countString} minutter
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}" msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr "" msgstr "{daysLeft, plural, one {# dag} other {# dager}}"
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
@@ -62,7 +62,7 @@ msgstr "I/O for {diskName}"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}" msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr "" msgstr "{numFailed, plural, one {# feilet tjeneste} other {# feilede tjenester}}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# tråd} other {# tråder}}"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}" msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr "" msgstr "{totalCount, plural, one {# tjeneste} other {# tjenester}}"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -137,7 +137,7 @@ msgstr "Aktiv tilstand"
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add" msgid "Add"
msgstr "" msgstr "Legg til"
#: 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
@@ -406,15 +406,15 @@ msgstr "Celsius (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate" msgid "Certificate"
msgstr "" msgstr "Sertifikat"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}" msgid "Certificate expired {expires}"
msgstr "" msgstr "Sertifikatet utløper {expires}"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}" msgid "Certificate expires {expires}"
msgstr "" msgstr "Sertifikatet utløper {expires}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -905,7 +905,7 @@ msgstr "Avsluttet aktiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired" msgid "Expired"
msgstr "" msgstr "Utløpt"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
@@ -1977,7 +1977,7 @@ msgstr "Mål"
#: 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
msgid "target[,protocol[,port[,interval]]]" msgid "target[,protocol[,port[,interval]]]"
msgstr "" msgstr "mål [,protokoll[,port[,interval]]"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -2359,3 +2359,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Dine brukerinnstillinger har blitt oppdatert." msgstr "Dine brukerinnstillinger har blitt oppdatert."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n" "Language: pl\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Polish\n" "Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@@ -2359,3 +2359,4 @@ msgstr "Tak"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Twoje ustawienia użytkownika zostały zaktualizowane." msgstr "Twoje ustawienia użytkownika zostały zaktualizowane."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: pt\n" "Language: pt\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Portuguese\n" "Language-Team: Portuguese\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Sim"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "As configurações do seu usuário foram atualizadas." msgstr "As configurações do seu usuário foram atualizadas."
+67 -13
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ro\n" "Language: ro\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:22\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Romanian\n" "Language-Team: Romanian\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);\n"
@@ -52,14 +52,26 @@ msgstr ""
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}" msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
msgstr "1 oră" msgstr "1 oră"
@@ -123,10 +135,10 @@ msgstr "Alerte Active"
msgid "Active state" msgid "Active state"
msgstr "Stare activă" msgstr "Stare activă"
#: src/components/add-system.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/add-system.tsx msgid "Add"
#: src/components/navbar.tsx msgstr ""
#: 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/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "Stare activă"
msgid "Add {foo}" msgid "Add {foo}"
msgstr "Adaugă {foo}" msgstr "Adaugă {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr ""
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Add URL" msgid "Add URL"
msgstr "Adaugă URL" msgstr "Adaugă URL"
@@ -260,6 +279,7 @@ msgstr ""
msgid "Average, minimum, and maximum response time" msgid "Average, minimum, and maximum response time"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h" msgid "Avg 1h"
msgstr "" msgstr ""
@@ -383,6 +403,19 @@ msgstr "Atenție - posibilă pierdere de date"
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "Celsius (°C)" msgstr "Celsius (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
msgstr "Schimbă unitățile de afișare pentru metrici." msgstr "Schimbă unitățile de afișare pentru metrici."
@@ -776,12 +809,15 @@ msgstr "Durată"
msgid "Edit" msgid "Edit"
msgstr "Editează" msgstr "Editează"
#: src/components/add-system.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 "Edit {foo}" msgid "Edit {foo}"
msgstr "Editează {foo}" msgstr "Editează {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr ""
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx #: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr ""
msgid "Exited active" msgid "Exited active"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
msgstr "" msgstr ""
@@ -887,10 +927,6 @@ msgstr ""
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr ""
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:" msgid "Failed Attributes:"
msgstr "" msgstr ""
@@ -1090,6 +1126,7 @@ msgstr ""
msgid "Inactive" msgid "Inactive"
msgstr "Inactiv" msgstr "Inactiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Interval" msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss" msgid "Loss"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h" msgid "Loss 1h"
msgstr "" msgstr ""
@@ -1204,6 +1242,7 @@ msgstr ""
msgid "Max 1 min" msgid "Max 1 min"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h" msgid "Max 1h"
msgstr "" msgstr ""
@@ -1234,6 +1273,7 @@ msgstr "Memorie Utilizată"
msgid "Memory usage of containers" msgid "Memory usage of containers"
msgstr "Utilizarea memoriei de către containere" msgstr "Utilizarea memoriei de către containere"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h" msgid "Min 1h"
msgstr "" msgstr ""
@@ -1565,6 +1605,7 @@ msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol" msgid "Protocol"
msgstr "" msgstr ""
@@ -1653,6 +1694,7 @@ msgstr ""
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx #: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response" msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "Detalii S.M.A.R.T."
msgid "S.M.A.R.T. Self-Test" msgid "S.M.A.R.T. Self-Test"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}" msgid "Save {foo}"
msgstr "" msgstr ""
@@ -1712,6 +1753,10 @@ msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
#: src/components/add-system.tsx
msgid "Save System"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it." msgid "Saved in the database and does not expire until you disable it."
msgstr "" msgstr ""
@@ -1833,6 +1878,7 @@ msgstr ""
msgid "SMTP settings" msgid "SMTP settings"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Sort By" msgid "Sort By"
msgstr "" msgstr ""
@@ -1875,12 +1921,11 @@ msgstr ""
msgid "Switch theme" msgid "Switch theme"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-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/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.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 #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target" msgid "Target"
msgstr "" msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
msgstr "Sarcini" msgstr "Sarcini"
@@ -2162,6 +2213,7 @@ msgstr ""
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-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
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr ""
msgid "Value" msgid "Value"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "View" msgid "View"
msgstr "" msgstr ""
@@ -2231,6 +2284,7 @@ msgstr ""
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Visible Fields" msgid "Visible Fields"
msgstr "" msgstr ""
+11 -10
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ru\n" "Language: ru\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-26 03:21\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Russian\n" "Language-Team: Russian\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" "Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} минута} few {{countString} ми
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}" msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr "" msgstr "{daysLeft, plural, one {# день} other {# дней}}"
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
@@ -62,7 +62,7 @@ msgstr "Ввод-вывод {diskName}"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}" msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr "" msgstr "{numFailed, plural, one {# сбой службы} other {# сбоев служб}}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# поток} few {# потока} many {# пот
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}" msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr "" msgstr "{totalCount, plural, one {# сервис} other {# сервисов}}"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -137,7 +137,7 @@ msgstr "Активное состояние"
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add" msgid "Add"
msgstr "" 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 #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -406,15 +406,15 @@ msgstr "Цельсий (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate" msgid "Certificate"
msgstr "" msgstr "Сертификат"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}" msgid "Certificate expired {expires}"
msgstr "" msgstr "Сертификат истёк {expires}"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}" msgid "Certificate expires {expires}"
msgstr "" msgstr "Сертификат истекает {expires}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -905,7 +905,7 @@ msgstr "Завершился активным"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired" msgid "Expired"
msgstr "" msgstr "Срок истек"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
@@ -1977,7 +1977,7 @@ 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 #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]" msgid "target[,protocol[,port[,interval]]]"
msgstr "" msgstr "target[,protocol[,port[,интервал]]]"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -2359,3 +2359,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Ваши настройки пользователя были обновлены." msgstr "Ваши настройки пользователя были обновлены."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: sl\n" "Language: sl\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Slovenian\n" "Language-Team: Slovenian\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n" "Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
@@ -2359,3 +2359,4 @@ msgstr "Da"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Vaše uporabniške nastavitve so posodobljene." msgstr "Vaše uporabniške nastavitve so posodobljene."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: sr\n" "Language: sr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Serbian (Cyrillic)\n" "Language-Team: Serbian (Cyrillic)\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" "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"
@@ -2359,3 +2359,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Ваша корисничка подешавања су ажурирана." msgstr "Ваша корисничка подешавања су ажурирана."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: sv\n" "Language: sv\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Swedish\n" "Language-Team: Swedish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Ja"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Dina användarinställningar har uppdaterats." msgstr "Dina användarinställningar har uppdaterats."
+67 -13
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: th\n" "Language: th\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:22\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Thai\n" "Language-Team: Thai\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -52,14 +52,26 @@ msgstr ""
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}" msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
msgstr "" msgstr ""
@@ -123,10 +135,10 @@ msgstr ""
msgid "Active state" msgid "Active state"
msgstr "" msgstr ""
#: src/components/add-system.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/add-system.tsx msgid "Add"
#: src/components/navbar.tsx msgstr ""
#: 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/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr ""
msgid "Add {foo}" msgid "Add {foo}"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr ""
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Add URL" msgid "Add URL"
msgstr "" msgstr ""
@@ -260,6 +279,7 @@ msgstr ""
msgid "Average, minimum, and maximum response time" msgid "Average, minimum, and maximum response time"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h" msgid "Avg 1h"
msgstr "" msgstr ""
@@ -383,6 +403,19 @@ msgstr ""
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
msgstr "" msgstr ""
@@ -776,12 +809,15 @@ msgstr ""
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
#: src/components/add-system.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 "Edit {foo}" msgid "Edit {foo}"
msgstr "" msgstr ""
#: src/components/add-system.tsx
msgid "Edit System"
msgstr ""
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx #: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr ""
msgid "Exited active" msgid "Exited active"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
msgstr "" msgstr ""
@@ -887,10 +927,6 @@ msgstr ""
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr ""
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:" msgid "Failed Attributes:"
msgstr "" msgstr ""
@@ -1090,6 +1126,7 @@ msgstr ""
msgid "Inactive" msgid "Inactive"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Interval" msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss" msgid "Loss"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h" msgid "Loss 1h"
msgstr "" msgstr ""
@@ -1204,6 +1242,7 @@ msgstr ""
msgid "Max 1 min" msgid "Max 1 min"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h" msgid "Max 1h"
msgstr "" msgstr ""
@@ -1234,6 +1273,7 @@ msgstr ""
msgid "Memory usage of containers" msgid "Memory usage of containers"
msgstr "การใช้หน่วยความจำของคอนเทนเนอร์" msgstr "การใช้หน่วยความจำของคอนเทนเนอร์"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h" msgid "Min 1h"
msgstr "" msgstr ""
@@ -1565,6 +1605,7 @@ msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol" msgid "Protocol"
msgstr "" msgstr ""
@@ -1653,6 +1694,7 @@ msgstr ""
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx #: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response" msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr ""
msgid "S.M.A.R.T. Self-Test" msgid "S.M.A.R.T. Self-Test"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}" msgid "Save {foo}"
msgstr "" msgstr ""
@@ -1712,6 +1753,10 @@ msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
#: src/components/add-system.tsx
msgid "Save System"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it." msgid "Saved in the database and does not expire until you disable it."
msgstr "" msgstr ""
@@ -1833,6 +1878,7 @@ msgstr ""
msgid "SMTP settings" msgid "SMTP settings"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Sort By" msgid "Sort By"
msgstr "" msgstr ""
@@ -1875,12 +1921,11 @@ msgstr ""
msgid "Switch theme" msgid "Switch theme"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-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/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.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 #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target" msgid "Target"
msgstr "" msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
msgstr "" msgstr ""
@@ -2162,6 +2213,7 @@ msgstr ""
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-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
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr ""
msgid "Value" msgid "Value"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "View" msgid "View"
msgstr "" msgstr ""
@@ -2231,6 +2284,7 @@ msgstr ""
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Visible Fields" msgid "Visible Fields"
msgstr "" msgstr ""
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: tr\n" "Language: tr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Turkish\n" "Language-Team: Turkish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Evet"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Kullanıcı ayarlarınız güncellendi." msgstr "Kullanıcı ayarlarınız güncellendi."
+67 -13
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ug\n" "Language: ug\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:22\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Uyghur\n" "Language-Team: Uyghur\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -52,14 +52,26 @@ msgstr ""
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}" msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
msgstr "" msgstr ""
@@ -123,10 +135,10 @@ msgstr ""
msgid "Active state" msgid "Active state"
msgstr "" msgstr ""
#: src/components/add-system.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/add-system.tsx msgid "Add"
#: src/components/navbar.tsx msgstr ""
#: 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/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr ""
msgid "Add {foo}" msgid "Add {foo}"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr ""
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Add URL" msgid "Add URL"
msgstr "" msgstr ""
@@ -260,6 +279,7 @@ msgstr ""
msgid "Average, minimum, and maximum response time" msgid "Average, minimum, and maximum response time"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h" msgid "Avg 1h"
msgstr "" msgstr ""
@@ -383,6 +403,19 @@ msgstr ""
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
msgstr "" msgstr ""
@@ -776,12 +809,15 @@ msgstr ""
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
#: src/components/add-system.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 "Edit {foo}" msgid "Edit {foo}"
msgstr "" msgstr ""
#: src/components/add-system.tsx
msgid "Edit System"
msgstr ""
#: src/components/login/auth-form.tsx #: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx #: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr ""
msgid "Exited active" msgid "Exited active"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
msgstr "" msgstr ""
@@ -887,10 +927,6 @@ msgstr ""
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr ""
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:" msgid "Failed Attributes:"
msgstr "" msgstr ""
@@ -1090,6 +1126,7 @@ msgstr ""
msgid "Inactive" msgid "Inactive"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Interval" msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss" msgid "Loss"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h" msgid "Loss 1h"
msgstr "" msgstr ""
@@ -1204,6 +1242,7 @@ msgstr ""
msgid "Max 1 min" msgid "Max 1 min"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h" msgid "Max 1h"
msgstr "" msgstr ""
@@ -1234,6 +1273,7 @@ msgstr ""
msgid "Memory usage of containers" msgid "Memory usage of containers"
msgstr "كونتېينېرلارنىڭ ئەسلەك ئىشلىتىشى" msgstr "كونتېينېرلارنىڭ ئەسلەك ئىشلىتىشى"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h" msgid "Min 1h"
msgstr "" msgstr ""
@@ -1565,6 +1605,7 @@ msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol" msgid "Protocol"
msgstr "" msgstr ""
@@ -1653,6 +1694,7 @@ msgstr ""
msgid "Resolved" msgid "Resolved"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx #: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response" msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr ""
msgid "S.M.A.R.T. Self-Test" msgid "S.M.A.R.T. Self-Test"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}" msgid "Save {foo}"
msgstr "" msgstr ""
@@ -1712,6 +1753,10 @@ msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
#: src/components/add-system.tsx
msgid "Save System"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it." msgid "Saved in the database and does not expire until you disable it."
msgstr "" msgstr ""
@@ -1833,6 +1878,7 @@ msgstr ""
msgid "SMTP settings" msgid "SMTP settings"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Sort By" msgid "Sort By"
msgstr "" msgstr ""
@@ -1875,12 +1921,11 @@ msgstr ""
msgid "Switch theme" msgid "Switch theme"
msgstr "" msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-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/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.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 #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target" msgid "Target"
msgstr "" msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
msgstr "" msgstr ""
@@ -2162,6 +2213,7 @@ msgstr ""
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-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
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr ""
msgid "Value" msgid "Value"
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "View" msgid "View"
msgstr "" msgstr ""
@@ -2231,6 +2284,7 @@ msgstr ""
msgid "View your 200 most recent alerts." msgid "View your 200 most recent alerts."
msgstr "" msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Visible Fields" msgid "Visible Fields"
msgstr "" msgstr ""
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: uk\n" "Language: uk\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Ukrainian\n" "Language-Team: Ukrainian\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" "Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
@@ -2359,3 +2359,4 @@ msgstr "Так"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Ваші налаштування користувача були оновлені." msgstr "Ваші налаштування користувача були оновлені."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: uz\n" "Language: uz\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:16\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Uzbek\n" "Language-Team: Uzbek\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -2359,3 +2359,4 @@ msgstr "Ha"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Foydalanuvchi sozlamalaringiz yangilandi." msgstr "Foydalanuvchi sozlamalaringiz yangilandi."
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: vi\n" "Language: vi\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Vietnamese\n" "Language-Team: Vietnamese\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -2359,3 +2359,4 @@ msgstr "Có"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Cài đặt người dùng của bạn đã được cập nhật." msgstr "Cài đặt người dùng của bạn đã được cập nhật."
+11 -10
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n" "Language: zh\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-25 07:01\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Chinese Simplified\n" "Language-Team: Chinese Simplified\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} 分钟} few {{countString} 分钟} ma
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}" msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr "" msgstr "{daysLeft, plural, other {# 天}}"
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
@@ -62,7 +62,7 @@ msgstr "{diskName} I/O"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}" msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr "" msgstr "{numFailed, plural, other {# 个失败服务}}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# 线程} other {# 线程}}"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}" msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr "" msgstr "{totalCount, plural, other {# 个服务}}"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -137,7 +137,7 @@ msgstr "活动状态"
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add" msgid "Add"
msgstr "" 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 #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -406,15 +406,15 @@ msgstr "摄氏度 (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate" msgid "Certificate"
msgstr "" msgstr "证书"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}" msgid "Certificate expired {expires}"
msgstr "" msgstr "证书已过期 {expires}"
#: src/components/network-monitors-table/network-monitors-table.tsx #: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}" msgid "Certificate expires {expires}"
msgstr "" msgstr "证书将于 {expires} 过期"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -905,7 +905,7 @@ msgstr "退出活动状态"
#: src/components/network-monitors-table/network-monitors-columns.tsx #: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired" msgid "Expired"
msgstr "" msgstr "已过期"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart." msgid "Expires after one hour or on hub restart."
@@ -1977,7 +1977,7 @@ 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 #: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]" msgid "target[,protocol[,port[,interval]]]"
msgstr "" msgstr "目标[,协议[,端口[,间隔]]]"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -2359,3 +2359,4 @@ msgstr "是"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "您的用户设置已更新。" msgstr "您的用户设置已更新。"
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n" "Language: zh\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:16\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Chinese Traditional, Hong Kong\n" "Language-Team: Chinese Traditional, Hong Kong\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -2359,3 +2359,4 @@ msgstr "是"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "您的用戶設置已更新。" msgstr "您的用戶設置已更新。"
+2 -1
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n" "Language: zh\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-24 16:15\n" "PO-Revision-Date: 2026-09-24 17:29\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Chinese Traditional\n" "Language-Team: Chinese Traditional\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -2359,3 +2359,4 @@ msgstr "是"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "已更新您的使用者設定" msgstr "已更新您的使用者設定"
+14
View File
@@ -459,6 +459,20 @@ export interface AlertInfo {
/** Additional information that remains visible while the alert is enabled */ /** Additional information that remains visible while the alert is enabled */
note?: () => string note?: () => string
invert?: boolean invert?: boolean
/** Selectable threshold units. Values are stored in the first unit (factor 1) */
units?: AlertUnit[]
}
export interface AlertUnit {
/** Unit suffix shown after the value */
unit: string
/** Multiplier converting a value in this unit to the stored value */
factor: number
min: number
max: number
step: number
/** Finer step for the number input, which also accepts values down to this step */
inputStep?: number
} }
export type AlertMap = Record<string, Map<string, AlertRecord>> export type AlertMap = Record<string, Map<string, AlertRecord>>