mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-30 19:56:21 +00:00
Compare commits
2
Commits
main
..
2341-lxc-cpu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a07933a91b | ||
|
|
ada8c69817 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user