feat(docker): per-host collection via the host agent; drop central scrape
Docker collection moves off the central single-socket scrape onto the host
agent, giving Docker a real per-host dimension. The Steward host now reports
its own containers like any other host, and same-named containers on different
hosts no longer collide.
- agent: stdlib UDS Docker client (AF_UNIX HTTP/1.1, Connection: close,
chunked-aware), collect_docker() ports the cpu%/mem math; sample["docker"]
added best-effort (silent-skip on absent/unreadable socket). AGENT_VERSION
1.2.0 → 1.3.0; optional docker_socket config key.
- ingest: host_agent ingest hands per-host container snapshots to the docker
plugin via a new "docker.persist_host_samples" capability (no hard import,
no-op when docker disabled), inside a SAVEPOINT so a docker failure never
sinks the host metrics. Resource names are host-scoped ("<host>/<name>").
- schema: docker_containers re-keyed (host_id, name); docker_metrics gains
host_id; docker_002 migration DROP+recreates (dev-only, rule 122).
- ui: Docker page + widgets grouped by host with host links; new per-host
Docker panel embedded on the Hosts hub (gated on docker enabled via a new
enabled_plugins template context). Replaces the SQLite-only strftime
bucketing with DB-agnostic Python bucketing.
- provisioning: install/provision playbooks add steward-agent to the docker
group (best-effort) so the agent can read the socket.
- removed central scrape: docker scheduler.py + scraper.py deleted; plugin.yaml
socket_path/scrape_interval_seconds/include_stopped dropped (plugin 2.0.0).
- tests: agent docker collector units (math, chunked decode, silent-skip,
sample shape, config) + integration (host-scoped schema + persistence).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -13,10 +13,26 @@ def setup(app: "Quart") -> None:
|
|||||||
_app = app
|
_app = app
|
||||||
from .models import DockerContainer, DockerMetric # noqa: F401 — register with Base
|
from .models import DockerContainer, DockerMetric # noqa: F401 — register with Base
|
||||||
|
|
||||||
|
# Publish the per-host persist hook so the host agent can hand off the
|
||||||
|
# `docker` array from each sample without importing our models (opportunistic
|
||||||
|
# synergy — host_agent gates on has_capability and no-ops if we're disabled).
|
||||||
|
# required_role=viewer: this is a trusted server-side data-plane write driven
|
||||||
|
# by the authenticated agent ingest, not a user-facing privileged action.
|
||||||
|
from steward.core.capabilities import register_capability
|
||||||
|
from steward.models.users import UserRole
|
||||||
|
from .ingest import persist_host_docker
|
||||||
|
register_capability(
|
||||||
|
"docker.persist_host_samples", persist_host_docker,
|
||||||
|
label="Persist host Docker samples",
|
||||||
|
description="Store per-host container state + metrics pushed by the host agent.",
|
||||||
|
required_role=UserRole.viewer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_scheduled_tasks() -> list:
|
def get_scheduled_tasks() -> list:
|
||||||
from .scheduler import make_task
|
# Collection is now agent-driven (pushed to the host_agent ingest); the
|
||||||
return [make_task(_app)]
|
# central socket scrape was removed. No periodic task to register.
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_blueprint():
|
def get_blueprint():
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# plugins/docker/ingest.py
|
||||||
|
"""Persist host-scoped Docker samples pushed by the host agent.
|
||||||
|
|
||||||
|
Published as the "docker.persist_host_samples" capability (see __init__.setup),
|
||||||
|
so the host_agent plugin can hand off the `docker` array from a sample WITHOUT
|
||||||
|
importing the docker models — the coupling is opportunistic and degrades to a
|
||||||
|
no-op when the docker plugin is disabled. Runs inside the caller's open
|
||||||
|
transaction (the ingest handler's session); never opens or commits its own.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_started_at(value) -> datetime | None:
|
||||||
|
"""Parse the agent's ISO-8601 started_at string back to a datetime."""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_host_docker(session, host, snapshots) -> None:
|
||||||
|
"""Upsert containers + append time-series for one host's docker snapshots.
|
||||||
|
|
||||||
|
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
|
||||||
|
one entry per ingested sample carrying docker data (usually just one; more
|
||||||
|
when the agent flushes a backlog). Every snapshot contributes time-series
|
||||||
|
DockerMetric rows; the newest snapshot drives current container state and
|
||||||
|
the alert pipeline (record_metric writes "now", so feeding it stale buffered
|
||||||
|
snapshots would be misleading).
|
||||||
|
"""
|
||||||
|
from steward.core.alerts import record_metric
|
||||||
|
from .models import DockerContainer, DockerMetric
|
||||||
|
|
||||||
|
if not snapshots:
|
||||||
|
return
|
||||||
|
ordered = sorted(snapshots, key=lambda s: s[0])
|
||||||
|
latest_at, latest_containers = ordered[-1]
|
||||||
|
|
||||||
|
# Time-series points for every snapshot (running containers with a CPU read).
|
||||||
|
for recorded_at, containers in ordered:
|
||||||
|
for c in containers:
|
||||||
|
if c.get("status") == "running" and c.get("cpu_pct") is not None:
|
||||||
|
session.add(DockerMetric(
|
||||||
|
host_id=host.id,
|
||||||
|
container_name=c["name"],
|
||||||
|
scraped_at=recorded_at,
|
||||||
|
cpu_pct=c["cpu_pct"],
|
||||||
|
mem_pct=c.get("mem_pct") or 0.0,
|
||||||
|
mem_usage_bytes=c.get("mem_usage_bytes") or 0,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Current state + alerts from the newest snapshot only.
|
||||||
|
for c in latest_containers:
|
||||||
|
name = c.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
existing = await session.get(DockerContainer, (host.id, name))
|
||||||
|
if existing is None:
|
||||||
|
existing = DockerContainer(host_id=host.id, name=name)
|
||||||
|
session.add(existing)
|
||||||
|
existing.container_id = c.get("container_id", "") or ""
|
||||||
|
existing.image = c.get("image", "") or ""
|
||||||
|
existing.status = c.get("status", "unknown") or "unknown"
|
||||||
|
existing.cpu_pct = c.get("cpu_pct")
|
||||||
|
existing.mem_usage_bytes = c.get("mem_usage_bytes")
|
||||||
|
existing.mem_limit_bytes = c.get("mem_limit_bytes")
|
||||||
|
existing.mem_pct = c.get("mem_pct")
|
||||||
|
existing.ports_json = json.dumps(c.get("ports") or [])
|
||||||
|
existing.started_at = _parse_started_at(c.get("started_at"))
|
||||||
|
existing.scraped_at = latest_at
|
||||||
|
|
||||||
|
# Alert pipeline — resource is host-scoped so containers of the same name
|
||||||
|
# on different hosts don't collide in the metric/alert namespace.
|
||||||
|
if c.get("status") == "running" and c.get("cpu_pct") is not None:
|
||||||
|
resource = f"{host.name}/{name}"
|
||||||
|
await record_metric(
|
||||||
|
session=session, source_module="docker",
|
||||||
|
resource_name=resource, metric_name="cpu_pct", value=c["cpu_pct"],
|
||||||
|
)
|
||||||
|
if c.get("mem_pct") is not None:
|
||||||
|
await record_metric(
|
||||||
|
session=session, source_module="docker",
|
||||||
|
resource_name=resource, metric_name="mem_pct", value=c["mem_pct"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Docker collection goes per-host: add host_id, re-key by (host_id, name)
|
||||||
|
|
||||||
|
Collection moved from the central single-socket scrape to the host agent, so
|
||||||
|
containers are now scoped to the host that reported them. docker_containers is
|
||||||
|
re-keyed (host_id, name) and docker_metrics gains host_id.
|
||||||
|
|
||||||
|
Dev-only posture (rule 122): the old tables only ever held the Steward box's
|
||||||
|
own containers (a single global namespace), which are disposable — so this
|
||||||
|
DROP+recreates rather than backfilling a host_id onto orphan rows.
|
||||||
|
|
||||||
|
Revision ID: docker_002_host_scoped
|
||||||
|
Revises: docker_001_initial
|
||||||
|
Create Date: 2026-06-18
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_002_host_scoped"
|
||||||
|
down_revision: Union[str, None] = "docker_001_initial"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
# FK targets hosts.id (created in 0002_core_monitors) — make the edge explicit.
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ("0002_core_monitors",)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("docker_metrics")
|
||||||
|
op.drop_table("docker_containers")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_containers",
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), primary_key=True),
|
||||||
|
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
|
||||||
|
sa.Column("image", sa.String(512), nullable=False, server_default=""),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("host_id", sa.String(36),
|
||||||
|
sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_metrics_host_id", "docker_metrics", ["host_id"])
|
||||||
|
op.create_index("ix_docker_metrics_container_name", "docker_metrics",
|
||||||
|
["container_name"])
|
||||||
|
op.create_index("ix_docker_metrics_scraped_at", "docker_metrics", ["scraped_at"])
|
||||||
|
op.create_index("ix_docker_metrics_host_container_time", "docker_metrics",
|
||||||
|
["host_id", "container_name", "scraped_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("docker_metrics")
|
||||||
|
op.drop_table("docker_containers")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"docker_containers",
|
||||||
|
sa.Column("name", sa.String(255), primary_key=True),
|
||||||
|
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
|
||||||
|
sa.Column("image", sa.String(512), nullable=False, server_default=""),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=True),
|
||||||
|
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"docker_metrics",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("container_name", sa.String(255), nullable=False, index=True),
|
||||||
|
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
@@ -2,16 +2,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import DateTime, Float, Integer, String, Text
|
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from steward.models.base import Base
|
from steward.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
class DockerContainer(Base):
|
class DockerContainer(Base):
|
||||||
"""Latest known state per container — upserted by name on each scrape."""
|
"""Latest known state per container, scoped to the host that reported it.
|
||||||
|
|
||||||
|
Collection is per-host via the host agent, so container names are only
|
||||||
|
unique within a host — the natural key is (host_id, name). host_id is NOT
|
||||||
|
NULL: every container arrives through a host_agent ingest that resolves a
|
||||||
|
Host first. Deleting a host cascades its containers away.
|
||||||
|
"""
|
||||||
__tablename__ = "docker_containers"
|
__tablename__ = "docker_containers"
|
||||||
|
|
||||||
# Container name is the stable natural key; container_id changes on recreation
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
name: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||||
container_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
container_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||||
@@ -32,12 +40,16 @@ class DockerContainer(Base):
|
|||||||
|
|
||||||
|
|
||||||
class DockerMetric(Base):
|
class DockerMetric(Base):
|
||||||
"""Time-series CPU/memory per container — one row per scrape per running container."""
|
"""Time-series CPU/memory per container — one row per sample per running
|
||||||
|
container, scoped to the reporting host."""
|
||||||
__tablename__ = "docker_metrics"
|
__tablename__ = "docker_metrics"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(
|
id: Mapped[str] = mapped_column(
|
||||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||||
)
|
)
|
||||||
|
host_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
container_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
container_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
scraped_at: Mapped[datetime] = mapped_column(
|
scraped_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False,
|
DateTime(timezone=True), nullable=False,
|
||||||
@@ -47,3 +59,10 @@ class DockerMetric(Base):
|
|||||||
cpu_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
cpu_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
mem_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
mem_pct: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
mem_usage_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
mem_usage_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Per-container history lookups filter on (host_id, container_name) then sort
|
||||||
|
# by time — a composite index keeps the rows() sparkline queries cheap.
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_docker_metrics_host_container_time",
|
||||||
|
"host_id", "container_name", "scraped_at"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: docker
|
name: docker
|
||||||
version: "1.0.0"
|
version: "2.0.0"
|
||||||
description: "Docker container status, resource usage, restart tracking via Docker socket"
|
description: "Per-host Docker container status + resource usage, collected by the host agent"
|
||||||
author: "Steward"
|
author: "Steward"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
@@ -11,7 +11,6 @@ tags:
|
|||||||
- docker
|
- docker
|
||||||
- infrastructure
|
- infrastructure
|
||||||
|
|
||||||
config:
|
# No config: collection is agent-driven (the host agent reads each host's local
|
||||||
socket_path: /var/run/docker.sock
|
# socket and pushes containers to the host_agent ingest). This plugin is pure
|
||||||
scrape_interval_seconds: 60
|
# presentation + storage, so there's no socket path or scrape interval to tune.
|
||||||
include_stopped: false
|
|
||||||
|
|||||||
+135
-57
@@ -2,11 +2,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from quart import Blueprint, current_app, render_template, request
|
from quart import Blueprint, current_app, render_template, request
|
||||||
from sqlalchemy import Integer, cast, func, select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
from steward.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds
|
from steward.models.hosts import Host
|
||||||
|
from steward.core.time_range import parse_range, DEFAULT_RANGE
|
||||||
from .models import DockerContainer, DockerMetric
|
from .models import DockerContainer, DockerMetric
|
||||||
|
|
||||||
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
||||||
@@ -33,6 +34,38 @@ def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket(values: list[float], target: int = 40) -> list[float]:
|
||||||
|
"""Bucket-average a series down to ~target points (DB-agnostic, in Python).
|
||||||
|
|
||||||
|
Replaces the old SQL strftime() bucketing, which was SQLite-only and broke
|
||||||
|
on Postgres. Agent cadence is dense, so a multi-hour window is hundreds of
|
||||||
|
rows — averaging into a readable point count keeps the sparkline's shape.
|
||||||
|
"""
|
||||||
|
n = len(values)
|
||||||
|
if n <= target:
|
||||||
|
return values
|
||||||
|
size = (n + target - 1) // target
|
||||||
|
out: list[float] = []
|
||||||
|
for i in range(0, n, size):
|
||||||
|
chunk = values[i:i + size]
|
||||||
|
out.append(sum(chunk) / len(chunk))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _container_history(db, host_id: str, name: str, since) -> tuple[list, list]:
|
||||||
|
"""Return (cpu_series, mem_series) sparkline-ready for one host's container."""
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(DockerMetric.cpu_pct, DockerMetric.mem_pct)
|
||||||
|
.where(DockerMetric.host_id == host_id)
|
||||||
|
.where(DockerMetric.container_name == name)
|
||||||
|
.where(DockerMetric.scraped_at >= since)
|
||||||
|
.order_by(DockerMetric.scraped_at)
|
||||||
|
)).all()
|
||||||
|
cpu = _bucket([r.cpu_pct or 0.0 for r in rows])
|
||||||
|
mem = _bucket([r.mem_pct or 0.0 for r in rows])
|
||||||
|
return cpu, mem
|
||||||
|
|
||||||
|
|
||||||
@docker_bp.get("/")
|
@docker_bp.get("/")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def index():
|
async def index():
|
||||||
@@ -45,64 +78,64 @@ async def index():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
||||||
|
if not host_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
h.id: h for h in (await db.execute(
|
||||||
|
select(Host).where(Host.id.in_(host_ids)))).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@docker_bp.get("/rows")
|
@docker_bp.get("/rows")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def rows():
|
async def rows():
|
||||||
"""HTMX fragment: container list with status and resource sparklines."""
|
"""HTMX fragment: containers grouped by host, with resource sparklines."""
|
||||||
since, range_key = parse_range(request.args.get("range"))
|
since, range_key = parse_range(request.args.get("range"))
|
||||||
b_secs = bucket_seconds(since)
|
|
||||||
|
|
||||||
bucket_col = (
|
|
||||||
cast(func.strftime('%s', DockerMetric.scraped_at), Integer) / b_secs
|
|
||||||
).label("bucket")
|
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
# All known containers ordered by running first, then name
|
containers = list((await db.execute(
|
||||||
result = await db.execute(
|
select(DockerContainer).order_by(
|
||||||
select(DockerContainer)
|
|
||||||
.order_by(
|
|
||||||
# running first
|
|
||||||
(DockerContainer.status == "running").desc(),
|
(DockerContainer.status == "running").desc(),
|
||||||
DockerContainer.name,
|
DockerContainer.name,
|
||||||
)
|
)
|
||||||
)
|
)).scalars())
|
||||||
containers = list(result.scalars())
|
hosts = await _host_map(db, {c.host_id for c in containers})
|
||||||
|
|
||||||
# Build per-container sparkline histories
|
# Group by host so each container is clearly attributed to the box it
|
||||||
histories: dict[str, list] = {}
|
# runs on (names are only unique within a host now).
|
||||||
|
groups: dict[str, dict] = {}
|
||||||
for c in containers:
|
for c in containers:
|
||||||
result = await db.execute(
|
g = groups.get(c.host_id)
|
||||||
select(
|
if g is None:
|
||||||
func.avg(DockerMetric.cpu_pct).label("cpu_pct"),
|
host = hosts.get(c.host_id)
|
||||||
func.avg(DockerMetric.mem_pct).label("mem_pct"),
|
g = groups[c.host_id] = {
|
||||||
bucket_col,
|
"host": host,
|
||||||
)
|
"host_id": c.host_id,
|
||||||
.where(DockerMetric.container_name == c.name)
|
"host_name": host.name if host else c.host_id,
|
||||||
.where(DockerMetric.scraped_at >= since)
|
"containers": [], "running": 0, "stopped": 0,
|
||||||
.group_by(bucket_col)
|
}
|
||||||
.order_by(bucket_col)
|
cpu_hist, mem_hist = await _container_history(db, c.host_id, c.name, since)
|
||||||
)
|
g["containers"].append({
|
||||||
histories[c.name] = result.all()
|
"container": c,
|
||||||
|
"ports": json.loads(c.ports_json) if c.ports_json else [],
|
||||||
running = sum(1 for c in containers if c.status == "running")
|
"sparkline_cpu": _sparkline(cpu_hist),
|
||||||
stopped = len(containers) - running
|
"sparkline_mem": _sparkline(mem_hist),
|
||||||
|
})
|
||||||
container_data = []
|
if c.status == "running":
|
||||||
for c in containers:
|
g["running"] += 1
|
||||||
hist = histories.get(c.name, [])
|
else:
|
||||||
ports = json.loads(c.ports_json) if c.ports_json else []
|
g["stopped"] += 1
|
||||||
container_data.append({
|
|
||||||
"container": c,
|
|
||||||
"ports": ports,
|
|
||||||
"sparkline_cpu": _sparkline([r.cpu_pct or 0 for r in hist]),
|
|
||||||
"sparkline_mem": _sparkline([r.mem_pct or 0 for r in hist]),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
|
||||||
|
running = sum(g["running"] for g in host_groups)
|
||||||
|
total = len(containers)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"docker/rows.html",
|
"docker/rows.html",
|
||||||
container_data=container_data,
|
host_groups=host_groups,
|
||||||
running=running,
|
running=running,
|
||||||
stopped=stopped,
|
stopped=total - running,
|
||||||
|
total=total,
|
||||||
range_key=range_key,
|
range_key=range_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,27 +143,38 @@ async def rows():
|
|||||||
@docker_bp.get("/widget")
|
@docker_bp.get("/widget")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def widget():
|
async def widget():
|
||||||
"""HTMX dashboard widget: container status overview."""
|
"""HTMX dashboard widget: container status overview, grouped by host."""
|
||||||
show_stopped = request.args.get("show_stopped", "no") == "yes"
|
show_stopped = request.args.get("show_stopped", "no") == "yes"
|
||||||
widget_id = request.args.get("wid", "0")
|
widget_id = request.args.get("wid", "0")
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
result = await db.execute(
|
all_containers = list((await db.execute(
|
||||||
select(DockerContainer)
|
select(DockerContainer).order_by(
|
||||||
.order_by(
|
|
||||||
(DockerContainer.status == "running").desc(),
|
(DockerContainer.status == "running").desc(),
|
||||||
DockerContainer.name,
|
DockerContainer.name,
|
||||||
)
|
)
|
||||||
)
|
)).scalars())
|
||||||
all_containers = list(result.scalars())
|
hosts = await _host_map(db, {c.host_id for c in all_containers})
|
||||||
|
|
||||||
running = [c for c in all_containers if c.status == "running"]
|
running = [c for c in all_containers if c.status == "running"]
|
||||||
stopped = [c for c in all_containers if c.status != "running"]
|
stopped = [c for c in all_containers if c.status != "running"]
|
||||||
display = all_containers if show_stopped else running
|
display = all_containers if show_stopped else running
|
||||||
|
|
||||||
|
# Group for display so multi-host fleets read clearly; single-host stays flat.
|
||||||
|
groups: dict[str, dict] = {}
|
||||||
|
for c in display:
|
||||||
|
g = groups.setdefault(c.host_id, {
|
||||||
|
"host_id": c.host_id,
|
||||||
|
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id,
|
||||||
|
"containers": [],
|
||||||
|
})
|
||||||
|
g["containers"].append(c)
|
||||||
|
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"docker/widget.html",
|
"docker/widget.html",
|
||||||
containers=display,
|
host_groups=host_groups,
|
||||||
|
multi_host=len(host_groups) > 1,
|
||||||
running_count=len(running),
|
running_count=len(running),
|
||||||
stopped_count=len(stopped),
|
stopped_count=len(stopped),
|
||||||
show_stopped=show_stopped,
|
show_stopped=show_stopped,
|
||||||
@@ -141,20 +185,54 @@ async def widget():
|
|||||||
@docker_bp.get("/widget/resources")
|
@docker_bp.get("/widget/resources")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def widget_resources():
|
async def widget_resources():
|
||||||
"""HTMX dashboard widget: CPU + memory usage for running containers."""
|
"""HTMX dashboard widget: CPU + memory usage for the busiest containers."""
|
||||||
limit = max(1, min(20, int(request.args.get("limit", 10) or 10)))
|
limit = max(1, min(20, int(request.args.get("limit", 10) or 10)))
|
||||||
widget_id = request.args.get("wid", "0")
|
widget_id = request.args.get("wid", "0")
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
result = await db.execute(
|
containers = list((await db.execute(
|
||||||
select(DockerContainer)
|
select(DockerContainer)
|
||||||
.where(DockerContainer.status == "running")
|
.where(DockerContainer.status == "running")
|
||||||
.order_by(DockerContainer.cpu_pct.desc().nullslast())
|
.order_by(DockerContainer.cpu_pct.desc().nullslast())
|
||||||
)
|
)).scalars())[:limit]
|
||||||
containers = list(result.scalars())[:limit]
|
hosts = await _host_map(db, {c.host_id for c in containers})
|
||||||
|
|
||||||
|
rows_data = [
|
||||||
|
{"c": c, "host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id}
|
||||||
|
for c in containers
|
||||||
|
]
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"docker/widget_resources.html",
|
"docker/widget_resources.html",
|
||||||
containers=containers,
|
rows=rows_data,
|
||||||
|
multi_host=len({c.host_id for c in containers}) > 1,
|
||||||
widget_id=widget_id,
|
widget_id=widget_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/host/<host_id>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def host_panel(host_id: str):
|
||||||
|
"""Per-host Docker fragment embedded on the core Hosts hub via HTMX.
|
||||||
|
|
||||||
|
Renders nothing when the host reports no containers, so hosts without Docker
|
||||||
|
don't carry an empty card.
|
||||||
|
"""
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
containers = list((await db.execute(
|
||||||
|
select(DockerContainer)
|
||||||
|
.where(DockerContainer.host_id == host_id)
|
||||||
|
.order_by(
|
||||||
|
(DockerContainer.status == "running").desc(),
|
||||||
|
DockerContainer.name,
|
||||||
|
)
|
||||||
|
)).scalars())
|
||||||
|
|
||||||
|
if not containers:
|
||||||
|
return ""
|
||||||
|
running = sum(1 for c in containers if c.status == "running")
|
||||||
|
return await render_template(
|
||||||
|
"docker/host_panel.html",
|
||||||
|
containers=containers,
|
||||||
|
running=running,
|
||||||
|
stopped=len(containers) - running,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
# plugins/docker/scheduler.py
|
|
||||||
from __future__ import annotations
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from steward.core.scheduler import ScheduledTask
|
|
||||||
from steward.core.alerts import record_metric
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def make_task(app) -> ScheduledTask:
|
|
||||||
interval = int(
|
|
||||||
app.config["PLUGINS"]["docker"].get("scrape_interval_seconds", 60)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scrape():
|
|
||||||
await _do_scrape(app)
|
|
||||||
|
|
||||||
return ScheduledTask(
|
|
||||||
name="docker_scrape",
|
|
||||||
coro_factory=scrape,
|
|
||||||
interval_seconds=interval,
|
|
||||||
run_on_startup=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _do_scrape(app) -> None:
|
|
||||||
from .scraper import scrape_docker
|
|
||||||
from .models import DockerContainer, DockerMetric
|
|
||||||
|
|
||||||
cfg = app.config["PLUGINS"]["docker"]
|
|
||||||
socket_path = cfg.get("socket_path", "/var/run/docker.sock")
|
|
||||||
include_stopped = bool(cfg.get("include_stopped", False))
|
|
||||||
|
|
||||||
try:
|
|
||||||
containers = await scrape_docker(socket_path, include_stopped)
|
|
||||||
except ConnectionError as exc:
|
|
||||||
logger.error("Docker scrape failed: %s", exc)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Docker scrape error")
|
|
||||||
return
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
async with app.db_sessionmaker() as session:
|
|
||||||
async with session.begin():
|
|
||||||
for c in containers:
|
|
||||||
# Upsert container state
|
|
||||||
existing = await session.get(DockerContainer, c["name"])
|
|
||||||
if existing is None:
|
|
||||||
existing = DockerContainer(name=c["name"])
|
|
||||||
session.add(existing)
|
|
||||||
existing.container_id = c["container_id"]
|
|
||||||
existing.image = c["image"]
|
|
||||||
existing.status = c["status"]
|
|
||||||
existing.cpu_pct = c["cpu_pct"]
|
|
||||||
existing.mem_usage_bytes = c["mem_usage_bytes"]
|
|
||||||
existing.mem_limit_bytes = c["mem_limit_bytes"]
|
|
||||||
existing.mem_pct = c["mem_pct"]
|
|
||||||
existing.restart_count = c["restart_count"]
|
|
||||||
existing.ports_json = json.dumps(c["ports"])
|
|
||||||
existing.started_at = c["started_at"]
|
|
||||||
existing.scraped_at = now
|
|
||||||
|
|
||||||
# Time-series metric (running containers only)
|
|
||||||
if c["status"] == "running" and c["cpu_pct"] is not None:
|
|
||||||
session.add(DockerMetric(
|
|
||||||
container_name=c["name"],
|
|
||||||
scraped_at=now,
|
|
||||||
cpu_pct=c["cpu_pct"],
|
|
||||||
mem_pct=c["mem_pct"] or 0.0,
|
|
||||||
mem_usage_bytes=c["mem_usage_bytes"] or 0,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Feed alert pipeline
|
|
||||||
await record_metric(
|
|
||||||
session=session,
|
|
||||||
source_module="docker",
|
|
||||||
resource_name=c["name"],
|
|
||||||
metric_name="cpu_pct",
|
|
||||||
value=c["cpu_pct"],
|
|
||||||
)
|
|
||||||
if c["mem_pct"] is not None:
|
|
||||||
await record_metric(
|
|
||||||
session=session,
|
|
||||||
source_module="docker",
|
|
||||||
resource_name=c["name"],
|
|
||||||
metric_name="mem_pct",
|
|
||||||
value=c["mem_pct"],
|
|
||||||
)
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# plugins/docker/scraper.py
|
|
||||||
"""Docker API client via Unix socket."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _short_id(container_id: str) -> str:
|
|
||||||
return container_id[:12] if container_id else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_cpu_pct(stats: dict) -> float:
|
|
||||||
"""Calculate CPU % from a Docker stats snapshot (one-shot)."""
|
|
||||||
try:
|
|
||||||
cpu = stats.get("cpu_stats", {})
|
|
||||||
precpu = stats.get("precpu_stats", {})
|
|
||||||
cpu_delta = (
|
|
||||||
cpu["cpu_usage"]["total_usage"] - precpu["cpu_usage"]["total_usage"]
|
|
||||||
)
|
|
||||||
sys_delta = cpu.get("system_cpu_usage", 0) - precpu.get("system_cpu_usage", 0)
|
|
||||||
num_cpus = cpu.get("online_cpus") or len(
|
|
||||||
cpu["cpu_usage"].get("percpu_usage") or [None]
|
|
||||||
)
|
|
||||||
if sys_delta <= 0 or cpu_delta < 0:
|
|
||||||
return 0.0
|
|
||||||
return round((cpu_delta / sys_delta) * num_cpus * 100.0, 2)
|
|
||||||
except (KeyError, TypeError, ZeroDivisionError):
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_mem(stats: dict) -> tuple[int, int, float]:
|
|
||||||
"""Return (usage_bytes, limit_bytes, mem_pct) from Docker stats."""
|
|
||||||
try:
|
|
||||||
mem = stats.get("memory_stats", {})
|
|
||||||
usage = mem.get("usage", 0)
|
|
||||||
limit = mem.get("limit", 0)
|
|
||||||
# Subtract page cache for working set (cgroup v1: "cache", v2: "inactive_file")
|
|
||||||
cache = mem.get("stats", {}).get("cache", 0) or \
|
|
||||||
mem.get("stats", {}).get("inactive_file", 0)
|
|
||||||
actual = max(0, usage - cache)
|
|
||||||
pct = round(actual / limit * 100.0, 2) if limit > 0 else 0.0
|
|
||||||
return actual, limit, pct
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
return 0, 0, 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ports(ports: list) -> list[dict]:
|
|
||||||
"""Normalise Docker port bindings to a compact list."""
|
|
||||||
result = []
|
|
||||||
for p in (ports or []):
|
|
||||||
if p.get("PublicPort"):
|
|
||||||
result.append({
|
|
||||||
"host_port": p["PublicPort"],
|
|
||||||
"container_port": p["PrivatePort"],
|
|
||||||
"protocol": p.get("Type", "tcp"),
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def scrape_docker(socket_path: str, include_stopped: bool) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Fetch all containers + resource stats from the Docker daemon.
|
|
||||||
Returns a list of normalised dicts ready for the scheduler to persist.
|
|
||||||
Raises ConnectionError if the socket is unreachable.
|
|
||||||
"""
|
|
||||||
transport = httpx.AsyncHTTPTransport(uds=socket_path)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport,
|
|
||||||
base_url="http://docker",
|
|
||||||
timeout=10.0,
|
|
||||||
) as client:
|
|
||||||
resp = await client.get(
|
|
||||||
"/containers/json",
|
|
||||||
params={"all": "true" if include_stopped else "false"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
containers = resp.json()
|
|
||||||
|
|
||||||
async def _get_stats(c: dict) -> tuple[dict, dict | None]:
|
|
||||||
if c.get("State") != "running":
|
|
||||||
return c, None
|
|
||||||
try:
|
|
||||||
r = await client.get(
|
|
||||||
f"/containers/{c['Id']}/stats",
|
|
||||||
params={"stream": "false", "one-shot": "true"},
|
|
||||||
timeout=5.0,
|
|
||||||
)
|
|
||||||
return c, r.json()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(
|
|
||||||
"Stats unavailable for %s: %s", _short_id(c.get("Id", "")), exc
|
|
||||||
)
|
|
||||||
return c, None
|
|
||||||
|
|
||||||
pairs = await asyncio.gather(*[_get_stats(c) for c in containers])
|
|
||||||
|
|
||||||
except httpx.ConnectError as exc:
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Cannot connect to Docker socket at {socket_path}: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for container, stats in pairs:
|
|
||||||
names = container.get("Names") or []
|
|
||||||
name = names[0].lstrip("/") if names else _short_id(container.get("Id", ""))
|
|
||||||
|
|
||||||
cpu_pct = mem_usage = mem_limit = mem_pct = None
|
|
||||||
if stats is not None:
|
|
||||||
cpu_pct = _calc_cpu_pct(stats)
|
|
||||||
mem_usage, mem_limit, mem_pct = _calc_mem(stats)
|
|
||||||
|
|
||||||
# Created is a Unix epoch int from /containers/json
|
|
||||||
created_ts = container.get("Created")
|
|
||||||
started_at = (
|
|
||||||
datetime.fromtimestamp(created_ts, tz=timezone.utc)
|
|
||||||
if isinstance(created_ts, (int, float)) else None
|
|
||||||
)
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"name": name,
|
|
||||||
"container_id": _short_id(container.get("Id", "")),
|
|
||||||
"image": container.get("Image", ""),
|
|
||||||
"status": container.get("State", "unknown"),
|
|
||||||
"cpu_pct": cpu_pct,
|
|
||||||
"mem_usage_bytes": mem_usage,
|
|
||||||
"mem_limit_bytes": mem_limit,
|
|
||||||
"mem_pct": mem_pct,
|
|
||||||
"restart_count": 0, # would require /inspect; left as 0 for now
|
|
||||||
"ports": _parse_ports(container.get("Ports", [])),
|
|
||||||
"started_at": started_at,
|
|
||||||
})
|
|
||||||
|
|
||||||
return results
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{# docker/host_panel.html — per-host Docker fragment embedded on the Hosts hub #}
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:0.6rem;margin-bottom:0.6rem;">
|
||||||
|
<h3 class="section-title" style="margin-bottom:0;">Docker</h3>
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-muted);">
|
||||||
|
{{ running }} running{% if stopped %} · {{ stopped }} stopped{% endif %}
|
||||||
|
<a href="/plugins/docker/" style="margin-left:0.6rem;color:var(--text-muted);">All →</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;gap:2px;">
|
||||||
|
{% for c in containers %}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.85rem;padding:0.2rem 0;">
|
||||||
|
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||||
|
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ c.image }}">{{ c.name }}</span>
|
||||||
|
<span style="font-size:0.74rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
|
||||||
|
{% if c.cpu_pct is not none %}{{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
|
||||||
|
{% if c.mem_pct is not none %} · {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{# docker/rows.html — HTMX fragment for the Docker main page #}
|
{# docker/rows.html — HTMX fragment for the Docker main page, grouped by host #}
|
||||||
|
|
||||||
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
|
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||||
@@ -12,13 +12,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">Total</div>
|
<div class="section-title" style="margin-bottom:0.35rem;">Total</div>
|
||||||
<span class="stat-val">{{ container_data | length }}</span>
|
<span class="stat-val">{{ total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">Hosts</div>
|
||||||
|
<span class="stat-val">{{ host_groups | length }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Container table ─────────────────────────────────────────────────────── #}
|
{# ── Container tables, one per host ───────────────────────────────────────── #}
|
||||||
{% if container_data %}
|
{% if host_groups %}
|
||||||
<div class="card-flush">
|
{% for g in host_groups %}
|
||||||
|
<div class="card-flush" style="margin-bottom:1.5rem;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
|
||||||
|
{% if g.host %}
|
||||||
|
<a href="/hosts/{{ g.host.id }}" style="font-weight:600;font-size:0.95rem;color:inherit;text-decoration:none;">{{ g.host.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-weight:600;font-size:0.95rem;">{{ g.host_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-muted);">
|
||||||
|
{{ g.running }} running{% if g.stopped %} · {{ g.stopped }} stopped{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -32,7 +47,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in container_data %}
|
{% for item in g.containers %}
|
||||||
{% set c = item.container %}
|
{% set c = item.container %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -77,10 +92,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="text-align:center;padding:3rem;">
|
<div class="card" style="text-align:center;padding:3rem;">
|
||||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||||
No containers found. Make sure the Docker socket is accessible and the plugin is configured correctly.
|
No containers reported yet. Containers are collected by the Steward host agent —
|
||||||
|
deploy the agent to a host running Docker (Hosts → the host → agent panel) and
|
||||||
|
its containers will appear here under that host.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# docker/widget.html — dashboard widget: container status overview #}
|
{# docker/widget.html — dashboard widget: container status overview, by host #}
|
||||||
{% if not containers and running_count == 0 and stopped_count == 0 %}
|
{% if running_count == 0 and stopped_count == 0 %}
|
||||||
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No containers found.</p>
|
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No containers reported yet.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;">
|
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;">
|
||||||
@@ -16,8 +16,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% for g in host_groups %}
|
||||||
|
{% if multi_host %}
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.2rem;">{{ g.host_name }}</div>
|
||||||
|
{% endif %}
|
||||||
<div style="display:grid;gap:2px;">
|
<div style="display:grid;gap:2px;">
|
||||||
{% for c in containers %}
|
{% for c in g.containers %}
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;padding:0.15rem 0;">
|
||||||
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||||
@@ -31,5 +35,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{# docker/widget_resources.html — dashboard widget: CPU + memory bars for running containers #}
|
{# docker/widget_resources.html — dashboard widget: CPU + memory bars, busiest containers #}
|
||||||
{% if not containers %}
|
{% if not rows %}
|
||||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No running containers.</div>
|
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No running containers.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="display:grid;gap:0.5rem;">
|
<div style="display:grid;gap:0.5rem;">
|
||||||
{% for c in containers %}
|
{% for r in rows %}
|
||||||
|
{% set c = r.c %}
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.15rem;">
|
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.15rem;">
|
||||||
<span style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%;">
|
<span style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%;">
|
||||||
{{ c.name }}
|
{{ c.name }}{% if multi_host %}<span style="color:var(--text-dim);font-weight:normal;"> · {{ r.host_name }}</span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;margin-left:0.5rem;">
|
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;margin-left:0.5rem;">
|
||||||
{% if c.cpu_pct is not none %}CPU {{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
|
{% if c.cpu_pct is not none %}CPU {{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
|
||||||
|
|||||||
+196
-3
@@ -19,7 +19,12 @@ import urllib.request
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
AGENT_VERSION = "1.2.0"
|
AGENT_VERSION = "1.3.0"
|
||||||
|
|
||||||
|
# Default path to the local Docker Engine socket. Overridable via the
|
||||||
|
# `docker_socket` config key; collection is silently skipped if it's absent or
|
||||||
|
# unreadable, so this never needs setting on a Docker-less host (zero-config).
|
||||||
|
DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
@@ -62,6 +67,7 @@ def read_config(path: str) -> dict:
|
|||||||
raise ConfigError(f"{path}: missing required key(s): {', '.join(missing)}")
|
raise ConfigError(f"{path}: missing required key(s): {', '.join(missing)}")
|
||||||
|
|
||||||
cfg.setdefault("interval_seconds", 30)
|
cfg.setdefault("interval_seconds", 30)
|
||||||
|
cfg.setdefault("docker_socket", DEFAULT_DOCKER_SOCKET)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -386,6 +392,180 @@ def _rates(cur: dict, prev: dict, dt: float) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ─── docker ──────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Per-host container collection over the local Docker socket. stdlib-only: a
|
||||||
|
# minimal HTTP/1.1 client over an AF_UNIX socket. We send `Connection: close`
|
||||||
|
# so the daemon closes the socket at end of response and we can read to EOF;
|
||||||
|
# Docker still frames the body with Transfer-Encoding: chunked, so we de-chunk
|
||||||
|
# when that header is present rather than assume Content-Length.
|
||||||
|
|
||||||
|
DOCKER_API_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dechunk(body: bytes) -> bytes:
|
||||||
|
"""Decode an HTTP/1.1 chunked-transfer body into the raw payload."""
|
||||||
|
out = bytearray()
|
||||||
|
while body:
|
||||||
|
line, sep, rest = body.partition(b"\r\n")
|
||||||
|
if not sep:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
size = int(line.split(b";", 1)[0], 16) # ignore chunk extensions
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
if size == 0:
|
||||||
|
break
|
||||||
|
out += rest[:size]
|
||||||
|
body = rest[size + 2:] # skip the chunk data and its trailing CRLF
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_request(socket_path: str, path: str, timeout: float = DOCKER_API_TIMEOUT):
|
||||||
|
"""GET `path` from the Docker Engine API over a Unix socket; return JSON.
|
||||||
|
|
||||||
|
Raises OSError on any socket/transport problem (absent socket, permission
|
||||||
|
denied, non-200) and ValueError on a non-JSON body — both are caught by the
|
||||||
|
caller and treated as "no docker here", so collection degrades silently.
|
||||||
|
"""
|
||||||
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
try:
|
||||||
|
sock.connect(socket_path)
|
||||||
|
req = (
|
||||||
|
"GET " + path + " HTTP/1.1\r\n"
|
||||||
|
"Host: docker\r\n"
|
||||||
|
"Accept: application/json\r\n"
|
||||||
|
"Connection: close\r\n"
|
||||||
|
"\r\n"
|
||||||
|
)
|
||||||
|
sock.sendall(req.encode("ascii"))
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
buf = sock.recv(65536)
|
||||||
|
if not buf:
|
||||||
|
break
|
||||||
|
chunks.append(buf)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
raw = b"".join(chunks)
|
||||||
|
head, _, body = raw.partition(b"\r\n\r\n")
|
||||||
|
header_text = head.decode("latin-1")
|
||||||
|
status_line = header_text.split("\r\n", 1)[0]
|
||||||
|
parts = status_line.split(None, 2) # "HTTP/1.1 200 OK"
|
||||||
|
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 0
|
||||||
|
if status != 200:
|
||||||
|
raise OSError(f"docker API {path} returned {status}")
|
||||||
|
if "transfer-encoding: chunked" in header_text.lower():
|
||||||
|
body = _dechunk(body)
|
||||||
|
return json.loads(body.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_cpu_pct(stats: dict) -> float:
|
||||||
|
"""CPU % from a Docker stats snapshot (delta of container vs system CPU).
|
||||||
|
|
||||||
|
Ported verbatim from the old central scraper's math; correct as long as both
|
||||||
|
cpu_stats and precpu_stats are populated (we request without `one-shot` so
|
||||||
|
the daemon fills precpu over one cycle).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cpu = stats.get("cpu_stats", {})
|
||||||
|
precpu = stats.get("precpu_stats", {})
|
||||||
|
cpu_delta = (
|
||||||
|
cpu["cpu_usage"]["total_usage"] - precpu["cpu_usage"]["total_usage"]
|
||||||
|
)
|
||||||
|
sys_delta = cpu.get("system_cpu_usage", 0) - precpu.get("system_cpu_usage", 0)
|
||||||
|
num_cpus = cpu.get("online_cpus") or len(
|
||||||
|
cpu["cpu_usage"].get("percpu_usage") or [None]
|
||||||
|
)
|
||||||
|
if sys_delta <= 0 or cpu_delta < 0:
|
||||||
|
return 0.0
|
||||||
|
return round((cpu_delta / sys_delta) * num_cpus * 100.0, 2)
|
||||||
|
except (KeyError, TypeError, ZeroDivisionError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_mem(stats: dict):
|
||||||
|
"""Return (usage_bytes, limit_bytes, mem_pct); working set excludes cache."""
|
||||||
|
try:
|
||||||
|
mem = stats.get("memory_stats", {})
|
||||||
|
usage = mem.get("usage", 0)
|
||||||
|
limit = mem.get("limit", 0)
|
||||||
|
# Subtract page cache for working set (cgroup v1 "cache", v2 "inactive_file").
|
||||||
|
cache = mem.get("stats", {}).get("cache", 0) or \
|
||||||
|
mem.get("stats", {}).get("inactive_file", 0)
|
||||||
|
actual = max(0, usage - cache)
|
||||||
|
pct = round(actual / limit * 100.0, 2) if limit > 0 else 0.0
|
||||||
|
return actual, limit, pct
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
return 0, 0, 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_ports(ports: list) -> list:
|
||||||
|
"""Normalise Docker port bindings to a compact published-ports list."""
|
||||||
|
out = []
|
||||||
|
for p in (ports or []):
|
||||||
|
if p.get("PublicPort"):
|
||||||
|
out.append({
|
||||||
|
"host_port": p["PublicPort"],
|
||||||
|
"container_port": p.get("PrivatePort"),
|
||||||
|
"protocol": p.get("Type", "tcp"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def collect_docker(socket_path: str) -> list:
|
||||||
|
"""Per-container state from the local Docker socket, or [] if unavailable.
|
||||||
|
|
||||||
|
Absent socket / permission denied / non-Docker endpoint all yield [] — the
|
||||||
|
agent silently reports no containers rather than erroring, so a Docker-less
|
||||||
|
host needs no configuration.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
containers = _docker_request(socket_path, "/containers/json?all=true")
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
if not isinstance(containers, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for c in containers:
|
||||||
|
cid = c.get("Id", "") or ""
|
||||||
|
names = c.get("Names") or []
|
||||||
|
name = names[0].lstrip("/") if names else cid[:12]
|
||||||
|
state = c.get("State", "unknown")
|
||||||
|
|
||||||
|
cpu_pct = mem_usage = mem_limit = mem_pct = None
|
||||||
|
if state == "running":
|
||||||
|
try:
|
||||||
|
stats = _docker_request(
|
||||||
|
socket_path, f"/containers/{cid}/stats?stream=false")
|
||||||
|
cpu_pct = _docker_cpu_pct(stats)
|
||||||
|
mem_usage, mem_limit, mem_pct = _docker_mem(stats)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass # stats unavailable for this container — leave gauges None
|
||||||
|
|
||||||
|
created = c.get("Created")
|
||||||
|
started_at = (
|
||||||
|
datetime.fromtimestamp(created, tz=timezone.utc).isoformat()
|
||||||
|
if isinstance(created, (int, float)) else None
|
||||||
|
)
|
||||||
|
out.append({
|
||||||
|
"name": name,
|
||||||
|
"container_id": cid[:12],
|
||||||
|
"image": c.get("Image", ""),
|
||||||
|
"status": state,
|
||||||
|
"cpu_pct": cpu_pct,
|
||||||
|
"mem_usage_bytes": mem_usage,
|
||||||
|
"mem_limit_bytes": mem_limit,
|
||||||
|
"mem_pct": mem_pct,
|
||||||
|
"ports": _docker_ports(c.get("Ports", [])),
|
||||||
|
"started_at": started_at,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ─── ring buffer ─────────────────────────────────────────────────────────────
|
# ─── ring buffer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -408,11 +588,14 @@ class RingBuffer:
|
|||||||
# ─── payload ─────────────────────────────────────────────────────────────────
|
# ─── payload ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def build_sample(mounts: list[str], state: dict) -> dict:
|
def build_sample(mounts: list[str], state: dict,
|
||||||
|
docker_socket: str = DEFAULT_DOCKER_SOCKET) -> dict:
|
||||||
"""Collect one full sample. Partial samples allowed if a collector fails.
|
"""Collect one full sample. Partial samples allowed if a collector fails.
|
||||||
|
|
||||||
`state` carries the previous network/disk counters + monotonic timestamp so
|
`state` carries the previous network/disk counters + monotonic timestamp so
|
||||||
throughput rates can be derived from deltas; it is mutated in place.
|
throughput rates can be derived from deltas; it is mutated in place.
|
||||||
|
`docker_socket` is probed best-effort — the `docker` key is omitted entirely
|
||||||
|
when no containers are found, so non-Docker hosts add nothing to the payload.
|
||||||
"""
|
"""
|
||||||
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
|
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
|
||||||
try:
|
try:
|
||||||
@@ -466,6 +649,14 @@ def build_sample(mounts: list[str], state: dict) -> dict:
|
|||||||
for n, (rd, wr) in _rates(disk_raw, state.get("disk", {}), dt).items()
|
for n, (rd, wr) in _rates(disk_raw, state.get("disk", {}), dt).items()
|
||||||
]
|
]
|
||||||
state["net"], state["disk"], state["mono"] = net_raw, disk_raw, now_mono
|
state["net"], state["disk"], state["mono"] = net_raw, disk_raw, now_mono
|
||||||
|
|
||||||
|
try:
|
||||||
|
docker = collect_docker(docker_socket)
|
||||||
|
except Exception:
|
||||||
|
docker = []
|
||||||
|
if docker:
|
||||||
|
sample["docker"] = docker
|
||||||
|
|
||||||
return sample
|
return sample
|
||||||
|
|
||||||
|
|
||||||
@@ -547,6 +738,7 @@ def main_loop(conf_path: str) -> int:
|
|||||||
metadata = collect_metadata()
|
metadata = collect_metadata()
|
||||||
hostname = cfg.get("hostname") or socket.gethostname()
|
hostname = cfg.get("hostname") or socket.gethostname()
|
||||||
mounts = cfg.get("mounts") or default_mounts()
|
mounts = cfg.get("mounts") or default_mounts()
|
||||||
|
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
||||||
buffer = RingBuffer(maxlen=20)
|
buffer = RingBuffer(maxlen=20)
|
||||||
backoff = 0
|
backoff = 0
|
||||||
# Carries previous net/disk counters + monotonic ts for rate computation.
|
# Carries previous net/disk counters + monotonic ts for rate computation.
|
||||||
@@ -560,13 +752,14 @@ def main_loop(conf_path: str) -> int:
|
|||||||
try:
|
try:
|
||||||
cfg = read_config(conf_path)
|
cfg = read_config(conf_path)
|
||||||
mounts = cfg.get("mounts") or default_mounts()
|
mounts = cfg.get("mounts") or default_mounts()
|
||||||
|
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
||||||
metadata = collect_metadata() # refresh host_ip/distro on reload
|
metadata = collect_metadata() # refresh host_ip/distro on reload
|
||||||
_log("INFO", "config reloaded")
|
_log("INFO", "config reloaded")
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
_log("ERROR", f"reload failed: {e}")
|
_log("ERROR", f"reload failed: {e}")
|
||||||
_reload_requested = False
|
_reload_requested = False
|
||||||
|
|
||||||
sample = build_sample(mounts, rate_state)
|
sample = build_sample(mounts, rate_state, docker_socket)
|
||||||
buffered = buffer.drain()
|
buffered = buffer.drain()
|
||||||
payload = build_payload(
|
payload = build_payload(
|
||||||
samples=buffered + [sample],
|
samples=buffered + [sample],
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ async def ingest():
|
|||||||
|
|
||||||
accepted = 0
|
accepted = 0
|
||||||
latest_ts: datetime | None = None
|
latest_ts: datetime | None = None
|
||||||
|
docker_snapshots: list[tuple[datetime, list]] = []
|
||||||
for sample in samples:
|
for sample in samples:
|
||||||
try:
|
try:
|
||||||
recorded_at = _parse_ts(sample["ts"])
|
recorded_at = _parse_ts(sample["ts"])
|
||||||
@@ -204,6 +205,9 @@ async def ingest():
|
|||||||
metrics = _expand_sample_to_metrics(sample, host.name, recorded_at)
|
metrics = _expand_sample_to_metrics(sample, host.name, recorded_at)
|
||||||
for m in metrics:
|
for m in metrics:
|
||||||
session.add(m)
|
session.add(m)
|
||||||
|
docker = sample.get("docker")
|
||||||
|
if isinstance(docker, list) and docker:
|
||||||
|
docker_snapshots.append((recorded_at, docker))
|
||||||
accepted += 1
|
accepted += 1
|
||||||
if latest_ts is None or recorded_at > latest_ts:
|
if latest_ts is None or recorded_at > latest_ts:
|
||||||
latest_ts = recorded_at
|
latest_ts = recorded_at
|
||||||
@@ -212,6 +216,26 @@ async def ingest():
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
return _error(400, "malformed_payload", "no valid samples")
|
return _error(400, "malformed_payload", "no valid samples")
|
||||||
|
|
||||||
|
# Hand host-scoped container data to the docker plugin if it's enabled
|
||||||
|
# (opportunistic synergy via the capability registry — no hard import,
|
||||||
|
# no-op when docker is disabled). A failure here must never sink the
|
||||||
|
# whole ingest, so the metrics above still land.
|
||||||
|
if docker_snapshots:
|
||||||
|
from steward.core.capabilities import has_capability, invoke_capability
|
||||||
|
if has_capability("docker.persist_host_samples"):
|
||||||
|
try:
|
||||||
|
# SAVEPOINT so a docker-side failure rolls back only the
|
||||||
|
# docker writes, leaving the host metrics intact to commit.
|
||||||
|
async with session.begin_nested():
|
||||||
|
await invoke_capability(
|
||||||
|
"docker.persist_host_samples", UserRole.admin,
|
||||||
|
session, host, docker_snapshots,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
current_app.logger.exception(
|
||||||
|
"host_agent ingest: docker persist failed for host=%s",
|
||||||
|
host.name)
|
||||||
|
|
||||||
md = payload.get("metadata") or {}
|
md = payload.get("metadata") or {}
|
||||||
changed = False
|
changed = False
|
||||||
if latest_ts and (reg.last_seen_at is None or latest_ts > reg.last_seen_at):
|
if latest_ts and (reg.last_seen_at is None or latest_ts > reg.last_seen_at):
|
||||||
|
|||||||
+3
-3
@@ -14,14 +14,14 @@ updated: "2026-03-22"
|
|||||||
plugins:
|
plugins:
|
||||||
|
|
||||||
- name: docker
|
- name: docker
|
||||||
version: "1.0.0"
|
version: "2.0.0"
|
||||||
description: "Docker container status, resource usage, and restart tracking via Docker socket"
|
description: "Per-host Docker container status + resource usage, collected by the Steward host agent"
|
||||||
author: "Steward"
|
author: "Steward"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
min_app_version: "0.1.0"
|
min_app_version: "0.1.0"
|
||||||
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
|
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
|
||||||
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/docker"
|
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/docker"
|
||||||
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/docker-v1.0.0/docker.zip"
|
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/docker-v2.0.0/docker.zip"
|
||||||
checksum_sha256: ""
|
checksum_sha256: ""
|
||||||
tags:
|
tags:
|
||||||
- containers
|
- containers
|
||||||
|
|||||||
@@ -29,6 +29,23 @@
|
|||||||
shell: /usr/sbin/nologin
|
shell: /usr/sbin/nologin
|
||||||
create_home: false
|
create_home: false
|
||||||
|
|
||||||
|
# Let the agent read the local Docker socket so it can report this host's
|
||||||
|
# containers (the per-host docker collection path). Best-effort: skipped on
|
||||||
|
# hosts without Docker. Restart picks up the new supplementary group.
|
||||||
|
- name: Detect docker group
|
||||||
|
ansible.builtin.command: getent group docker
|
||||||
|
register: _docker_grp
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Add steward-agent to the docker group
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: steward-agent
|
||||||
|
groups: docker
|
||||||
|
append: true
|
||||||
|
when: _docker_grp.rc == 0
|
||||||
|
notify: restart steward-agent
|
||||||
|
|
||||||
- name: Create agent directory
|
- name: Create agent directory
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: /usr/local/lib/steward-agent
|
path: /usr/local/lib/steward-agent
|
||||||
|
|||||||
@@ -85,6 +85,23 @@
|
|||||||
shell: /usr/sbin/nologin
|
shell: /usr/sbin/nologin
|
||||||
create_home: false
|
create_home: false
|
||||||
|
|
||||||
|
# Let the agent read the local Docker socket so it can report this host's
|
||||||
|
# containers (the per-host docker collection path). Best-effort: skipped on
|
||||||
|
# hosts without Docker. Restart picks up the new supplementary group.
|
||||||
|
- name: Detect docker group
|
||||||
|
ansible.builtin.command: getent group docker
|
||||||
|
register: _docker_grp
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Add steward-agent to the docker group
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: steward-agent
|
||||||
|
groups: docker
|
||||||
|
append: true
|
||||||
|
when: _docker_grp.rc == 0
|
||||||
|
notify: restart steward-agent
|
||||||
|
|
||||||
- name: Create agent directory
|
- name: Create agent directory
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: /usr/local/lib/steward-agent
|
path: /usr/local/lib/steward-agent
|
||||||
|
|||||||
+11
-1
@@ -167,7 +167,17 @@ def create_app(
|
|||||||
@app.context_processor
|
@app.context_processor
|
||||||
def _inject_plugin_failures():
|
def _inject_plugin_failures():
|
||||||
from .core.plugin_manager import get_plugin_failures
|
from .core.plugin_manager import get_plugin_failures
|
||||||
return {"plugin_failures": get_plugin_failures()}
|
# enabled_plugins lets templates gate cross-plugin embeds (e.g. the
|
||||||
|
# Hosts hub only fetches the docker fragment when docker is enabled),
|
||||||
|
# avoiding a 404 to a route whose blueprint isn't registered.
|
||||||
|
enabled_plugins = {
|
||||||
|
name for name, cfg in (app.config.get("PLUGINS") or {}).items()
|
||||||
|
if isinstance(cfg, dict) and cfg.get("enabled")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"plugin_failures": get_plugin_failures(),
|
||||||
|
"enabled_plugins": enabled_plugins,
|
||||||
|
}
|
||||||
|
|
||||||
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
||||||
@app.before_request
|
@app.before_request
|
||||||
|
|||||||
@@ -122,6 +122,11 @@
|
|||||||
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
|
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Docker (docker plugin fragment; renders nothing if the host has none) ──── #}
|
||||||
|
{% if "docker" in enabled_plugins %}
|
||||||
|
<div hx-get="/plugins/docker/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Ansible ──────────────────────────────────────────────────────────────── #}
|
{# ── Ansible ──────────────────────────────────────────────────────────────── #}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="section-title">Ansible</h3>
|
<h3 class="section-title">Ansible</h3>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Integration: per-host Docker schema + host-scoped persistence.
|
||||||
|
|
||||||
|
Validates the docker_002 migration (host_id columns + composite key) applied and
|
||||||
|
that persist_host_docker scopes containers/metrics by host so same-named
|
||||||
|
containers on different hosts no longer collide. Requires STEWARD_DATABASE_URL.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_NEEDS_DB = pytest.mark.skipif(
|
||||||
|
not os.environ.get("STEWARD_DATABASE_URL"),
|
||||||
|
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||||
|
pytest.skip("needs Postgres")
|
||||||
|
from steward.app import create_app
|
||||||
|
return create_app(testing=False)
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_docker_tables_are_host_scoped(app):
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
# Both tables carry a host_id column after docker_002.
|
||||||
|
for tbl in ("docker_containers", "docker_metrics"):
|
||||||
|
cols = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :t"), {"t": tbl})).all()}
|
||||||
|
assert "host_id" in cols, f"{tbl} missing host_id"
|
||||||
|
# docker_containers is keyed by (host_id, name), not name alone.
|
||||||
|
pk_cols = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT a.attname FROM pg_index i "
|
||||||
|
"JOIN pg_attribute a ON a.attrelid = i.indrelid "
|
||||||
|
" AND a.attnum = ANY(i.indkey) "
|
||||||
|
"WHERE i.indrelid = 'docker_containers'::regclass AND i.indisprimary"
|
||||||
|
))).all()}
|
||||||
|
assert pk_cols == {"host_id", "name"}
|
||||||
|
|
||||||
|
asyncio.run(_go())
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_fn(app):
|
||||||
|
"""Resolve persist_host_docker via the registered capability if the docker
|
||||||
|
plugin is loaded, else import it directly (the import is safe only when the
|
||||||
|
plugin is NOT loaded — otherwise its models are already mapped)."""
|
||||||
|
from steward.core.capabilities import has_capability, get_capability
|
||||||
|
if has_capability("docker.persist_host_samples"):
|
||||||
|
return get_capability("docker.persist_host_samples").fn
|
||||||
|
from plugins.docker.ingest import persist_host_docker
|
||||||
|
return persist_host_docker
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_persist_scopes_containers_by_host(app):
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
persist = _persist_fn(app)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
snapshot = [(now, [{
|
||||||
|
"name": "web", "container_id": "abc123", "image": "nginx",
|
||||||
|
"status": "running", "cpu_pct": 12.5, "mem_pct": 30.0,
|
||||||
|
"mem_usage_bytes": 100, "mem_limit_bytes": 200, "ports": [], "started_at": None,
|
||||||
|
}])]
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(text("DELETE FROM docker_metrics"))
|
||||||
|
await s.execute(text("DELETE FROM docker_containers"))
|
||||||
|
await s.execute(text(
|
||||||
|
"DELETE FROM plugin_metrics WHERE source_module = 'docker'"))
|
||||||
|
h1 = Host(id=str(uuid.uuid4()), name="alpha", address="10.0.0.1")
|
||||||
|
h2 = Host(id=str(uuid.uuid4()), name="beta", address="10.0.0.2")
|
||||||
|
s.add_all([h1, h2])
|
||||||
|
await s.flush()
|
||||||
|
# Same container name on two hosts — must not collide now.
|
||||||
|
await persist(s, h1, snapshot)
|
||||||
|
await persist(s, h2, snapshot)
|
||||||
|
|
||||||
|
web_rows = (await s.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM docker_containers WHERE name = 'web'"))).scalar()
|
||||||
|
metric_hosts = (await s.execute(text(
|
||||||
|
"SELECT COUNT(DISTINCT host_id) FROM docker_metrics "
|
||||||
|
"WHERE container_name = 'web'"))).scalar()
|
||||||
|
# Alert pipeline received host-scoped resource names.
|
||||||
|
resources = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT DISTINCT resource_name FROM plugin_metrics "
|
||||||
|
"WHERE source_module = 'docker'"))).all()}
|
||||||
|
return web_rows, metric_hosts, resources
|
||||||
|
|
||||||
|
web_rows, metric_hosts, resources = asyncio.run(_go())
|
||||||
|
assert web_rows == 2 # one 'web' per host, keyed by (host_id, name)
|
||||||
|
assert metric_hosts == 2 # time-series rows scoped per host
|
||||||
|
assert resources == {"alpha/web", "beta/web"}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# tests/plugins/host_agent/test_agent_docker.py
|
||||||
|
"""Unit tests for the agent's stdlib Docker collector.
|
||||||
|
|
||||||
|
No socket / no network — the UDS request layer is monkeypatched so we exercise
|
||||||
|
the cpu/mem math, chunked-body decoding, sample assembly, and the silent-skip
|
||||||
|
contract in isolation.
|
||||||
|
"""
|
||||||
|
from plugins.host_agent import agent as a
|
||||||
|
|
||||||
|
|
||||||
|
# ── chunked transfer decoding ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_dechunk_reassembles_body():
|
||||||
|
chunked = b"4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n"
|
||||||
|
assert a._dechunk(chunked) == b"Wikipedia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dechunk_ignores_chunk_extensions():
|
||||||
|
assert a._dechunk(b"3;name=v\r\nabc\r\n0\r\n\r\n") == b"abc"
|
||||||
|
|
||||||
|
|
||||||
|
# ── cpu / mem math (ported from the old central scraper) ──────────────────────
|
||||||
|
|
||||||
|
def test_docker_cpu_pct():
|
||||||
|
stats = {
|
||||||
|
"cpu_stats": {
|
||||||
|
"cpu_usage": {"total_usage": 200_000_000},
|
||||||
|
"system_cpu_usage": 2_000_000_000,
|
||||||
|
"online_cpus": 4,
|
||||||
|
},
|
||||||
|
"precpu_stats": {
|
||||||
|
"cpu_usage": {"total_usage": 100_000_000},
|
||||||
|
"system_cpu_usage": 1_000_000_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# cpu_delta=1e8, sys_delta=1e9 → 0.1 * 4 cpus * 100 = 40.0%
|
||||||
|
assert a._docker_cpu_pct(stats) == 40.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_cpu_pct_guards_bad_stats():
|
||||||
|
assert a._docker_cpu_pct({}) == 0.0
|
||||||
|
# No forward progress in system time → 0, never a divide error.
|
||||||
|
assert a._docker_cpu_pct({
|
||||||
|
"cpu_stats": {"cpu_usage": {"total_usage": 5}, "system_cpu_usage": 10},
|
||||||
|
"precpu_stats": {"cpu_usage": {"total_usage": 5}, "system_cpu_usage": 10},
|
||||||
|
}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_mem_subtracts_cache():
|
||||||
|
stats = {"memory_stats": {
|
||||||
|
"usage": 200 * 1024 * 1024,
|
||||||
|
"limit": 1000 * 1024 * 1024,
|
||||||
|
"stats": {"cache": 50 * 1024 * 1024},
|
||||||
|
}}
|
||||||
|
usage, limit, pct = a._docker_mem(stats)
|
||||||
|
assert usage == 150 * 1024 * 1024 # working set = usage − cache
|
||||||
|
assert limit == 1000 * 1024 * 1024
|
||||||
|
assert pct == 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_ports_keeps_only_published():
|
||||||
|
ports = [
|
||||||
|
{"PublicPort": 8080, "PrivatePort": 80, "Type": "tcp"},
|
||||||
|
{"PrivatePort": 5432, "Type": "tcp"}, # unpublished → dropped
|
||||||
|
]
|
||||||
|
assert a._docker_ports(ports) == [
|
||||||
|
{"host_port": 8080, "container_port": 80, "protocol": "tcp"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── collect_docker assembly + silent skip ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_collect_docker_silent_skip_when_no_socket():
|
||||||
|
# Absent socket path must degrade to [] (zero-config on Docker-less hosts).
|
||||||
|
assert a.collect_docker("/nonexistent/does-not-exist.sock") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_assembles_container(monkeypatch):
|
||||||
|
container = {
|
||||||
|
"Id": "abc123def4567890",
|
||||||
|
"Names": ["/web"],
|
||||||
|
"Image": "nginx:latest",
|
||||||
|
"State": "running",
|
||||||
|
"Created": 1_700_000_000,
|
||||||
|
"Ports": [{"PublicPort": 8080, "PrivatePort": 80, "Type": "tcp"}],
|
||||||
|
}
|
||||||
|
stats = {
|
||||||
|
"cpu_stats": {"cpu_usage": {"total_usage": 200_000_000},
|
||||||
|
"system_cpu_usage": 2_000_000_000, "online_cpus": 4},
|
||||||
|
"precpu_stats": {"cpu_usage": {"total_usage": 100_000_000},
|
||||||
|
"system_cpu_usage": 1_000_000_000},
|
||||||
|
"memory_stats": {"usage": 200 * 1024 * 1024, "limit": 1000 * 1024 * 1024,
|
||||||
|
"stats": {"cache": 50 * 1024 * 1024}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||||
|
return [container] if path.startswith("/containers/json") else stats
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "_docker_request", fake_request)
|
||||||
|
out = a.collect_docker("/var/run/docker.sock")
|
||||||
|
assert len(out) == 1
|
||||||
|
c = out[0]
|
||||||
|
assert c["name"] == "web"
|
||||||
|
assert c["container_id"] == "abc123def456" # 12-char short id
|
||||||
|
assert c["image"] == "nginx:latest"
|
||||||
|
assert c["status"] == "running"
|
||||||
|
assert c["cpu_pct"] == 40.0
|
||||||
|
assert c["mem_pct"] == 15.0
|
||||||
|
assert c["ports"] == [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
|
||||||
|
assert c["started_at"].startswith("2023-11-14T") # epoch → ISO-8601
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_skips_stats_for_stopped(monkeypatch):
|
||||||
|
container = {"Id": "x" * 16, "Names": ["/db"], "Image": "pg", "State": "exited",
|
||||||
|
"Created": None, "Ports": []}
|
||||||
|
|
||||||
|
def fake_request(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||||
|
if path.startswith("/containers/json"):
|
||||||
|
return [container]
|
||||||
|
raise AssertionError("stats must not be fetched for a stopped container")
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "_docker_request", fake_request)
|
||||||
|
out = a.collect_docker("/var/run/docker.sock")
|
||||||
|
assert out[0]["status"] == "exited"
|
||||||
|
assert out[0]["cpu_pct"] is None and out[0]["started_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_sample wiring ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_build_sample_includes_docker_when_present(monkeypatch):
|
||||||
|
monkeypatch.setattr(a, "collect_docker", lambda _s: [{"name": "web", "status": "running"}])
|
||||||
|
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||||
|
assert sample["docker"] == [{"name": "web", "status": "running"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sample_omits_docker_when_empty(monkeypatch):
|
||||||
|
monkeypatch.setattr(a, "collect_docker", lambda _s: [])
|
||||||
|
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||||
|
assert "docker" not in sample
|
||||||
|
|
||||||
|
|
||||||
|
# ── config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_read_config_defaults_docker_socket(tmp_path):
|
||||||
|
cfg_file = tmp_path / "agent.conf"
|
||||||
|
cfg_file.write_text("url = https://steward.example\ntoken = abc\n")
|
||||||
|
cfg = a.read_config(str(cfg_file))
|
||||||
|
assert cfg["docker_socket"] == a.DEFAULT_DOCKER_SOCKET
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_honours_explicit_docker_socket(tmp_path):
|
||||||
|
cfg_file = tmp_path / "agent.conf"
|
||||||
|
cfg_file.write_text(
|
||||||
|
"url = https://steward.example\ntoken = abc\n"
|
||||||
|
"docker_socket = /run/user/1000/docker.sock\n")
|
||||||
|
cfg = a.read_config(str(cfg_file))
|
||||||
|
assert cfg["docker_socket"] == "/run/user/1000/docker.sock"
|
||||||
Reference in New Issue
Block a user