diff --git a/SENSORS.md b/SENSORS.md index 1d16ceb..b3812c3 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -199,6 +199,16 @@ GET /api/fleet/sensors/query?machines=grid,wyse&cpu=cpu.usage&failed=services.f → {"core": {"cpu": 4.8, "up": 50211}, "grid": {"cpu": 0.4, "up": 437177}, "mac": {"error": "timeout"}} ``` +## What counts as a disk + +`disk.*` names **drives**, not the block devices the kernel lists. An NVMe controller can +present several namespaces — an Apple SSD shows `nvme0n1`, `nvme0n2` and `nvme0n3`, the last +two a few MiB of firmware — and each one arrives from `/proc/diskstats` looking like a whole +disk. They are folded into the controller (`disk.nvme0.*`), with the rates added and busy +taken as the longest of them rather than the sum, since they share one controller. This is +what the Disks page has always shown; since 2026-09-22 the dashboard, the Top tab and these +sensors agree with it. + ## The Top tab Ham Lab → **Top** is a whole machine on one screen in the style of btop: black ground, boxes diff --git a/internal/metrics/fold_test.go b/internal/metrics/fold_test.go index 415445f..7964d18 100644 --- a/internal/metrics/fold_test.go +++ b/internal/metrics/fold_test.go @@ -97,3 +97,47 @@ func TestKeepSpacing(t *testing.T) { t.Errorf("at the default window every sample is kept, got %d of 50", len(c.ring)) } } + +func TestFoldNVMe(t *testing.T) { + // an Apple SSD as the kernel lists it: one controller, three namespaces + got := foldNVMe([]Disk{ + {Name: "nvme0n1", Model: "APPLE SSD AP0512Z", Size: 500 << 30, ReadBps: 100, WriteBps: 200, UtilPct: 12, Temp: 37}, + {Name: "nvme0n2", Model: "APPLE SSD AP0512Z", Size: 3 << 20, ReadBps: 10, WriteBps: 20, UtilPct: 40}, + {Name: "nvme0n3", Model: "APPLE SSD AP0512Z", Size: 128 << 20, ReadBps: 1, WriteBps: 2, UtilPct: 5}, + {Name: "sda", Size: 100 << 30, ReadBps: 7, UtilPct: 3}, + {Name: "nvme1n1", Size: 200 << 30, ReadBps: 5}, + }) + if len(got) != 3 { + t.Fatalf("three namespaces of one controller are one drive: %v", names(got)) + } + d := got[0] + if d.Name != "nvme0" { + t.Errorf("the drive is the controller, got %q", d.Name) + } + if d.Size != 500<<30+3<<20+128<<20 { + t.Errorf("its size is all of its namespaces, got %d", d.Size) + } + if d.ReadBps != 111 || d.WriteBps != 222 { + t.Errorf("rates add up, got %v/%v", d.ReadBps, d.WriteBps) + } + if d.UtilPct != 40 { + t.Errorf("busy is the longest of them, not the sum: got %v", d.UtilPct) + } + if d.Temp != 37 { + t.Errorf("a temperature from any namespace is the drive's, got %v", d.Temp) + } + if got[1].Name != "sda" || got[1].UtilPct != 3 || got[2].Name != "nvme1" { + t.Errorf("everything else is left alone: %v", names(got)) + } + if n := len(foldNVMe(nil)); n != 0 { + t.Errorf("no disks, no drives, got %d", n) + } +} + +func names(d []Disk) []string { + out := make([]string, len(d)) + for i, x := range d { + out[i] = x.Name + } + return out +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index f0cf616..18cdf0b 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -715,10 +715,53 @@ func (c *Collector) disks(dt float64) []Disk { c.prevDisk[name] = [3]uint64{rd, wr, ticks} out = append(out, d) } + out = foldNVMe(out) sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } +var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`) + +// foldNVMe merges nvme0n1, nvme0n2, … into the drive they are: one controller presenting +// several namespaces, which the kernel lists as whole disks apiece. An Apple SSD shows three — +// the 466 GiB of storage and two of firmware, a few MiB each — and a page that lists disks was +// listing all three as separate drives. The Disks page has always folded them (internal/storage +// does the same); this is what the dashboard, the Top tab and the disk.* sensors read. +// +// Rates add up across namespaces. Busy does not: they share one controller, so the time it +// spent working is the longest of them, never the sum. +func foldNVMe(devs []Disk) []Disk { + byCtrl := map[string]int{} + out := devs[:0] + for _, d := range devs { + m := nsRe.FindStringSubmatch(d.Name) + if m == nil { + out = append(out, d) + continue + } + ctrl := m[1] + if i, ok := byCtrl[ctrl]; ok { + out[i].Size += d.Size + out[i].ReadBps = round1(out[i].ReadBps + d.ReadBps) + out[i].WriteBps = round1(out[i].WriteBps + d.WriteBps) + if d.UtilPct > out[i].UtilPct { + out[i].UtilPct = d.UtilPct + } + if out[i].Temp == 0 { + out[i].Temp = d.Temp + } + continue + } + d.Name = ctrl + if mdl := strings.TrimSpace(readStr("/sys/class/nvme/" + ctrl + "/model")); mdl != "" { + d.Model = mdl + } + byCtrl[ctrl] = len(out) + out = append(out, d) + } + return out +} + // ---- filesystems ---- // Kernel/virtual filesystems that aren't storage. Anything else that's mounted is diff --git a/web/src/Top.tsx b/web/src/Top.tsx index 4e8ac73..5117967 100644 --- a/web/src/Top.tsx +++ b/web/src/Top.tsx @@ -377,7 +377,9 @@ export default function Top() {