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
+2 -1
View File
@@ -329,8 +329,9 @@ METRICS: dict[str, tuple[str, str]] = {
"mem_used_pct": ("Memory used", "memory in use"),
"mem_available_bytes": ("Memory available", "memory free for new work"),
"swap_used_bytes": ("Swap used", "memory paged out to disk"),
"disk_used_pct_worst": ("Fullest disk", "the most-full mount on the host"),
"disk_used_pct_worst": ("Fullest disk", "the most-full mount on the host, by space or inodes"),
"disk_used_pct": ("Disk used", "one mount (resource host:mount)"),
"inodes_used_pct": ("Inodes used", "one mount's file slots in use (resource host:mount); at 100% no file can be created"),
"load_1m": ("Load (1 min)", "runnable processes, averaged over a minute"),
"load_5m": ("Load (5 min)", "runnable processes, averaged over 5 minutes"),
"load_15m": ("Load (15 min)", "runnable processes, averaged over 15 minutes"),
+12 -3
View File
@@ -269,7 +269,7 @@ async def agent_overview_by_host(
stale_after = int(stale_after_seconds)
latest = await latest_by_resource(
db, "host_agent", list(host_names) + [h + ":/" for h in host_names],
set(AGENT_GLANCE_METRICS) | {"disk_used_pct", "clock_off_secs"})
set(AGENT_GLANCE_METRICS) | {"disk_used_pct", "inodes_used_pct", "clock_off_secs"})
from steward.models.metrics import PluginMetricLatest
cores: dict[str, int] = {}
for res in (await db.execute(
@@ -289,6 +289,8 @@ async def agent_overview_by_host(
d = {m: r.value for m, r in own.items()}
if root is not None:
d["disk_root"] = root.value
inodes = latest.get(name + ":/", {}).get("inodes_used_pct")
d["inodes_root"] = inodes.value if inodes is not None else None
ts = max((r.recorded_at for r in own.values()), default=None)
d["last_at"] = ts
d["fresh"] = bool(ts and (now - ts).total_seconds() <= stale_after)
@@ -314,6 +316,11 @@ HOST_NORMED = {"cpu": ("cpu_pct", "host_cpu_pct"), "mem": ("mem_used_pct", "host
_KIND_OF = {metric: kind for kind, (metric, _f) in HOST_NORMED.items()}
def fuller(space: float | None, inodes: float | None) -> float | None:
"""How full a mount is: by space or by inodes, whichever is fuller (#5745)."""
return max((v for v in (space, inodes) if v is not None), default=None)
def host_normals(norms: dict, thresholds: dict) -> dict:
"""{kind: (Norm, floor)} for worst_reading and anomalies.graded, from an
agent glance's `norms`."""
@@ -381,11 +388,13 @@ def host_glance(hosts, status: dict, agent: dict, thresholds: dict) -> list[dict
worst, grades = None, {}
if a and a.get("fresh"):
grades = host_grades({"cpu": a.get("cpu_pct"), "mem": a.get("mem_used_pct"),
"disk": a.get("disk_root"), "load": a.get("load_per_core")},
"disk": fuller(a.get("disk_root"), a.get("inodes_root")),
"load": a.get("load_per_core")},
a.get("norms"), thresholds)
worst = worst_reading((
("CPU", a.get("cpu_pct"), "cpu"), ("Memory", a.get("mem_used_pct"), "mem"),
("Disk /", a.get("disk_root"), "disk"), ("Load", a.get("load_per_core"), "load")),
("Disk /", a.get("disk_root"), "disk"), ("Inodes on /", a.get("inodes_root"), "disk"),
("Load", a.get("load_per_core"), "load")),
thresholds, host_normals(a.get("norms"), thresholds))
if st and st["state"] == "down":
state = "down"
+1 -1
View File
@@ -127,7 +127,7 @@ def alert_findings(active, silent: dict[str, list[str]] | None = None) -> list[d
_AGENT_CHECKS = (("cpu_pct", "cpu", "CPU"), ("mem_used_pct", "mem", "memory"),
("disk_used_pct_worst", "disk", "fullest disk"))
("disk_used_pct_worst", "disk", "fullest disk (space or inodes)"))
def agent_findings(hosts, agent: dict, thresholds: dict,
+2 -1
View File
@@ -33,7 +33,8 @@
</td>
{% for v, kind in ((a.get('cpu_pct'), 'cpu'), (a.get('mem_used_pct'), 'mem'), (a.get('disk_root'), 'disk')) %}
{% set g = (r.grades or {}).get(kind) or (none, none) %}
<td style="{{ grade_style(g[0]) }}"{% if g[1] %} title="{{ g[1]|capitalize }}"{% endif %}>{% if v is none %}—{% else %}{{ "%.0f"|format(v) }}%{% endif %}</td>
{% set inodes = a.get('inodes_root') if kind == 'disk' else none %}
<td style="{{ grade_style(g[0]) }}"{% if g[1] %} title="{{ g[1]|capitalize }}"{% elif inodes is not none and (v is none or inodes > v) %} title="Inodes {{ '%.0f'|format(inodes) }}% used: no file can be created once they run out"{% endif %}>{% if v is none %}—{% else %}{{ "%.0f"|format(v) }}%{% endif %}{% if inodes is not none and (v is none or inodes > v) and inodes >= 80 %} <span class="w-sub">inodes {{ "%.0f"|format(inodes) }}%</span>{% endif %}</td>
{% endfor %}
{% set g = (r.grades or {}).get('load') or (none, none) %}
<td style="{{ grade_style(g[0]) }}"