feat(docker): uptime from StartedAt, a restart is an event, host totals count physical devices only (#5153)

- Agent: started_at comes from inspect's State.StartedAt (nanoseconds
  trimmed; Docker's zero time is "never started"), not the list's Created,
  so a restarted container's uptime counts from its restart.
- A rising restart_count between two snapshots emits a "restart" event
  ("restart #3", or "3 restarts, now #5"), said instead of a start; a
  restart then a death gives both. A count that falls (recreated under the
  same name) says nothing. The timeline draws it as ↻.
- Network and disk I/O count what the kernel calls physical: interfaces
  not under /sys/devices/virtual/net (bridges, docker0, veth, vxlan, tun),
  block devices with a device behind them (not dm, md, zram), so a byte is
  not counted twice. Where /sys calls everything virtual (an agent in LXC)
  the name-filtered set is kept rather than reporting no traffic.
- Agent tests updated for #5152's (status, detail, containers) and
  (is_manager, topology) shapes; the no-Docker disk test now states the
  case it was passing for by accident.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:45:57 -04:00
co-authored by Claude Opus 5.5
parent 8ceb983183
commit bcd0563f39
8 changed files with 170 additions and 32 deletions
@@ -4,6 +4,8 @@ _derive_events is a pure function (no DB), and ingest.py imports its models
lazily inside functions, so importing it here doesn't register ORM tables —
safe for the no-DB unit lane.
"""
import pytest
from plugins.docker.ingest import _derive_events
@@ -69,6 +71,27 @@ def test_no_event_when_unchanged():
# ── container-log row shaping (m79, pure) ─────────────────────────────────────
@pytest.mark.parametrize("old_status, old_n, new, events", [
# Docker restarted it between samples: a restart, not silence (#5153).
("running", 2, _c("web", "running", restart_count=3), [("web", "restart", "restart #3")]),
# Caught restarting, now back up: a restart, said instead of a start.
("restarting", 2, _c("web", "running", restart_count=3), [("web", "restart", "restart #3")]),
("running", 2, _c("web", "running", restart_count=5), [("web", "restart", "3 restarts, now #5")]),
# Restarted, then died for good: both happened.
("running", 3, _c("web", "exited", restart_count=4, exit_code=1),
[("web", "restart", "restart #4"), ("web", "die", "exit 1")]),
# Started by hand (the count did not move): a start.
("exited", 2, _c("web", "running", restart_count=2, image="nginx"), [("web", "start", "nginx")]),
# Recreated under the same name, so the count reset: no restart.
("running", 5, _c("web", "running", restart_count=0), []),
("running", 2, _c("web", "running", restart_count=2), []),
])
def test_restart_events(old_status, old_n, new, events):
old = {"web": {"status": old_status, "health": None, "oom_killed": False, "exit_code": None,
"restart_count": old_n}}
assert _derive_events(old, [new]) == events
def test_log_rows_shapes_and_filters():
from datetime import datetime, timezone
from plugins.docker.ingest import _log_rows