feat(host_agent): a disk out of inodes reads as full (#5745)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m19s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m13s

VDNT-Util01's / ran out of inodes (956 free of 60.8M) with 274G of space
left; every file create failed and Steward's page read healthy at 66%.

- Agent 1.13.0 reports each mount's inodes (statvfs; a filesystem reporting
  none, like btrfs, is left without).
- Ingest records inodes_used_pct per mount, and disk_used_pct_worst is the
  fullest mount by space or inodes, so existing disk-full rules and the
  overview see it.
- The host's health strip, the Hosts widget and the host glance grade / by
  whichever is fuller and name inodes when they are ("Disk / 66% · inodes
  100%", "Inodes on / ... past its critical threshold").

Hosts pick up the inode figures once their agent is updated to 1.13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:51:46 -04:00
co-authored by Claude Opus 5.5
parent 1b82d8ad83
commit 5dd9956f35
11 changed files with 85 additions and 16 deletions
+13 -3
View File
@@ -127,6 +127,9 @@ def _expand_sample_to_metrics(
if sample.get("uptime_secs") is not None:
row("uptime_secs", host_name, sample["uptime_secs"])
# The fullest disk is the fullest by space or by inodes (#5745): out of
# inodes, every file create fails with space to spare, so the disk rules
# and the overview, which read disk_used_pct_worst, see either.
worst_pct = 0.0
for disk in sample.get("storage") or []:
total = disk.get("total_bytes", 0)
@@ -136,6 +139,10 @@ def _expand_sample_to_metrics(
row("disk_used_pct", mount_res, pct)
row("disk_used_bytes", mount_res, used)
row("disk_total_bytes", mount_res, total)
if disk.get("inodes_total"):
inode_pct = 100.0 * disk.get("inodes_used", 0) / disk["inodes_total"]
row("inodes_used_pct", mount_res, inode_pct)
pct = max(pct, inode_pct)
if pct > worst_pct:
worst_pct = pct
if sample.get("storage"):
@@ -750,6 +757,8 @@ async def host_vitals(host_id: str):
# At-a-glance disk = the root filesystem (what people actually care about),
# not the "worst" mount which is opaque. Worst stays on the full-metrics page.
disk_root = (latest.get(host.name + ":/", {}) or {}).get("disk_used_pct")
# Out of inodes, "/" is full with space to spare (#5745): named when worse.
inodes_root = (latest.get(host.name + ":/", {}) or {}).get("inodes_used_pct")
sparks = {k: sparkline_svg(v, height=24, fill_width=True) for k, v in series_raw.items()}
# Normalize load by core count so it's comparable across hardware (load/core
# as %). Cores derived from the per-core CPU sub-resources already collected.
@@ -767,12 +776,12 @@ async def host_vitals(host_id: str):
cpu, mem = hostlvl.get("cpu_pct"), hostlvl.get("mem_used_pct")
thresholds = current_app.config.get("THRESHOLDS", {})
worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"),
("Load per core", load_per_core, "load")),
("Inodes on /", inodes_root, "disk"), ("Load per core", load_per_core, "load")),
thresholds, host_svc.host_normals(norms, thresholds))
# Each tile's grade; CPU, memory and load within the host's own normal
# for the hour read healthy, and say what is usual (#5742).
grades = host_svc.host_grades({"cpu": cpu, "mem": mem, "disk": disk_root, "load": load_per_core},
norms, thresholds)
grades = host_svc.host_grades({"cpu": cpu, "mem": mem, "disk": host_svc.fuller(disk_root, inodes_root),
"load": load_per_core}, norms, thresholds)
usual = [f"{label} {value:.0f}% is {grades[kind][1]}"
for label, value, kind in (("CPU", cpu, "cpu"), ("Memory", mem, "mem"),
("load per core", load_per_core, "load"))
@@ -780,6 +789,7 @@ async def host_vitals(host_id: str):
return await render_template(
"_host_vitals.html",
reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core,
inodes_root=inodes_root,
sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst,
grades=grades, usual=usual,
clock=clock_words(reg.clock_offset_s if reg else None, current_app.config.get("THRESHOLDS", {})),