Disks: eMMC is one device, not three, and reports wear from its own registers
mmcblkNboot0/boot1/rpmb are hardware areas of the chip; hide them on Disks and Dashboard. Badge eMMC/SD instead of SSD. smartctl cannot talk to MMC, so read life_time and pre_eol_info from /sys instead of showing its error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
488d929733
commit
232163ff4c
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -331,6 +378,7 @@ type Smart struct {
|
||||
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"`
|
||||
@@ -340,6 +388,14 @@ type Smart struct {
|
||||
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 {
|
||||
CriticalWarning int `json:"critical_warning"`
|
||||
PercentageUsed int `json:"percentage_used"`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+8
-2
@@ -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<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', other: 'disk' }
|
||||
const KIND: Record<string, string> = { 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 <div className="error">{err}</div>
|
||||
if (!s) return <Loading label="Reading SMART…" />
|
||||
if (!s) return <Loading label="Reading health…" />
|
||||
if (!s.available) return <div className="notice">smartmontools isn't installed, so there's no SMART data. Install it from the button above.</div>
|
||||
if (s.standby) return <div className="notice">Disk is spun down; SMART wasn't read so it stays asleep. <Btn className="btn" onClick={() => load(true)}>Wake and read</Btn></div>
|
||||
if (s.error) return <div className="notice">{s.error}</div>
|
||||
@@ -43,6 +43,12 @@ function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
|
||||
<div><span className="k">Read</span><span className="muted">{ago(s.fetched_at)} <button className="link" onClick={() => load(true)}>refresh</button></span></div>
|
||||
</div>
|
||||
{s.warnings.length > 0 && <div className="notice attn">{s.warnings.join(' · ')}</div>}
|
||||
{s.mmc && (
|
||||
<div className="props">
|
||||
<div><span className="k">Wear</span><span>{[['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'}</span></div>
|
||||
<div><span className="k">Reserve</span><span>{['not reported', 'normal', 'warning — 80% of spare blocks used', 'urgent — 90% of spare blocks used'][s.mmc.pre_eol] ?? s.mmc.pre_eol}</span></div>
|
||||
</div>
|
||||
)}
|
||||
{s.nvme && (
|
||||
<div className="props">
|
||||
<div><span className="k">Endurance</span><span>{s.nvme.percentage_used}% used · spare {s.nvme.available_spare}% (threshold {s.nvme.spare_threshold}%)</span></div>
|
||||
|
||||
+1
-1
@@ -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 }[] }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user