diff --git a/plugins/docker/ingest.py b/plugins/docker/ingest.py index 7194edc..5262280 100644 --- a/plugins/docker/ingest.py +++ b/plugins/docker/ingest.py @@ -124,11 +124,20 @@ async def _persist_logs(session, host, log_batches) -> None: """Append pushed container log lines (one row per line) for this host. Time-series / append-only — the per-container size+age ring (retention) - bounds growth, so a chatty container just keeps a shorter window. + bounds growth, so a chatty container just keeps a shorter window. The global + toggle + exclude list (Settings, no restart) are enforced here: this is the + authoritative drop point, since the push model has no channel to tell an + agent to stop collecting. """ + from steward.core.settings import get_setting from .models import DockerLog + if not await get_setting(session, "docker.logs.enabled"): + return + exclude = set(await get_setting(session, "docker.logs.exclude") or ()) for row in _log_rows(log_batches, host.id): + if row["container_name"] in exclude: + continue session.add(DockerLog(**row)) diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 9aae5dc..c2c522f 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -16,8 +16,8 @@ from steward.core.time_range import parse_range, DEFAULT_RANGE from .dedup import dedup_by_container_id from .swarm_view import build_swarm_services from .models import ( - DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerMetric, - DockerSwarmNode, DockerSwarmService, + DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerLog, + DockerMetric, DockerSwarmNode, DockerSwarmService, ) docker_bp = Blueprint("docker", __name__, template_folder="templates") @@ -647,3 +647,56 @@ async def container_history(host_id: str, name: str): have_data=len(cpu_hist) >= 2, range_key=range_key, ) + + +# Newest lines returned per fetch — the retained window is bounded by the ring, +# but a container can still hold thousands of lines; cap what one fetch renders. +_LOG_TAIL_DEFAULT = 500 + + +async def _query_logs(db, host_id: str, name: str, stream: str, query: str, + limit: int) -> list: + """The recent retained lines for one container, newest-first, optionally + filtered by stream and a case-insensitive substring. + + Newest-first is deliberate: the viewer replaces the list on each poll, which + resets scroll to the top — so the latest lines stay visible without any + scroll handling, and older lines are a scroll away. + """ + stmt = (select(DockerLog.ts, DockerLog.stream, DockerLog.line) + .where(DockerLog.host_id == host_id) + .where(DockerLog.container_name == name)) + if stream in ("stdout", "stderr"): + stmt = stmt.where(DockerLog.stream == stream) + if query: + stmt = stmt.where(DockerLog.line.ilike(f"%{query}%")) + stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit) + return (await db.execute(stmt)).all() + + +@docker_bp.get("/container///logs") +@require_role(UserRole.viewer) +async def container_logs(host_id: str, name: str): + """Full log-viewer page for one container (lines load via an HTMX fragment).""" + async with current_app.db_sessionmaker() as db: + host = await db.get(Host, host_id) + return await render_template( + "docker/container_logs.html", host=host, host_id=host_id, name=name, + ) + + +@docker_bp.get("/container///logs/lines") +@require_role(UserRole.viewer) +async def container_logs_lines(host_id: str, name: str): + """HTMX fragment: the recent retained log lines, stream + text filtered. + Polled every few seconds by the viewer for near-live follow.""" + stream = request.args.get("stream", "all") + query = (request.args.get("q") or "").strip() + async with current_app.db_sessionmaker() as db: + rows = await _query_logs(db, host_id, name, stream, query, _LOG_TAIL_DEFAULT) + return await render_template( + "docker/_container_logs_lines.html", + lines=[{"ts": r.ts, "stream": r.stream, "line": r.line} for r in rows], + name=name, tail=_LOG_TAIL_DEFAULT, + filtered=bool(query) or stream in ("stdout", "stderr"), + ) diff --git a/plugins/docker/templates/docker/_container_logs_lines.html b/plugins/docker/templates/docker/_container_logs_lines.html new file mode 100644 index 0000000..4e67b45 --- /dev/null +++ b/plugins/docker/templates/docker/_container_logs_lines.html @@ -0,0 +1,22 @@ +{# docker/_container_logs_lines.html — log-line fragment, HTMX-polled (m79). + {{ l.line }} is auto-escaped by Jinja, so log content can't inject markup. #} +{% if lines %} +{% for l in lines %} +
+ {{ l.ts.strftime("%m-%d %H:%M:%S") }} + {{ l.stream }} + {{ l.line }} +
+{% endfor %} +{% else %} +
+ {% if filtered %} + No lines match the current filter. + {% else %} + No logs collected yet for {{ name }}. Lines appear within a few + seconds of the container writing to stdout/stderr — unless it's on the log + exclude list or log collection is turned off in + Settings → Thresholds & Retention. + {% endif %} +
+{% endif %} diff --git a/plugins/docker/templates/docker/container_detail.html b/plugins/docker/templates/docker/container_detail.html index bf4d76e..282029c 100644 --- a/plugins/docker/templates/docker/container_detail.html +++ b/plugins/docker/templates/docker/container_detail.html @@ -26,6 +26,7 @@
{{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %} {% if host %} · on {{ host.name }}{% endif %} + · View logs
{# ── Facts grid ──────────────────────────────────────────────────────────── #} diff --git a/plugins/docker/templates/docker/container_logs.html b/plugins/docker/templates/docker/container_logs.html new file mode 100644 index 0000000..a1bfee6 --- /dev/null +++ b/plugins/docker/templates/docker/container_logs.html @@ -0,0 +1,51 @@ +{# docker/container_logs.html — per-container log viewer (m79) #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs %} +{% block title %}Logs — {{ name }} — Docker — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([ + ("Docker", "/plugins/docker/"), + (name, "/plugins/docker/container/" ~ host_id ~ "/" ~ name), + ("Logs", "")]) }}{% endblock %} +{% block content %} + +
+

{{ name }}

+ logs +
+
+ Recent lines (newest first) collected by the host agent{% if host %} on + {{ host.name }}{% endif %} — + updated every few seconds. + ← back to container +
+ +{# ── Controls: stream filter + text search (drive the fragment via HTMX) ───── #} +
+ + +
+ +
+
+
Loading…
+
+
+ +{% endblock %} diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 5b37672..f869aaf 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -133,6 +133,7 @@ _RETENTION_FIELDS = [ ("docker_metrics_raw_days", "docker.retention.metrics_raw_days"), ("docker_metrics_rollup_days", "docker.retention.metrics_rollup_days"), ("docker_events_days", "docker.retention.events_days"), + ("docker_logs_retention_days", "docker.logs.retention_days"), ("metrics_raw_days", "metrics.retention.raw_days"), ("metrics_rollup_days", "metrics.retention.rollup_days"), ] @@ -170,6 +171,20 @@ async def save_thresholds(): except (TypeError, ValueError): continue await set_setting(db, key, val) + # Container-log controls (m79). Checkbox: present ⇒ on (this is a + # full-page form, so absence is a genuine "off"). Exclude: comma-split + # names. Size: entered in MB, stored as bytes. + await set_setting(db, "docker.logs.enabled", "docker_logs_enabled" in form) + exclude = [n.strip() for n in form.get("docker_logs_exclude", "").split(",") + if n.strip()] + await set_setting(db, "docker.logs.exclude", exclude) + max_mb = form.get("docker_logs_max_mb", "") + if max_mb != "": + try: + await set_setting(db, "docker.logs.max_bytes_per_container", + max(1, int(max_mb)) * 1_000_000) + except (TypeError, ValueError): + pass await _reload_app_config() await log_audit(current_app, session.get("user_id"), session.get("username", ""), "settings.saved", detail={"section": "thresholds"}) diff --git a/steward/templates/settings/thresholds.html b/steward/templates/settings/thresholds.html index db83c0c..000b6f2 100644 --- a/steward/templates/settings/thresholds.html +++ b/steward/templates/settings/thresholds.html @@ -70,6 +70,54 @@ "Keep container start/stop/die/health history this long.") }} +
+

Container logs

+

+ The host agent tails each container's logs and pushes them here, viewable per + container. On by default for every container; storage is bounded by a + per-container ring (oldest lines rotate out once the age or size cap is hit, + whichever comes first). Applied by the hourly cleanup and on ingest. +

+ +
+ +
+ Global kill-switch. When off, pushed log lines are dropped and no new logs are stored. +
+
+ +
+ +
+ +
+
+ Comma-separated container names whose logs are dropped on ingest (e.g. known-noisy ones). +
+
+ + {{ days("Log retention", "docker_logs_retention_days", "docker.logs.retention_days", + "Keep each container's log lines at most this long before rotating them out.") }} + +
+ +
+ +
+
+ Newest lines are kept up to this size per container; older lines rotate out first. +
+
+
+

Host metrics retention

diff --git a/tests/integration/test_docker.py b/tests/integration/test_docker.py index 9703cf4..3dfae2a 100644 --- a/tests/integration/test_docker.py +++ b/tests/integration/test_docker.py @@ -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 diff --git a/tests/plugins/docker/test_routes.py b/tests/plugins/docker/test_routes.py index baa2705..1e01bf7 100644 --- a/tests/plugins/docker/test_routes.py +++ b/tests/plugins/docker/test_routes.py @@ -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."""