diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index acd21b2..bbc8909 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -573,6 +573,10 @@ func (c *Collector) net(dt float64) []Net { var skipDiskPrefix = []string{"loop", "ram", "zram", "nbd", "sr", "fd", "dm-", "md", "drbd", "zd", "pmem"} func skipDisk(n string) bool { + // eMMC boot/RPMB/general-purpose areas: regions of the chip, not drives + if strings.HasPrefix(n, "mmcblk") && (strings.Contains(n, "boot") || strings.HasSuffix(n, "rpmb") || strings.Contains(n, "gp")) { + return true + } for _, p := range skipDiskPrefix { if strings.HasPrefix(n, p) { return true diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 25f0df3..eb3e108 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -38,7 +38,7 @@ type Part struct { type Device struct { Name string `json:"name"` - Kind string `json:"kind"` // nvme | ssd | hdd | usb | other + Kind string `json:"kind"` // nvme | ssd | hdd | usb | emmc | sd | other Model string `json:"model,omitempty"` Vendor string `json:"vendor,omitempty"` Serial string `json:"serial,omitempty"` @@ -88,19 +88,24 @@ func Devices(ctx context.Context) ([]Device, error) { } devs := []Device{} for _, n := range root.Blockdevices { - if n.Type != "disk" || n.Size == 0 || strings.HasPrefix(n.Name, "zd") { + if n.Type != "disk" || n.Size == 0 || strings.HasPrefix(n.Name, "zd") || mmcAreaRe.MatchString(n.Name) { continue } d := Device{Name: n.Name, Model: str(n.Model), Vendor: str(n.Vendor), Serial: str(n.Serial), Tran: str(n.Tran), Size: n.Size, Rotational: n.Rota, Removable: n.RM, Temp: blockTemp(n.Name), Parts: []Part{}} switch { + case strings.HasPrefix(n.Name, "mmcblk"): + d.Kind = mmcKind(n.Name) + if d.Model == "" { + d.Model = strings.TrimSpace(readStrFile("/sys/block/" + n.Name + "/device/name")) + } case d.Tran == "usb": d.Kind = "usb" case d.Tran == "nvme" || strings.HasPrefix(n.Name, "nvme"): d.Kind = "nvme" case n.Rota: d.Kind = "hdd" - case d.Tran == "sata" || d.Tran == "sas" || strings.HasPrefix(n.Name, "sd") || strings.HasPrefix(n.Name, "mmcblk"): + case d.Tran == "sata" || d.Tran == "sas" || strings.HasPrefix(n.Name, "sd"): d.Kind = "ssd" default: d.Kind = "other" @@ -199,6 +204,48 @@ func sysBlockDevices() []lsblkNode { return out } +// An eMMC chip's boot and RPMB areas are hardware regions of the same chip, not drives; +// the kernel lists them as disks of their own (mmcblk0boot0, mmcblk0boot1, mmcblk0rpmb). +var mmcAreaRe = regexp.MustCompile(`^mmcblk\d+(boot\d+|rpmb|gp\d+)$`) + +func mmcKind(name string) string { + if strings.TrimSpace(readStrFile("/sys/block/"+name+"/device/type")) == "MMC" { + return "emmc" + } + return "sd" +} + +// mmcHealth is the eMMC answer to SMART: the chip's own wear and end-of-life registers, +// which the kernel publishes in /sys. SD cards have neither; smartctl speaks to neither. +func mmcHealth(s Smart) Smart { + dir := "/sys/block/" + s.Device + "/device/" + s.Available, s.Supported = true, true + s.SelfTestNote = "not a feature of eMMC or SD" + s.Serial = strings.TrimSpace(readStrFile(dir + "serial")) + s.Firmware = strings.TrimSpace(readStrFile(dir + "fwrev")) + s.Model = strings.TrimSpace(readStrFile(dir + "name")) + hex := func(v string) int { n, _ := strconv.ParseInt(strings.TrimPrefix(v, "0x"), 16, 32); return int(n) } + life := strings.Fields(readStrFile(dir + "life_time")) + eol := strings.TrimSpace(readStrFile(dir + "pre_eol_info")) + if len(life) != 2 || eol == "" { + s.Error = "This card reports no health data (SD cards don't; eMMC 5.0 and later does)." + return s + } + m := &MMCHealth{LifeA: hex(life[0]), LifeB: hex(life[1]), PreEOL: hex(eol)} + s.MMC = m + ok := m.PreEOL <= 1 && m.LifeA < 10 && m.LifeB < 10 + s.Passed = &ok + if m.PreEOL == 2 { + s.Warnings = append(s.Warnings, "80% of reserved blocks consumed") + } else if m.PreEOL >= 3 { + s.Warnings = append(s.Warnings, "90% of reserved blocks consumed — replace soon") + } + if m.LifeA >= 10 || m.LifeB >= 10 { + s.Warnings = append(s.Warnings, "estimated lifetime reached") + } + return s +} + var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`) // foldNVMe merges nvme0n1, nvme0n2, … into one physical device "nvme0": one SSD, one @@ -318,26 +365,35 @@ type Attr struct { } type Smart struct { - Device string `json:"device"` - Available bool `json:"available"` // smartctl installed - Supported bool `json:"supported"` // device answered - Standby bool `json:"standby"` // spun down; not read - Passed *bool `json:"passed"` - Temp int `json:"temp,omitempty"` - PowerOnHours int `json:"power_on_hours,omitempty"` - PowerCycles int `json:"power_cycles,omitempty"` - Model string `json:"model,omitempty"` - Serial string `json:"serial,omitempty"` - Firmware string `json:"firmware,omitempty"` - Attrs []Attr `json:"attrs"` - NVMe *NVMeLog `json:"nvme,omitempty"` - SelfTest string `json:"self_test,omitempty"` - SelfTestPct int `json:"self_test_remaining,omitempty"` - SelfTestOK bool `json:"self_test_supported"` - SelfTestNote string `json:"self_test_note,omitempty"` - Warnings []string `json:"warnings"` - Error string `json:"error,omitempty"` - FetchedAt int64 `json:"fetched_at"` + Device string `json:"device"` + Available bool `json:"available"` // smartctl installed + Supported bool `json:"supported"` // device answered + Standby bool `json:"standby"` // spun down; not read + Passed *bool `json:"passed"` + Temp int `json:"temp,omitempty"` + PowerOnHours int `json:"power_on_hours,omitempty"` + PowerCycles int `json:"power_cycles,omitempty"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Firmware string `json:"firmware,omitempty"` + Attrs []Attr `json:"attrs"` + NVMe *NVMeLog `json:"nvme,omitempty"` + MMC *MMCHealth `json:"mmc,omitempty"` + SelfTest string `json:"self_test,omitempty"` + SelfTestPct int `json:"self_test_remaining,omitempty"` + SelfTestOK bool `json:"self_test_supported"` + SelfTestNote string `json:"self_test_note,omitempty"` + Warnings []string `json:"warnings"` + Error string `json:"error,omitempty"` + FetchedAt int64 `json:"fetched_at"` +} + +// MMCHealth is the eMMC EXT_CSD wear report. Life values are tenths: 1 = 0–10% of rated +// life used, 10 = 90–100%, 11 = exceeded, 0 = not reported. A is the SLC area, B the MLC. +type MMCHealth struct { + LifeA int `json:"life_a"` + LifeB int `json:"life_b"` + PreEOL int `json:"pre_eol"` // 1 normal · 2 warning · 3 urgent } type NVMeLog struct { @@ -437,6 +493,9 @@ func (m *Manager) Smart(ctx context.Context, dev string, force bool) Smart { s.Error = "unknown device" return s } + if strings.HasPrefix(dev, "mmcblk") { + return mmcHealth(s) + } if !m.HasSmartctl() { return s } diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx index 25b1604..1c88b56 100644 --- a/web/src/Disks.tsx +++ b/web/src/Disks.tsx @@ -6,7 +6,7 @@ import { Btn, Loading, Spinner, toast } from './ui' import { useRefreshMs } from './prefs' const pct = (v: number) => `${Math.round(v)}%` -const KIND: Record = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', other: 'disk' } +const KIND: Record = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' } const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`) const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` } @@ -26,7 +26,7 @@ function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) { useEffect(() => { load() }, [load]) if (err) return
{err}
- if (!s) return + if (!s) return if (!s.available) return
smartmontools isn't installed, so there's no SMART data. Install it from the button above.
if (s.standby) return
Disk is spun down; SMART wasn't read so it stays asleep. load(true)}>Wake and read
if (s.error) return
{s.error}
@@ -43,6 +43,12 @@ function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
Read{ago(s.fetched_at)}
{s.warnings.length > 0 &&
{s.warnings.join(' · ')}
} + {s.mmc && ( +
+
Wear{[['A', s.mmc.life_a], ['B', s.mmc.life_b]].filter(([, v]) => v).map(([k, v]) => `type ${k} ${v === 11 ? 'past rated life' : `${(v as number) * 10 - 10}–${(v as number) * 10}% used`}`).join(' · ') || 'not reported'}
+
Reserve{['not reported', 'normal', 'warning — 80% of spare blocks used', 'urgent — 90% of spare blocks used'][s.mmc.pre_eol] ?? s.mmc.pre_eol}
+
+ )} {s.nvme && (
Endurance{s.nvme.percentage_used}% used · spare {s.nvme.available_spare}% (threshold {s.nvme.spare_threshold}%)
diff --git a/web/src/api.ts b/web/src/api.ts index 8b43fee..ab1ba06 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -97,7 +97,7 @@ export interface UpdatesStatus { manager: string; can_upgrade: boolean; selectiv export interface StoragePart { name: string; type: string; fstype?: string; mount?: string; label?: string; size: number; used?: number; fs_size?: number } export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] } export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean } -export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number } +export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number } export interface ZFSSnapshot { name: string; used: number; created: number } export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] }