Compare commits

..
2 Commits
66 changed files with 1100 additions and 1420 deletions
+6
View File
@@ -35,6 +35,12 @@ type CpuMetrics struct {
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements. // getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
// It returns percentages for total, user, system, iowait, and steal time. // It returns percentages for total, user, system, iowait, and steal time.
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) { func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
// Inside a container, /proc/stat reports the host cores' counters (via
// lxcfs on LXC, or the host's procfs elsewhere), not the container's own
// usage. Prefer the cgroup's own CPU accounting when available. (#2332)
if metrics, ok := containerCpuMetrics(cacheTimeMs); ok {
return metrics, nil
}
times, err := cpu.Times(false) times, err := cpu.Times(false)
if err != nil || len(times) == 0 { if err != nil || len(times) == 0 {
return CpuMetrics{}, err return CpuMetrics{}, err
+417
View File
@@ -0,0 +1,417 @@
//go:build linux
package agent
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
)
// Container-aware CPU accounting (issue #2332).
//
// Inside a container /proc/stat does not describe the container's own usage:
// lxcfs serves LXC guests the raw counters of the host cores in their cpuset,
// and plain runtimes (Docker, k8s) expose the host's /proc outright. An idle
// container sharing a host core with a busy neighbor then reports near-100%
// CPU while doing nothing. The cgroup's own accounting (cpu.stat /
// cpuacct.usage) reflects only the container's processes, so when the agent
// runs inside a container we derive CPU% from that instead.
// File paths and hooks are variables so tests can point them at fixtures.
var (
cpuCgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point
cpuCgroupMountinfo = "/proc/self/mountinfo"
cpuProcSelfCgroup = "/proc/self/cgroup"
cpuProcOneEnviron = "/proc/1/environ"
cpuDockerenvPath = "/.dockerenv"
cpuContainerenv = "/run/.containerenv"
cpuSystemdContPath = "/run/systemd/container"
cpuNumCPU = runtime.NumCPU
cpuNow = time.Now
)
// cpuUserHZ is the USER_HZ jiffies-per-second rate cpuacct.stat reports in.
const cpuUserHZ = 100
var (
containerOnce sync.Once
containerDetected bool
)
// inContainer reports whether the agent itself runs inside a container.
// The result is cached because it cannot change during the process lifetime.
func inContainer() bool {
containerOnce.Do(func() { containerDetected = detectContainer() })
return containerDetected
}
// detectContainer looks for the usual container markers.
func detectContainer() bool {
// set by systemd-nspawn, LXC, Podman and others
if os.Getenv("container") != "" {
return true
}
for _, p := range []string{cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath} {
if utils.FileExists(p) {
return true
}
}
// liblxc always puts container=lxc in the container init's environment,
// which survives on non-systemd guests such as Alpine LXC.
if data, err := os.ReadFile(cpuProcOneEnviron); err == nil {
if bytes.HasPrefix(data, []byte("container=")) ||
bytes.Contains(data, []byte("\x00container=")) {
return true
}
}
// an lxcfs mount means /proc/stat is virtualized with host core counters
if data, err := os.ReadFile(cpuCgroupMountinfo); err == nil &&
bytes.Contains(data, []byte(" - fuse.lxcfs ")) {
return true
}
return false
}
// cgroupCpuSample is one read of the container's cumulative CPU accounting.
type cgroupCpuSample struct {
usageUsec uint64
userUsec uint64
systemUsec uint64
cores float64 // usable CPU cores: affinity ∩ cpuset ∩ quota
at time.Time
}
var lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
// init seeds the container CPU baseline so the first reported value is a real
// delta since startup rather than zero.
func init() {
if !inContainer() {
return
}
if s, ok := readContainerCpuSample(); ok {
s.at = cpuNow()
lastCgroupCpuSamples[60000] = s
}
}
// containerCpuMetrics derives CPU metrics from the agent's own cgroup
// accounting when running inside a container. It returns ok=false on plain
// hosts and whenever cgroup accounting is unreadable, so callers keep the
// /proc/stat fallback.
func containerCpuMetrics(cacheTimeMs uint16) (CpuMetrics, bool) {
if !inContainer() {
return CpuMetrics{}, false
}
cur, ok := readContainerCpuSample()
if !ok {
return CpuMetrics{}, false
}
cur.at = cpuNow()
prev, ok := lastCgroupCpuSamples[cacheTimeMs]
if !ok {
prev = lastCgroupCpuSamples[60000]
}
lastCgroupCpuSamples[cacheTimeMs] = cur
// No baseline yet, a backwards counter (cgroup recreated), or a
// non-positive clock delta: report zero this tick instead of guessing.
elapsedUsec := cur.at.Sub(prev.at).Microseconds()
if prev.at.IsZero() || elapsedUsec <= 0 || cur.usageUsec < prev.usageUsec {
return CpuMetrics{}, true
}
cores := cur.cores
if cores <= 0 {
cores = 1
}
window := float64(elapsedUsec) * cores
metrics := CpuMetrics{
Total: clampPercent(float64(cur.usageUsec-prev.usageUsec) / window * 100),
User: clampPercent(float64(cur.userUsec-prev.userUsec) / window * 100),
System: clampPercent(float64(cur.systemUsec-prev.systemUsec) / window * 100),
}
// cgroup accounting has no iowait/steal; everything not busy is idle.
metrics.Idle = clampPercent(100 - metrics.Total)
return metrics, true
}
// readContainerCpuSample reads the container's cumulative CPU usage, preferring
// the cgroup v2 unified hierarchy and falling back to the v1 cpuacct
// controller.
func readContainerCpuSample() (cgroupCpuSample, bool) {
if s, ok := readCgroupV2CpuSample(); ok {
return s, true
}
return readCgroupV1CpuSample()
}
// readCgroupV2CpuSample reads usage from the unified hierarchy's cpu.stat.
//
// The mount root is usually the right cgroup to read: inside a private cgroup
// namespace (LXC, default Docker) /sys/fs/cgroup already is the container's
// root cgroup, and its cpu.stat accounts for every process in the container,
// including siblings of the agent's own service cgroup. When the hierarchy is
// not namespaced (e.g. docker run --cgroupns=host) /proc/self/cgroup instead
// holds the container's real host-side path, which is joined onto the mount.
func readCgroupV2CpuSample() (cgroupCpuSample, bool) {
rel := selfCgroupPath("0::")
if rel == "" {
return cgroupCpuSample{}, false // no v2 membership; try v1
}
dir := cpuCgroupRoot
if mount := cgroupMountPoint("cgroup2", ""); mount != "" {
dir = mount
}
if rel != "/" && hasContainerRuntimeMarker(rel) {
if cand := filepath.Join(dir, rel); directoryExistsOK(cand) {
dir = cand
}
}
stat := filepath.Join(dir, "cpu.stat")
usage, ok := cgroupStatValue(stat, "usage_usec")
if !ok {
return cgroupCpuSample{}, false
}
s := cgroupCpuSample{usageUsec: usage, cores: cpuCgroupCores(dir)}
s.userUsec, _ = cgroupStatValue(stat, "user_usec")
s.systemUsec, _ = cgroupStatValue(stat, "system_usec")
return s, true
}
// readCgroupV1CpuSample reads usage from the legacy cpuacct controller.
// Runtimes bind-mount the container's own cpuacct directory at the hierarchy
// mount, so the mount root is normally already the container's cgroup; if the
// process's cgroup path still resolves below the mount (shared host view),
// that subdirectory is used instead.
func readCgroupV1CpuSample() (cgroupCpuSample, bool) {
mount := cgroupMountPoint("cgroup", "cpuacct")
if mount == "" {
return cgroupCpuSample{}, false
}
dir := mount
if rel := selfCgroupPath("cpuacct"); rel != "" && rel != "/" {
if cand := filepath.Join(mount, rel); utils.FileExists(filepath.Join(cand, "cpuacct.usage")) {
dir = cand
}
}
usageNs, ok := utils.ReadUintFile(filepath.Join(dir, "cpuacct.usage"))
if !ok {
return cgroupCpuSample{}, false
}
s := cgroupCpuSample{usageUsec: usageNs / 1000, cores: cpuCgroupCores(dir)}
// cpuacct.stat reports user/system in USER_HZ jiffies.
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "user"); ok {
s.userUsec = v * 1e6 / cpuUserHZ
}
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "system"); ok {
s.systemUsec = v * 1e6 / cpuUserHZ
}
return s, true
}
// selfCgroupPath returns the agent's cgroup path from /proc/self/cgroup: the
// path after "0::" for the v2 unified hierarchy, or the path of the entry
// whose controller list contains the given v1 controller (e.g. "cpuacct").
func selfCgroupPath(selector string) string {
data, err := os.ReadFile(cpuProcSelfCgroup)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, ":", 3)
if len(parts) != 3 {
continue
}
if selector == "0::" {
if parts[0] == "0" && parts[1] == "" {
return parts[2]
}
continue
}
for _, ctrl := range strings.Split(parts[1], ",") {
if ctrl == selector {
return parts[2]
}
}
}
return ""
}
// cgroupMountPoint returns the mount point of a cgroup hierarchy from
// /proc/self/mountinfo: the cgroup2 mount for v2, or the cgroup mount whose
// super options list the wanted v1 controller.
func cgroupMountPoint(fstype, v1ctrl string) string {
data, err := os.ReadFile(cpuCgroupMountinfo)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
left, right, found := strings.Cut(line, " - ")
if !found {
continue
}
post := strings.Fields(right)
if len(post) == 0 || post[0] != fstype {
continue
}
if v1ctrl != "" && !mountOptHas(post, v1ctrl) {
continue
}
fields := strings.Fields(left)
if len(fields) >= 5 {
return unescapeMountPoint(fields[4])
}
}
return ""
}
// mountOptHas reports whether the comma-separated super options (field 3 after
// the " - " separator) contain opt.
func mountOptHas(post []string, opt string) bool {
if len(post) < 3 {
return false
}
for _, o := range strings.Split(post[2], ",") {
if o == opt {
return true
}
}
return false
}
// unescapeMountPoint decodes octal escapes (e.g. \040 for space) used in
// mountinfo paths.
func unescapeMountPoint(s string) string {
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s)
}
// hasContainerRuntimeMarker reports whether a cgroup path looks like a real
// host-side container cgroup path (docker/k8s/lxc/podman), meaning the visible
// hierarchy is not namespaced and the path can be resolved under the mount.
func hasContainerRuntimeMarker(path string) bool {
for _, m := range []string{"docker", "kubepods", "lxc", "crio", "libpod", "containerd", "podman"} {
if strings.Contains(path, m) {
return true
}
}
return false
}
// cpuCgroupCores returns how many CPU cores the cgroup at dir may use: the
// smallest of the process affinity mask, the cgroup cpuset, and the CPU quota.
func cpuCgroupCores(dir string) float64 {
cores := float64(cpuNumCPU())
if n := cpusetCount(dir); n > 0 && n < cores {
cores = n
}
if q, ok := cpuQuotaCores(dir); ok && q < cores {
cores = q
}
if cores <= 0 {
cores = 1
}
return cores
}
// cpusetCount returns the number of CPUs in the cgroup's cpuset, e.g. "0-3" or
// "2,5-7". An empty or missing file means unconstrained.
func cpusetCount(dir string) float64 {
for _, name := range []string{"cpuset.cpus.effective", "cpuset.cpus"} {
raw, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
if n := countCpuList(strings.TrimSpace(string(raw))); n > 0 {
return float64(n)
}
}
return 0
}
// countCpuList counts the CPUs in a Linux CPU list like "0-3,5,8-9".
func countCpuList(list string) int {
total := 0
for _, part := range strings.Split(list, ",") {
lo, hi, ranged := strings.Cut(part, "-")
a, err := strconv.Atoi(lo)
if err != nil {
continue
}
b := a
if ranged {
if v, err := strconv.Atoi(hi); err == nil {
b = v
}
}
if b >= a {
total += b - a + 1
}
}
return total
}
// cpuQuotaCores returns the cgroup's CPU quota in cores. v2 uses cpu.max
// ("<quota|max> <period>"), v1 uses cpu.cfs_quota_us / cpu.cfs_period_us.
func cpuQuotaCores(dir string) (float64, bool) {
if raw, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil {
fields := strings.Fields(string(raw))
if len(fields) == 2 && fields[0] != "max" {
if quota, err := strconv.ParseFloat(fields[0], 64); err == nil && quota > 0 {
if period, err := strconv.ParseFloat(fields[1], 64); err == nil && period > 0 {
return quota / period, true
}
}
}
}
if quota, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 {
if period, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 {
return float64(quota) / float64(period), true
}
}
return 0, false
}
// cgroupStatValue returns the value of key in a cgroup "key value" stat file.
func cgroupStatValue(path, key string) (uint64, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
for _, line := range strings.Split(string(data), "\n") {
name, value, found := strings.Cut(line, " ")
if !found || name != key {
continue
}
v, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
return v, err == nil
}
return 0, false
}
// readCgroupInt reads a file containing a single signed integer
// (cpu.cfs_quota_us is -1 when no quota is set).
func readCgroupInt(path string) (int64, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
v, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return v, err == nil
}
// directoryExistsOK reports whether path is a directory.
func directoryExistsOK(path string) bool {
ok, _ := directoryExists(path)
return ok
}
+337
View File
@@ -0,0 +1,337 @@
//go:build testing && linux
package agent
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// swapCpuContainerSeams points every container-detection and cgroup path at
// empty fixtures under a temp dir, then restores them on cleanup.
func swapCpuContainerSeams(t *testing.T) {
t.Helper()
backup := struct {
root, mountinfo, selfCgroup, oneEnviron, dockerenv, containerenv, systemdCont string
numCPU func() int
now func() time.Time
}{
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuProcOneEnviron,
cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath, cpuNumCPU, cpuNow,
}
detected := containerDetected
samples := lastCgroupCpuSamples
env, hadEnv := os.LookupEnv("container")
t.Cleanup(func() {
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuProcOneEnviron = backup.root, backup.mountinfo, backup.selfCgroup, backup.oneEnviron
cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath = backup.dockerenv, backup.containerenv, backup.systemdCont
cpuNumCPU, cpuNow = backup.numCPU, backup.now
containerOnce = sync.Once{}
containerDetected = detected
lastCgroupCpuSamples = samples
if hadEnv {
os.Setenv("container", env)
}
})
containerOnce = sync.Once{}
containerDetected = false
lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
os.Unsetenv("container")
tmp := t.TempDir()
cpuCgroupRoot = filepath.Join(tmp, "cgroup")
cpuCgroupMountinfo = filepath.Join(tmp, "mountinfo")
cpuProcSelfCgroup = filepath.Join(tmp, "self-cgroup")
cpuProcOneEnviron = filepath.Join(tmp, "one-environ")
cpuDockerenvPath = filepath.Join(tmp, "dockerenv")
cpuContainerenv = filepath.Join(tmp, "containerenv")
cpuSystemdContPath = filepath.Join(tmp, "systemd-container")
}
func writeCpuFixture(t *testing.T, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}
// fakeNow installs a controllable clock and returns a function to advance it.
func fakeNow(t *testing.T) func(time.Duration) {
t.Helper()
cur := time.Unix(1_700_000_000, 0)
cpuNow = func() time.Time { return cur }
return func(d time.Duration) { cur = cur.Add(d) }
}
func TestDetectContainer(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
want bool
}{
{"plain host", func(t *testing.T) {}, false},
{"container env", func(t *testing.T) { t.Setenv("container", "lxc") }, true},
{".dockerenv", func(t *testing.T) { writeCpuFixture(t, cpuDockerenvPath, "") }, true},
{".containerenv", func(t *testing.T) { writeCpuFixture(t, cpuContainerenv, "") }, true},
{"systemd container", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "lxc\n") }, true},
{"init environ container=lxc", func(t *testing.T) {
writeCpuFixture(t, cpuProcOneEnviron, "PATH=/sbin\x00container=lxc\x00HOME=/root\x00")
}, true},
{"init environ without marker", func(t *testing.T) {
writeCpuFixture(t, cpuProcOneEnviron, "PATH=/sbin\x00HOME=/root\x00")
}, false},
{"lxcfs serving /proc", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
"31 25 0:28 / /proc/stat rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
}, true},
{"cgroup-only mountinfo", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
"36 25 0:32 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw,nsdelegate\n")
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
swapCpuContainerSeams(t)
tt.setup(t)
assert.Equal(t, tt.want, detectContainer())
})
}
}
func TestReadCgroupV2CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 2000000\nsystem_usec 1000000\nnr_throttled 7\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
cpuNumCPU = func() int { return 8 }
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 3000000, s.usageUsec)
assert.EqualValues(t, 2000000, s.userUsec)
assert.EqualValues(t, 1000000, s.systemUsec)
assert.InDelta(t, 4, s.cores, 0.001) // cpuset 2,5-7 = 4 cores
}
// In a namespaced container the agent may sit in a sub-cgroup (e.g. a systemd
// service); the mount root still accounts for the whole container and must win.
func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/system.slice/beszel-agent.service\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "system.slice/beszel-agent.service/cpu.stat"), "usage_usec 5\n")
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 9000, s.usageUsec)
}
// Without a cgroup namespace the mount shows the real host hierarchy and the
// container's own path (docker/kubepods/lxc markers) resolves under it.
func TestReadCgroupV2ResolvesRuntimePath(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/system.slice/docker-deadbeef.scope\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
sub := filepath.Join(cpuCgroupRoot, "system.slice/docker-deadbeef.scope")
writeCpuFixture(t, filepath.Join(sub, "cpu.stat"), "usage_usec 5\n")
writeCpuFixture(t, filepath.Join(sub, "cpu.max"), "100000 100000\n")
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 5, s.usageUsec)
assert.InDelta(t, 1, s.cores, 0.001) // cpu.max quota of 1 core
}
func TestReadCgroupV1CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
v1 := filepath.Join(t.TempDir(), "cpuacct")
writeCpuFixture(t, cpuCgroupMountinfo,
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpuacct\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "2000000000\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.stat"), "user 100\nsystem 50\n")
cpuNumCPU = func() int { return 4 }
s, ok := readContainerCpuSample() // no 0:: line -> falls through to v1
require.True(t, ok)
assert.EqualValues(t, 2000000, s.usageUsec) // ns -> usec
assert.EqualValues(t, 1000000, s.userUsec) // 100 jiffies * 1e6/100
assert.EqualValues(t, 500000, s.systemUsec) // 50 jiffies
assert.InDelta(t, 4, s.cores, 0.001)
}
func TestContainerCpuMetricsMath(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "0-3\n")
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 1000000\nuser_usec 600000\nsystem_usec 400000\n")
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total) // first call only seeds the baseline
// 1s elapsed, container burned 2 core-seconds on 4 usable cores
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 1600000\nsystem_usec 900000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01)
assert.InDelta(t, 25, m.User, 0.01)
assert.InDelta(t, 12.5, m.System, 0.01)
assert.Zero(t, m.Iowait)
assert.Zero(t, m.Steal)
assert.InDelta(t, 50, m.Idle, 0.01)
}
func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
containerCpuMetrics(60000)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01) // 1 core-second against a 2-core quota
}
func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
// seed the baseline, then do not advance the clock: elapsed <= 0
containerCpuMetrics(60000)
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total)
// counter goes backwards (cgroup recreated): report zero and re-baseline
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 100000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total)
// next tick measures from the new baseline, not the stale one
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 25, m.Total, 0.01) // 1e6 usec / (1s * 4 cores)
}
func TestContainerCpuMetricsFallbacks(t *testing.T) {
t.Run("not in container", func(t *testing.T) {
swapCpuContainerSeams(t)
_, ok := containerCpuMetrics(60000)
assert.False(t, ok)
})
t.Run("in container without cgroup accounting", func(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
// cpuCgroupRoot has no cpu.stat
_, ok := containerCpuMetrics(60000)
assert.False(t, ok)
})
}
// The host path must keep reporting through gopsutil untouched.
func TestGetCpuMetricsHostFallback(t *testing.T) {
swapCpuContainerSeams(t)
m, err := getCpuMetrics(60000)
require.NoError(t, err)
assert.GreaterOrEqual(t, m.Total, 0.0)
assert.LessOrEqual(t, m.Total, 100.0)
}
// Inside a container getCpuMetrics must report the cgroup-derived value, not
// the host core counters from /proc/stat.
func TestGetCpuMetricsPrefersCgroup(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 0\n")
m, err := getCpuMetrics(60000)
require.NoError(t, err)
assert.Zero(t, m.Total)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, err = getCpuMetrics(60000)
require.NoError(t, err)
assert.InDelta(t, 50, m.Total, 0.01)
}
func TestCountCpuList(t *testing.T) {
assert.Equal(t, 4, countCpuList("0-3"))
assert.Equal(t, 4, countCpuList("2,5-7"))
assert.Equal(t, 1, countCpuList("2"))
assert.Equal(t, 0, countCpuList(""))
assert.Equal(t, 0, countCpuList("max"))
assert.Equal(t, 6, countCpuList("0-3,8-9"))
}
func TestCpuQuotaCores(t *testing.T) {
dir := t.TempDir()
_, ok := cpuQuotaCores(dir)
assert.False(t, ok) // no quota files
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "max 100000\n")
_, ok = cpuQuotaCores(dir)
assert.False(t, ok) // unlimited
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "150000 100000\n")
q, ok := cpuQuotaCores(dir)
require.True(t, ok)
assert.InDelta(t, 1.5, q, 0.001)
// v1 files
v1 := t.TempDir()
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "-1\n")
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_period_us"), "100000\n")
_, ok = cpuQuotaCores(v1)
assert.False(t, ok)
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "50000\n")
q, ok = cpuQuotaCores(v1)
require.True(t, ok)
assert.InDelta(t, 0.5, q, 0.001)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package agent
// containerCpuMetrics is Linux-only (cgroup accounting). Other platforms keep
// the gopsutil /proc path.
func containerCpuMetrics(uint16) (CpuMetrics, bool) {
return CpuMetrics{}, false
}
+4 -10
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, ok := a.diskPrev[cacheTimeMs][name] prev, hasPrev := a.diskPrev[cacheTimeMs][name]
firstSample := !ok if !hasPrev {
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
if prev, ok = a.diskBaseline[name]; !ok { prev, hasPrev = a.diskBaseline[name]
if !hasPrev {
prev = prevDiskFromCounter(d, now) prev = prevDiskFromCounter(d, now)
} }
} }
@@ -697,12 +697,6 @@ 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
+8 -57
View File
@@ -75,30 +75,17 @@ func TestUpdateDiskIoTimeCounterWrap(t *testing.T) {
} }
} }
// backdateDiskBaseline moves the baseline of a device into the past so the // The first sample of a cache interval has no snapshot of its own. It must
// next seeded sample of an interval spans d. // measure the time counters from the same baseline as the byte counters.
func backdateDiskBaseline(a *Agent, name string, d time.Duration) { func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
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)
return func(line string) { writeDiskstats := 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")
@@ -110,7 +97,9 @@ 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")
@@ -126,7 +115,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.
backdateDiskBaseline(a, "sda", time.Second) time.Sleep(150 * time.Millisecond)
// 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)
@@ -134,41 +123,3 @@ 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)
}
-1
View File
@@ -866,7 +866,6 @@ 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{
"timestamps": []string{"1"},
"stdout": []string{"1"}, "stdout": []string{"1"},
"stderr": []string{"1"}, "stderr": []string{"1"},
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)}, "tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
+1 -2
View File
@@ -7,6 +7,5 @@ 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 adapter alias (e.g. `Wi-Fi`), falling back - Windows: native WLAN API, keyed by interface GUID.
to interface GUID.
- Other platforms: unsupported. - Other platforms: unsupported.
+1 -22
View File
@@ -17,10 +17,6 @@ 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
@@ -88,7 +84,7 @@ func collect(ctx context.Context) map[string]system.WiFi {
} }
wlanFree.Call(uintptr(data)) wlanFree.Call(uintptr(data))
} }
result[interfaceName(&iface.GUID)] = reading result[iface.GUID.String()] = reading
} }
return result return result
} }
@@ -101,20 +97,3 @@ 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()
}
+5 -77
View File
@@ -18,11 +18,6 @@ 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 {
@@ -66,7 +61,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", "iostats", "objset-*"} { for _, filename := range []string{"state", "io", "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
@@ -102,25 +97,11 @@ func PoolKernelStats() ([]PoolKernelStat, error) {
return pools, nil return pools, nil
} }
// readPoolCounters supports the ZFS kernel interfaces. OpenZFS 2.3+ exposes // readPoolCounters supports both ZFS kernel interfaces. OpenZFS through 2.3
// pool-wide read/write counters in "iostats" that cover every objset, // exposes aggregate vdev counters in "io". When that file is unavailable, sum
// including mounted snapshots, which never get an "objset-*" kstat. OpenZFS // the logical I/O counters exposed for each dataset in the pool.
// 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 := readPoolIOStats(filepath.Join(poolDir, "iostats")) nread, nwrite, err := readPoolIO(filepath.Join(poolDir, "io"))
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
} }
@@ -163,59 +144,6 @@ 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,111 +66,6 @@ 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()
@@ -194,22 +89,6 @@ 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 uint `json:"power_cycles"` PowerCycles uint16 `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 uint `json:"power_cycle_count"` PowerCycleCount uint16 `json:"power_cycle_count"`
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"` PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
} }
+3 -23
View File
@@ -1,11 +1,10 @@
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 { useEffect, useMemo, useState } from "react" import { 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"
@@ -50,9 +49,6 @@ 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[] = []
@@ -73,24 +69,9 @@ 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
// stay hidden after dismissing until an alert triggers that wasn't active at the time if (alertCount === 0) {
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
@@ -99,7 +80,6 @@ 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 ? (
@@ -137,5 +117,5 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
})} })}
</AlertBannerSheet> </AlertBannerSheet>
) )
}, [alertsKey, systemCount, systems, open, className, dismissedIds]) }, [alertsKey, systemCount, systems, open, className])
} }
@@ -1,5 +1,5 @@
import { Trans, useLingui } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon, XIcon } from "lucide-react" import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon } 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,7 +20,6 @@ export function AlertBannerSheet({
sheetTitle, sheetTitle,
sheetDescription, sheetDescription,
icon: Icon = TriangleAlertIcon, icon: Icon = TriangleAlertIcon,
onDismiss,
className, className,
children, children,
}: { }: {
@@ -32,12 +31,9 @@ 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)}>
@@ -46,7 +42,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-400/80">{description}</AlertDescription> <AlertDescription className="text-destructive/80 dark:text-red-500/80">{description}</AlertDescription>
)} )}
</div> </div>
</div> </div>
@@ -54,24 +50,12 @@ 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-400 dark:hover:bg-destructive/15 dark:hover:text-red-400 max-sm:w-full max-sm:order-last" 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"
> >
{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">
@@ -104,7 +88,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 dark:text-red-400 shrink-0"> <div className="rounded-md bg-destructive/10 p-2 text-destructive 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,7 +10,6 @@ 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"
@@ -18,7 +17,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, AlertUnit, SystemRecord } from "@/types" import type { AlertInfo, AlertRecord, SystemRecord } from "@/types"
const Slider = lazy(() => import("@/components/ui/slider")) const Slider = lazy(() => import("@/components/ui/slider"))
@@ -28,24 +27,6 @@ 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({
@@ -264,17 +245,7 @@ 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 { units } = alertData const [value, setValue] = useState(alert?.value ?? (noThreshold ? 0 : (alertData.start ?? 80)))
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
@@ -296,12 +267,12 @@ export function AlertContent({
return systemIds return systemIds
} }
function sendUpsert(min: number, value: number, valueFactor = factor) { function sendUpsert(min: number, value: number) {
const systems = getSystemIds() const systems = getSystemIds()
systems.length && systems.length &&
upsertAlerts({ upsertAlerts({
name: alertKey, name: alertKey,
value: Number((value * valueFactor).toFixed(6)), value,
min, min,
systems, systems,
}) })
@@ -355,7 +326,7 @@ export function AlertContent({
Average drops below{" "} Average drops below{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{value} {value}
{unitLabel} {alertData.unit}
</strong> </strong>
</Trans> </Trans>
) : ( ) : (
@@ -363,7 +334,7 @@ export function AlertContent({
Average exceeds{" "} Average exceeds{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{value} {value}
{unitLabel} {alertData.unit}
</strong> </strong>
</Trans> </Trans>
)} )}
@@ -374,9 +345,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={step} step={alertData.step ?? 1}
min={valueMin ?? 1} min={alertData.min ?? 1}
max={valueMax ?? 99} max={alertData.max ?? 99}
/> />
<Input <Input
type="number" type="number"
@@ -384,45 +355,17 @@ 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)) {
// keep values on the step grid so the unit can be inferred on reload if (alertData.max != null) val = Math.min(val, alertData.max)
if (unit) val = roundToStep(val, inputStep) if (alertData.min != null) val = Math.max(val, alertData.min)
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={inputStep} step={alertData.step ?? 1}
min={inputMin ?? 1} min={alertData.min ?? 1}
max={valueMax ?? 99} max={alertData.max ?? 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,8 +32,7 @@ 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, LogsIconButton, LogsTimestampToggle } from "@/components/logs-display" import { LogsDisplay, LogsFullscreenDialog } from "@/components/logs-display"
import { getLogTimestampDecorations } from "@/lib/logs"
const syntaxTheme = "github-dark-dimmed" const syntaxTheme = "github-dark-dimmed"
@@ -283,13 +282,7 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
container: container.id, container: container.id,
}), }),
]) ])
return logsHtml.logs return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
? 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 ""
@@ -417,15 +410,20 @@ 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>
<LogsTimestampToggle className="ms-auto" /> <Button
<LogsIconButton label={t`Refresh`} onClick={refreshLogs} disabled={isRefreshingLogs}> variant="ghost"
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" : ""}`}
/> />
</LogsIconButton> </Button>
<LogsIconButton label={t`Fullscreen`} onClick={() => setLogsFullscreenOpen(true)}> <Button variant="ghost" size="sm" onClick={() => setLogsFullscreenOpen(true)} className="h-8 w-8 p-0">
<MaximizeIcon className="size-4" /> <MaximizeIcon className="size-4" />
</LogsIconButton> </Button>
</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">
+5 -60
View File
@@ -1,12 +1,8 @@
/** 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 { useStore } from "@nanostores/react" import { RefreshCwIcon } from "lucide-react"
import { ClockIcon, RefreshCwIcon } from "lucide-react" import { type RefObject, useEffect, useRef } from "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 = {
@@ -14,51 +10,13 @@ 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"]
)} )}
> >
@@ -82,7 +40,6 @@ 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(() => {
@@ -100,31 +57,19 @@ 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 <div className="min-h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm">
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>
<LogsTimestampToggle className="absolute end-18 top-2 hover:bg-white/10 hover:text-white aria-pressed:bg-white/15 aria-pressed:text-white" />
<Tooltip>
<TooltipTrigger asChild onFocus={keyboardFocusOnly}>
<button <button
onClick={onRefresh} onClick={onRefresh}
className={cn("absolute end-11 top-3 opacity-60 hover:opacity-100", dialogIconButtonClassName)} className={cn("absolute end-11 top-3 opacity-60 hover:opacity-100", dialogIconButtonClassName)}
disabled={isRefreshing} disabled={isRefreshing}
title={t`Refresh`}
aria-label={t`Refresh`} aria-label={t`Refresh`}
> >
<RefreshCwIcon <RefreshCwIcon className={cn("size-4 transition-transform duration-300", isRefreshing && "animate-spin")} />
className={cn("size-4 transition-transform duration-300", isRefreshing && "animate-spin")}
/>
</button> </button>
</TooltipTrigger>
<TooltipContent>{t`Refresh`}</TooltipContent>
</Tooltip>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react" import { 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,11 +23,10 @@ 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, GlobeIcon, ListIcon, type LucideIcon, PlusIcon, SearchIcon, ServerIcon } from "lucide-react" import { ChevronDownIcon, ListIcon, 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"
@@ -95,22 +94,9 @@ 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({
@@ -216,7 +202,6 @@ export function SystemMultiSelect({
className, className,
systemIds, systemIds,
placeholder, placeholder,
canSelectMore,
}: { }: {
id: string id: string
selectedSystemIds: Set<string> selectedSystemIds: Set<string>
@@ -226,107 +211,8 @@ 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)
@@ -339,15 +225,19 @@ function MultiSelect<T extends MultiSelectOption>({
}, []) }, [])
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
const query = search.trim().toLocaleLowerCase() const query = search.trim().toLocaleLowerCase()
const filteredOptions = options.filter((option) => option.label.toLocaleLowerCase().includes(query)) const filteredSystems = systems.filter(
const allSelected = filteredOptions.every((option) => selectedIds.has(option.id)) (system) =>
const anySelected = filteredOptions.some((option) => selectedIds.has(option.id)) (systemIds ? systemIds.includes(system.id) : supportsNetworkMonitors(system)) &&
system.name.toLocaleLowerCase().includes(query)
)
const allSelected = filteredSystems.every((system) => selectedSystemIds.has(system.id))
const anySelected = filteredSystems.some((system) => selectedSystemIds.has(system.id))
const selectFiltered = (selected: boolean) => { const selectFiltered = (selected: boolean) => {
const next = new Set(selectedIds) const next = new Set(selectedSystemIds)
for (const option of filteredOptions) { for (const system of filteredSystems) {
if (selected) next.add(option.id) if (selected) next.add(system.id)
else next.delete(option.id) else next.delete(system.id)
} }
onChange(next) onChange(next)
} }
@@ -361,13 +251,13 @@ function MultiSelect<T extends MultiSelectOption>({
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)}
> >
<Icon className="size-3.5 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" /> <ServerIcon className="size-3.5 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<span className="truncate"> <span className="truncate">
{selectedIds.size === 0 {selectedSystemIds.size === 0
? placeholder ? (placeholder ?? t`Select systems`)
: selectedIds.size === 1 : selectedSystemIds.size === 1
? options.find((option) => selectedIds.has(option.id))?.label ? systems.find((s) => selectedSystemIds.has(s.id))?.name
: t`${selectedIds.size} selected`} : t`${selectedSystemIds.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>
@@ -390,8 +280,8 @@ function MultiSelect<T extends MultiSelectOption>({
ref={focusSearchOnMount} ref={focusSearchOnMount}
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder={searchPlaceholder} placeholder={t`Search systems`}
aria-label={searchPlaceholder} aria-label={t`Search systems`}
className="h-10 min-w-0 rounded-none border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" 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
@@ -413,7 +303,7 @@ function MultiSelect<T extends MultiSelectOption>({
<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={!filteredOptions.length || allSelected || !canSelectMore} disabled={!filteredSystems.length || allSelected}
onSelect={(event) => { onSelect={(event) => {
event.preventDefault() event.preventDefault()
selectFiltered(true) selectFiltered(true)
@@ -435,29 +325,32 @@ function MultiSelect<T extends MultiSelectOption>({
{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">{t`${selectedIds.size} selected`}</span> <span className="px-1.5 text-xs tabular-nums text-muted-foreground">
{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">
{filteredOptions.length === 0 && ( {filteredSystems.length === 0 && (
<output className="block px-2.5 py-3 text-sm text-muted-foreground">{emptyText}</output> <output className="block px-2.5 py-3 text-sm text-muted-foreground">
<Trans>No systems found.</Trans>
</output>
)} )}
{filteredOptions.map((option) => ( {filteredSystems.map((sys) => (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={option.id} key={sys.id}
checked={selectedIds.has(option.id)} checked={selectedSystemIds.has(sys.id)}
disabled={!canSelectMore && !selectedIds.has(option.id)}
onSelect={(event) => event.preventDefault()} onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const next = new Set(selectedIds) const next = new Set(selectedSystemIds)
if (checked) next.add(option.id) if (checked) next.add(sys.id)
else next.delete(option.id) else next.delete(sys.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"
> >
{renderOption(option)} <span className="truncate">{sys.name}</span>
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
</div> </div>
@@ -727,7 +620,6 @@ 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(() => {
@@ -841,14 +733,8 @@ 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>
@@ -925,9 +811,7 @@ function MonitorDialogContent({
)} )}
<Button <Button
type="submit" type="submit"
disabled={ disabled={loading || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))}
loading || dnsTargetIsIp || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))
}
> >
{isEditing ? ( {isEditing ? (
<Trans>Save {{ foo: t`Monitor` }}</Trans> <Trans>Save {{ foo: t`Monitor` }}</Trans>
@@ -15,6 +15,7 @@ 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"
@@ -24,6 +25,9 @@ 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"
@@ -36,6 +40,7 @@ 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" }
@@ -303,6 +308,11 @@ 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>
@@ -347,8 +357,31 @@ export function getMonitorColumns(
}} }}
> >
<CopyIcon className="me-2.5 size-4" /> <CopyIcon className="me-2.5 size-4" />
<Trans>Copy bulk config</Trans> <Trans>Bulk copy</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, MonitorMultiSelect, SystemMultiSelect } from "./monitor-dialog" import { AddMonitorDialog, EditMonitorDialog, SystemMultiSelect } from "./monitor-dialog"
import { import {
ArrowDownIcon, ArrowDownIcon,
ArrowLeftRightIcon, ArrowLeftRightIcon,
@@ -67,8 +67,7 @@ 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 { getMonitorCompareState } from "@/lib/monitor-compare" import { useMatchingMonitors, useNetworkMonitorStats } from "@/lib/use-network-monitors"
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"
@@ -471,7 +470,6 @@ export default function NetworkMonitorsTableNew({
visibleColumnsKey={visibleColumnsKey} visibleColumnsKey={visibleColumnsKey}
rowSelection={rowSelection} rowSelection={rowSelection}
isLoading={isLoading} isLoading={isLoading}
includesAllSystems={!systemId}
/> />
</div> </div>
</Card> </Card>
@@ -485,7 +483,6 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
visibleColumnsKey, visibleColumnsKey,
rowSelection, rowSelection,
isLoading, isLoading,
includesAllSystems,
}: { }: {
table: TableType<NetworkMonitorRecord> table: TableType<NetworkMonitorRecord>
rows: Row<NetworkMonitorRecord>[] rows: Row<NetworkMonitorRecord>[]
@@ -493,8 +490,6 @@ 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)
@@ -565,8 +560,6 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
setSheetOpen(nextOpen) setSheetOpen(nextOpen)
}} }}
monitor={activeMonitor} monitor={activeMonitor}
monitors={table.options.data}
includesAllSystems={includesAllSystems}
/> />
</div> </div>
) )
@@ -637,29 +630,16 @@ 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 ( return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
<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" }
@@ -696,16 +676,10 @@ 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(() => {
@@ -717,7 +691,8 @@ function NetworkMonitorSheetContent({
const systems = useStore($allSystemsById) const systems = useStore($allSystemsById)
const system = systems[monitor.system] const system = systems[monitor.system]
const [compareTargetIds, setCompareTargetIds] = useState<Set<string>>(() => new Set()) // Same target probed from other systems, for side-by-side comparison (#2385).
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(""))
@@ -726,25 +701,16 @@ 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(""))
} }
// Other systems' monitors come from the table when it lists every system, otherwise from one fetch. const matchingSystemIds = useMemo(() => matchingMonitors.map((m) => m.system), [matchingMonitors])
const fetchedMonitors = useCompareMonitors(monitor.system, monitor.protocol, open && !includesAllSystems) // The opened system is always charted; the picker only adds other systems to compare against.
const compare = useMemo( const compareMonitors = useMemo(
() => () => [monitor, ...matchingMonitors.filter((m) => compareSystemIds.has(m.system))],
getMonitorCompareState({ [monitor, matchingMonitors, compareSystemIds]
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,
@@ -800,31 +766,21 @@ 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 min-w-0 basis-full sm:basis-0" className="bg-card flex-1 basis-48"
agentVersion={chartData.agentVersion} agentVersion={chartData.agentVersion}
chartTimeStore={chartTimeStore} chartTimeStore={chartTimeStore}
allowRealtime={false} allowRealtime={false}
/> />
<MonitorMultiSelect {matchingMonitors.length > 0 && (
id="monitor-compare-targets"
className="flex-1 min-w-0 basis-full sm:basis-0 bg-card"
monitors={compare.targetOptions}
selectedMonitorIds={compare.selectedTargetIds}
onChange={setCompareTargetIds}
disabled={compare.targetOptions.length === 0}
canSelectMore={compare.canAddTarget}
placeholder={t`Compare with other targets`}
/>
<SystemMultiSelect <SystemMultiSelect
id="monitor-compare-systems" 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"
systemIds={compare.systemOptions} systemIds={matchingSystemIds}
selectedSystemIds={compare.selectedSystemIds} selectedSystemIds={compareSystemIds}
onChange={setCompareSystemIds} onChange={setCompareSystemIds}
disabled={compare.systemOptions.length === 0}
canSelectMore={compare.canAddSystem}
placeholder={t`Compare with other systems`} placeholder={t`Compare with other systems`}
/> />
)}
</div> </div>
{comparing ? ( {comparing ? (
<> <>
@@ -834,7 +790,7 @@ function NetworkMonitorSheetContent({
monitors={compareMonitors} monitors={compareMonitors}
chartData={chartData} chartData={chartData}
empty={!hasMonitorStats} empty={!hasMonitorStats}
getLabel={compare.getLabel} getLabel={getSystemName}
filterStore={compareFilterStore} filterStore={compareFilterStore}
/> />
<LossChart <LossChart
@@ -843,7 +799,7 @@ function NetworkMonitorSheetContent({
monitors={compareMonitors} monitors={compareMonitors}
chartData={chartData} chartData={chartData}
empty={!hasMonitorStats} empty={!hasMonitorStats}
getLabel={compare.getLabel} getLabel={getSystemName}
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, LogsIconButton, LogsTimestampToggle } from "@/components/logs-display" import { LogsDisplay, LogsFullscreenDialog } 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,13 +41,7 @@ async function getSystemdLogsHtml(systemId: string, serviceName: string): Promis
query: { system: systemId, service: serviceName }, query: { system: systemId, service: serviceName },
}), }),
]) ])
return logs return logs ? highlighter.codeToHtml(logs, { lang: "log", theme: syntaxTheme }) : ""
? highlighter.codeToHtml(logs, {
lang: "log",
theme: syntaxTheme,
decorations: getLogTimestampDecorations(logs),
})
: ""
} }
export default function SystemdTable({ systemId }: { systemId?: string }) { export default function SystemdTable({ systemId }: { systemId?: string }) {
@@ -532,15 +526,28 @@ function SystemdSheet({
<h3 className="text-sm font-medium"> <h3 className="text-sm font-medium">
<Trans>Logs</Trans> <Trans>Logs</Trans>
</h3> </h3>
<LogsTimestampToggle className="ms-auto" /> <Button
<LogsIconButton label={t`Refresh`} onClick={loadLogs} disabled={isLoadingLogs}> variant="ghost"
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")}
/> />
</LogsIconButton> </Button>
<LogsIconButton label={t`Fullscreen`} onClick={() => setLogsFullscreenOpen(true)} disabled={!logs}> <Button
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" />
</LogsIconButton> </Button>
</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-400 dark:border-destructive/50 [&>svg]:text-destructive dark:[&>svg]:text-red-400", "bg-destructive/5 border-destructive/50 text-destructive dark:bg-destructive/6 dark:text-red-500 dark:border-destructive/70 [&>svg]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
-5
View File
@@ -53,11 +53,6 @@ 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
@@ -1,13 +0,0 @@
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,
}))
}
@@ -1,173 +0,0 @@
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
@@ -1,128 +0,0 @@
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,7 +98,11 @@ export function mergeMonitorStats(rawRecords: RawMonitorStatsRecord[], bucketMs
.map(([created, stats]) => ({ created, stats })) .map(([created, stats]) => ({ created, stats }))
} }
export { getMonitorTarget } from "./monitor-compare" export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" | "protocol" | "port">) {
if (monitor.protocol !== "tcp") return monitor.target
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
return `${host}:${monitor.port}`
}
/** 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,17 +40,6 @@ 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 of one protocol on all systems except the given one, to compare against (#2385). * Monitors on other systems that probe the same target (same protocol, target, port, and DNS server).
* Fetched per open so it also works in single-system tables, which only hold one system's monitors. * Fetched once per open so it also works in single-system tables, which only hold one system's monitors.
*/ */
export function useCompareMonitors(system: string, protocol: string, enabled = true) { export function useMatchingMonitors(monitor: NetworkMonitorRecord, enabled = true) {
const key = `${system}:${protocol}` const [matches, setMatches] = useState<NetworkMonitorRecord[]>([])
const [result, setResult] = useState<{ key: string; monitors: NetworkMonitorRecord[] }>({ key, monitors: [] }) const { id, system, protocol, target, port, server } = monitor
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: COMPARE_MONITOR_FIELDS, fields: NETWORK_MONITOR_FIELDS,
filter: pb.filter("system!={:system} && protocol={:protocol}", { system, protocol }), filter: pb.filter(
"id!={:id} && system!={:system} && protocol={:protocol} && target={:target} && port={:port} && server={:server}",
{ id, system, protocol, target, port, server }
),
}) })
.then((monitors) => { .then((records) => {
if (!cancelled) setResult({ key: `${system}:${protocol}`, monitors }) if (!cancelled) setMatches(records)
}) })
.catch((error) => { .catch((error) => {
if (!cancelled) console.error("Failed to fetch compare monitors:", error) if (!cancelled) console.error("Failed to fetch matching monitors:", error)
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [system, protocol, enabled]) }, [id, system, protocol, target, port, server, enabled])
// Keep showing the last result while reopening refreshes it, but never another monitor's. return matches
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<T>(() => { const [value, setValue] = useState(() => {
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] as const return [value, setValue]
} }
/** Format temperature to user's preferred unit */ /** Format temperature to user's preferred unit */
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:16\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,4 +2359,3 @@ 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 "تم تحديث إعدادات المستخدم الخاصة بك."
+1 -2
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 17:28\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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 "Настройките за потребителя ти са обновени."
+1 -2
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 17:28\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+1 -2
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 17:28\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:16\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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,11 +2354,3 @@ 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"
+1 -2
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 17:28\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:16\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,4 +2359,3 @@ 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 -10
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-25 14:03\n" "PO-Revision-Date: 2026-09-24 16:15\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 "{daysLeft, plural, one {# jour} other {# jours}}" 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"
@@ -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 "{numFailed, plural, one {# service en échec} other {# services en échec}}" 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}}"
@@ -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 "{totalCount, plural, one {# service} other {# services}}" msgstr ""
#: 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 "Ajouter" 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 "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 "Certificat" 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 "Certificat expiré {expires}" msgstr ""
#: 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 "Le certificat expire le {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."
@@ -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 "Expiré" 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."
@@ -2359,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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 "הגדרות המשתמש שלך עודכנו."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:16\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:16\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+17 -18
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-10-01 01:10\n" "PO-Revision-Date: 2026-09-24 16:15\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 "{daysLeft, plural, one {# 日} other {# 日}}" 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"
@@ -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 "{numFailed, plural, one {# 失敗したサービス} other {# 失敗したサービス}}" 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}}"
@@ -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 "{totalCount, plural, one {# サービス} other {# サービス}}" msgstr ""
#: 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 "証明書は{expires}に期限が切れました" msgstr ""
#: 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 "証明書は{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."
@@ -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 "target[,protocol[,port[,interval]]]" msgstr ""
#: 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,4 +2359,3 @@ 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 "ユーザー設定が更新されました。"
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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 "사용자 설정이 업데이트되었습니다."
+1 -9
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,11 +2359,3 @@ 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"
+10 -11
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 18:40\n" "PO-Revision-Date: 2026-09-24 16:15\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 "{daysLeft, plural, one {# dag} other {# dager}}" 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"
@@ -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 "{numFailed, plural, one {# feilet tjeneste} other {# feilede tjenester}}" 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}}"
@@ -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 "{totalCount, plural, one {# tjeneste} other {# tjenester}}" msgstr ""
#: 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 "Legg til" 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 "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 "Sertifikat" 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 "Sertifikatet utløper {expires}" msgstr ""
#: 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 "Sertifikatet utløper {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."
@@ -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 "Utløpt" 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 "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 "mål [,protokoll[,port[,interval]]" msgstr ""
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -2359,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+13 -67
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-24 17:29\n" "PO-Revision-Date: 2026-09-18 19:22\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,26 +52,14 @@ 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ă"
@@ -135,10 +123,10 @@ msgstr "Alerte Active"
msgid "Active state" msgid "Active state"
msgstr "Stare activă" msgstr "Stare activă"
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/add-system.tsx
msgid "Add" #: src/components/add-system.tsx
msgstr "" #: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -148,13 +136,6 @@ msgstr ""
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"
@@ -279,7 +260,6 @@ 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 ""
@@ -403,19 +383,6 @@ 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."
@@ -809,15 +776,12 @@ 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
@@ -903,10 +867,6 @@ 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 ""
@@ -927,6 +887,10 @@ 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 ""
@@ -1126,7 +1090,6 @@ 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"
@@ -1219,7 +1182,6 @@ 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 ""
@@ -1242,7 +1204,6 @@ 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 ""
@@ -1273,7 +1234,6 @@ 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 ""
@@ -1605,7 +1565,6 @@ 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 ""
@@ -1694,7 +1653,6 @@ 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"
@@ -1740,6 +1698,7 @@ 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 ""
@@ -1753,10 +1712,6 @@ 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 ""
@@ -1878,7 +1833,6 @@ 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 ""
@@ -1921,11 +1875,12 @@ 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
@@ -1970,15 +1925,9 @@ 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"
@@ -2213,7 +2162,6 @@ 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
@@ -2269,7 +2217,6 @@ 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 ""
@@ -2284,7 +2231,6 @@ 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 ""
+10 -11
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-26 03:21\n" "PO-Revision-Date: 2026-09-24 16:15\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 "{daysLeft, plural, one {# день} other {# дней}}" 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"
@@ -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 "{numFailed, plural, one {# сбой службы} other {# сбоев служб}}" 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}}"
@@ -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 "{totalCount, plural, one {# сервис} other {# сервисов}}" msgstr ""
#: 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 "Сертификат истёк {expires}" msgstr ""
#: 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 "Сертификат истекает {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."
@@ -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 "target[,protocol[,port[,интервал]]]" msgstr ""
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -2359,4 +2359,3 @@ 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 "Ваши настройки пользователя были обновлены."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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 "Ваша корисничка подешавања су ажурирана."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+13 -67
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-24 17:29\n" "PO-Revision-Date: 2026-09-18 19:22\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,26 +52,14 @@ 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 ""
@@ -135,10 +123,10 @@ msgstr ""
msgid "Active state" msgid "Active state"
msgstr "" msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/add-system.tsx
msgid "Add" #: src/components/add-system.tsx
msgstr "" #: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -148,13 +136,6 @@ 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 ""
@@ -279,7 +260,6 @@ 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 ""
@@ -403,19 +383,6 @@ 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 ""
@@ -809,15 +776,12 @@ 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
@@ -903,10 +867,6 @@ 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 ""
@@ -927,6 +887,10 @@ 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 ""
@@ -1126,7 +1090,6 @@ 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"
@@ -1219,7 +1182,6 @@ 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 ""
@@ -1242,7 +1204,6 @@ 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 ""
@@ -1273,7 +1234,6 @@ 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 ""
@@ -1605,7 +1565,6 @@ 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 ""
@@ -1694,7 +1653,6 @@ 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"
@@ -1740,6 +1698,7 @@ 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 ""
@@ -1753,10 +1712,6 @@ 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 ""
@@ -1878,7 +1833,6 @@ 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 ""
@@ -1921,11 +1875,12 @@ 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
@@ -1970,15 +1925,9 @@ 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 ""
@@ -2213,7 +2162,6 @@ 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
@@ -2269,7 +2217,6 @@ 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 ""
@@ -2284,7 +2231,6 @@ 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 ""
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+13 -67
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-24 17:29\n" "PO-Revision-Date: 2026-09-18 19:22\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,26 +52,14 @@ 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 ""
@@ -135,10 +123,10 @@ msgstr ""
msgid "Active state" msgid "Active state"
msgstr "" msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/add-system.tsx
msgid "Add" #: src/components/add-system.tsx
msgstr "" #: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx #: src/components/network-monitors-table/monitor-dialog.tsx
@@ -148,13 +136,6 @@ 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 ""
@@ -279,7 +260,6 @@ 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 ""
@@ -403,19 +383,6 @@ 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 ""
@@ -809,15 +776,12 @@ 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
@@ -903,10 +867,6 @@ 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 ""
@@ -927,6 +887,10 @@ 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 ""
@@ -1126,7 +1090,6 @@ 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"
@@ -1219,7 +1182,6 @@ 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 ""
@@ -1242,7 +1204,6 @@ 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 ""
@@ -1273,7 +1234,6 @@ 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 ""
@@ -1605,7 +1565,6 @@ 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 ""
@@ -1694,7 +1653,6 @@ 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"
@@ -1740,6 +1698,7 @@ 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 ""
@@ -1753,10 +1712,6 @@ 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 ""
@@ -1878,7 +1833,6 @@ 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 ""
@@ -1921,11 +1875,12 @@ 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
@@ -1970,15 +1925,9 @@ 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 ""
@@ -2213,7 +2162,6 @@ 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
@@ -2269,7 +2217,6 @@ 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 ""
@@ -2284,7 +2231,6 @@ 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 ""
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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 "Ваші налаштування користувача були оновлені."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:16\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,4 +2359,3 @@ 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."
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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."
+10 -11
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-25 07:01\n" "PO-Revision-Date: 2026-09-24 16:15\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 "{daysLeft, plural, other {# 天}}" 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"
@@ -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 "{numFailed, plural, other {# 个失败服务}}" 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}}"
@@ -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 "{totalCount, plural, other {# 个服务}}" msgstr ""
#: 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 "证书已过期 {expires}" msgstr ""
#: 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 "证书将于 {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."
@@ -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,4 +2359,3 @@ 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 "您的用户设置已更新。"
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:16\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,4 +2359,3 @@ 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 "您的用戶設置已更新。"
+1 -2
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 17:29\n" "PO-Revision-Date: 2026-09-24 16:15\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,4 +2359,3 @@ 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,20 +459,6 @@ 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>>