Compare commits

..
2 Commits
19 changed files with 909 additions and 655 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 -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()
}
+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">
@@ -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"
@@ -203,7 +202,6 @@ export function SystemMultiSelect({
className, className,
systemIds, systemIds,
placeholder, placeholder,
canSelectMore,
}: { }: {
id: string id: string
selectedSystemIds: Set<string> selectedSystemIds: Set<string>
@@ -213,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)
@@ -326,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)
} }
@@ -348,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>
@@ -377,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
@@ -400,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)
@@ -422,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>
@@ -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}
/> />
</> </>
+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: {
@@ -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()) {
+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 */