feat(docker): container log viewer + Settings controls [M79 step 5]
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 41s
CI / integration (push) Failing after 2m24s
CI / publish (push) Has been skipped

The user-facing half (rule 27). Per-container log viewer + admin controls, so
container logs are something the operator can actually touch.

Viewer (plugins/docker):
- /container/<host>/<name>/logs full page + /logs/lines HTMX fragment polled
  every 5s (near-live follow); stream filter (all/stdout/stderr) + case-insensitive
  text search; newest-first so the latest lines survive the poll's scroll reset;
  empty/loading states; a "View logs" link from the container detail page.
  Jinja auto-escapes log content (no markup injection).

Settings (Thresholds & Retention tab):
- global on/off toggle, comma-separated exclude list, retention days + max MB
  per container — DB-backed, no restart. The toggle + exclude are enforced
  authoritatively at ingest (_persist_logs drops disabled/excluded lines), since
  the push model has no channel to tell an agent to stop.

Tests: route-defined smoke; template-parse covers the new templates; integration
test for the ingest-time toggle + exclude enforcement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C
This commit is contained in:
2026-07-19 18:58:20 -04:00
parent 07a841d91e
commit 8f1c8c5cf7
9 changed files with 252 additions and 3 deletions
+44
View File
@@ -210,6 +210,50 @@ def test_persist_logs_stores_lines(app):
]
@_NEEDS_DB
def test_persist_logs_respects_toggle_and_exclude(app):
"""Server-side controls (Settings): an excluded container's lines are dropped
while others persist; the global toggle off stores nothing new."""
from sqlalchemy import text
from steward.models.hosts import Host
from steward.core.settings import set_setting
persist = _persist_fn(app)
now = datetime.now(timezone.utc)
batch = [(now, [
{"container": "web", "stream": "stdout", "ts": None, "line": "keep-me"},
{"container": "noisy", "stream": "stdout", "ts": None, "line": "drop-me"},
])]
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(text("DELETE FROM docker_logs"))
await set_setting(s, "docker.logs.enabled", True)
await set_setting(s, "docker.logs.exclude", ["noisy"])
h = Host(id=str(uuid.uuid4()), name="loghost3", address="10.7.7.10")
s.add(h)
await s.flush()
hid = h.id
await persist(s, h, [], None, None, batch)
kept = {r[0] for r in (await s.execute(text(
"SELECT DISTINCT container_name FROM docker_logs WHERE host_id=:h"),
{"h": hid})).all()}
async with s.begin(): # flip the global kill-switch off
await set_setting(s, "docker.logs.enabled", False)
await persist(s, h, [], None, None, batch)
after_off = (await s.execute(text(
"SELECT COUNT(*) FROM docker_logs WHERE host_id=:h"), {"h": hid})).scalar()
async with s.begin(): # restore defaults for other tests
await set_setting(s, "docker.logs.enabled", True)
await set_setting(s, "docker.logs.exclude", [])
return kept, after_off
kept, after_off = asyncio.run(_go())
assert kept == {"web"} # excluded 'noisy' dropped on ingest
assert after_off == 1 # toggle off → no new rows (still just the one 'web')
@_NEEDS_DB
def test_large_memory_values_persist_as_bigint(app):
"""A container using >2^31 bytes of RAM must persist. Regression: mem_usage_bytes
+6
View File
@@ -35,6 +35,12 @@ def test_disk_prune_view_defined():
assert callable(r.disk_prune)
def test_container_log_views_defined():
# M79 per-container log viewer + its HTMX-polled line fragment.
assert callable(r.container_logs)
assert callable(r.container_logs_lines)
def test_prune_extra_vars_mapping():
"""The prune buttons drive one playbook via prune_target; only 'images'
widens to ALL unused images (docker image prune -a), system stays -f."""