Compare commits
43 Commits
a840d6f823
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2d994e4c | |||
| 414c8efa98 | |||
| 8f1c8c5cf7 | |||
| 07a841d91e | |||
| a8de3570fe | |||
| 45565b2c01 | |||
| c95194747d | |||
| a9b3b11327 | |||
| e8ac99174a | |||
| 01f5805139 | |||
| 524fd1f509 | |||
| efc11c1837 | |||
| f40063a74d | |||
| 0c5a1573da | |||
| 2df5fc94a3 | |||
| b1aabb7dfa | |||
| 24df8458ea | |||
| 431f804037 | |||
| e289b6c49f | |||
| 0c055ed6fa | |||
| 51682f130a | |||
| 8af297670e | |||
| b0d3e83bdd | |||
| a35c369dd4 | |||
| 28c9a4dd2f | |||
| 10dfd8ffd2 | |||
| 9e4f1983f8 | |||
| a78af23793 | |||
| aff0c36d37 | |||
| 6d08db0d89 | |||
| 3a54d6d71d | |||
| 7d144780df | |||
| 2545e8c6ce | |||
| c8b6719b37 | |||
| 95ebdf7045 | |||
| 0940dc6972 | |||
| 4fc8c96c41 | |||
| 88091936c5 | |||
| 626ba69934 | |||
| 9615f9abcd | |||
| 114262dbf9 | |||
| 3e4e35de96 | |||
| 277eb40165 |
@@ -14,6 +14,17 @@ on:
|
|||||||
# pull_request intentionally absent — push on [dev, main] already fires CI for
|
# pull_request intentionally absent — push on [dev, main] already fires CI for
|
||||||
# every dev commit and dev→main PRs. Single-operator repo, no fork PRs.
|
# every dev commit and dev→main PRs. Single-operator repo, no fork PRs.
|
||||||
|
|
||||||
|
# Serialize CI runs per ref so the publish lane never shares the runner's docker
|
||||||
|
# daemon with another run. Two publishes racing on one daemon evict each other's
|
||||||
|
# freshly-built image mid-push, breaking the post-build tag/push (issue #1093).
|
||||||
|
# cancel-in-progress:false is deliberate — rule 46 requires EVERY dev/main push to
|
||||||
|
# publish its own immutable :<sha> image, so a superseded run must still run to
|
||||||
|
# completion (never cancelled) to emit its SHA. (Forgejo honors `concurrency:` —
|
||||||
|
# CI-runner's build workflows use it.)
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this
|
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this
|
||||||
# runs with NO dependency install and surfaces lint bounces in seconds.
|
# runs with NO dependency install and surfaces lint bounces in seconds.
|
||||||
@@ -115,14 +126,25 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
IMAGE=git.fabledsword.com/bvandeusen/steward
|
IMAGE=git.fabledsword.com/bvandeusen/steward
|
||||||
|
# The moving tag for this ref: dev→:dev, main→:latest (rule 46). main IS
|
||||||
|
# the production line, so :latest tracks main's tip; there is no :main.
|
||||||
|
MOVING=""
|
||||||
|
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||||
|
MOVING="dev"
|
||||||
|
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
|
MOVING="latest"
|
||||||
|
fi
|
||||||
|
# Tag BOTH targets at build time (mirrors CI-runner's build workflows):
|
||||||
|
# one `docker build -t :<sha> -t :<moving>` points both tags at the image
|
||||||
|
# atomically, so we never run a post-push `docker tag` of an image a
|
||||||
|
# concurrent run could have evicted from the shared daemon (issue #1093).
|
||||||
|
if [ -n "$MOVING" ]; then
|
||||||
|
docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:$MOVING" .
|
||||||
|
docker push "$IMAGE:${{ github.sha }}"
|
||||||
|
docker push "$IMAGE:$MOVING"
|
||||||
|
else
|
||||||
docker build -t "$IMAGE:${{ github.sha }}" .
|
docker build -t "$IMAGE:${{ github.sha }}" .
|
||||||
docker push "$IMAGE:${{ github.sha }}"
|
docker push "$IMAGE:${{ github.sha }}"
|
||||||
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
|
||||||
docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:dev"
|
|
||||||
docker push "$IMAGE:dev"
|
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
||||||
docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:latest"
|
|
||||||
docker push "$IMAGE:latest"
|
|
||||||
fi
|
fi
|
||||||
- name: Prune dangling layers
|
- name: Prune dangling layers
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
+4
-2
@@ -19,8 +19,10 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY steward/ steward/
|
COPY steward/ steward/
|
||||||
# .[ansible] pulls the full Ansible package so the playbook runner works in-image.
|
# Bundle the extras whose first-party plugins ship in the image: [ansible] for
|
||||||
RUN pip install --no-cache-dir '.[ansible]'
|
# the playbook runner, [snmp] (pysnmp) for the SNMP poller. Without them those
|
||||||
|
# bundled plugins load but silently no-op for want of their runtime dependency.
|
||||||
|
RUN pip install --no-cache-dir '.[ansible,snmp]'
|
||||||
|
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
# First-party plugins ship inside the image (bundled root at /app/plugins).
|
# First-party plugins ship inside the image (bundled root at /app/plugins).
|
||||||
|
|||||||
+11
-1
@@ -22,12 +22,22 @@ services:
|
|||||||
# /data holds the auto-generated secret key, third-party plugins, and the
|
# /data holds the auto-generated secret key, third-party plugins, and the
|
||||||
# Ansible playbook cache — all of which must survive image updates. No
|
# Ansible playbook cache — all of which must survive image updates. No
|
||||||
# source bind-mounts: core + first-party plugins live inside the image.
|
# source bind-mounts: core + first-party plugins live inside the image.
|
||||||
|
#
|
||||||
|
# The container runs as the unprivileged 'app' user (uid 1000), so /data
|
||||||
|
# MUST be writable by uid 1000. A named docker volume (below) gets that
|
||||||
|
# automatically. If you bind-mount a host/NFS path instead, chown it to
|
||||||
|
# 1000:1000 (and mind NFS root_squash) — otherwise the app can't persist
|
||||||
|
# its secret key, mints a fresh one each boot, and every encrypted secret
|
||||||
|
# (managed SSH key, SMTP/OIDC/LDAP creds) becomes unrecoverable. The app
|
||||||
|
# now refuses to start in that state rather than silently degrade.
|
||||||
- app_data:/data
|
- app_data:/data
|
||||||
environment:
|
environment:
|
||||||
- STEWARD_DATABASE_URL=postgresql+asyncpg://steward:${POSTGRES_PASSWORD:-steward}@db/steward
|
- STEWARD_DATABASE_URL=postgresql+asyncpg://steward:${POSTGRES_PASSWORD:-steward}@db/steward
|
||||||
# STEWARD_SECRET_KEY is intentionally absent — the app generates a random
|
# STEWARD_SECRET_KEY is intentionally absent — the app generates a random
|
||||||
# key on first boot and persists it to /data/secret.key, which the app_data
|
# key on first boot and persists it to /data/secret.key, which the app_data
|
||||||
# volume keeps across image updates.
|
# volume keeps across image updates. On multi-node Swarm with a shared bind
|
||||||
|
# mount, pin STEWARD_SECRET_KEY (env or a docker secret) instead, so the key
|
||||||
|
# never depends on filesystem ownership being correct on every node.
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -74,8 +74,15 @@ token = a1b2c3d4...
|
|||||||
interval_seconds = 30
|
interval_seconds = 30
|
||||||
hostname = myhost # optional; defaults to uname -n
|
hostname = myhost # optional; defaults to uname -n
|
||||||
mounts = /, /mnt/data # optional; defaults to all real mounts (excluding tmpfs/devtmpfs/etc.)
|
mounts = /, /mnt/data # optional; defaults to all real mounts (excluding tmpfs/devtmpfs/etc.)
|
||||||
|
docker_logs_enabled = true # optional; on by default — per-host container-log kill-switch
|
||||||
|
docker_log_exclude = watchtower, noisy-svc # optional; container names whose logs this host skips collecting
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Container-log collection is on by default (no config needed). The two keys above
|
||||||
|
are the per-host opt-outs; the operator-facing global toggle + exclude live in
|
||||||
|
Settings and are enforced server-side (the push model has no channel to command
|
||||||
|
an agent), so they don't require touching a host's conf.
|
||||||
|
|
||||||
### Agent internals (function list, not classes)
|
### Agent internals (function list, not classes)
|
||||||
|
|
||||||
- `read_config(path)` — parses the conf file into a dict.
|
- `read_config(path)` — parses the conf file into a dict.
|
||||||
@@ -85,10 +92,17 @@ mounts = /, /mnt/data # optional; defaults to all real mounts (excludin
|
|||||||
- `collect_load()` — reads `/proc/loadavg`, returns `[1m, 5m, 15m]`.
|
- `collect_load()` — reads `/proc/loadavg`, returns `[1m, 5m, 15m]`.
|
||||||
- `collect_uptime()` — reads `/proc/uptime`, returns seconds since boot (int).
|
- `collect_uptime()` — reads `/proc/uptime`, returns seconds since boot (int).
|
||||||
- `collect_metadata()` — `os.uname()` for kernel + arch, `/etc/os-release` for distro. Called once at startup and cached.
|
- `collect_metadata()` — `os.uname()` for kernel + arch, `/etc/os-release` for distro. Called once at startup and cached.
|
||||||
|
- `collect_docker_logs(socket, containers, state, exclude)` — (m79) per running
|
||||||
|
container, fetches new log lines over the Docker socket with an incremental
|
||||||
|
`since` cursor kept per container in `state` (a container's first interval
|
||||||
|
seeds from a short tail). Demuxes the multiplexed stream, parses each line's
|
||||||
|
RFC3339 timestamp, caps the whole batch at a byte limit (a marker line records
|
||||||
|
a truncation; the deferred lines come next interval). Folded into the sample as
|
||||||
|
`docker_logs`; omitted when empty.
|
||||||
- `build_payload()` — assembles a snapshot from all collectors into one dict.
|
- `build_payload()` — assembles a snapshot from all collectors into one dict.
|
||||||
- `post_payload(url, token, payloads)` — POSTs a list of samples, returns success/failure.
|
- `post_payload(url, token, payloads)` — POSTs a list of samples, returns success/failure.
|
||||||
- `RingBuffer(maxlen=20)` — tiny FIFO wrapper, drops oldest when full.
|
- `RingBuffer(maxlen=20)` — tiny FIFO wrapper, drops oldest when full.
|
||||||
- `main_loop()` — the 30s loop: collect → try POST → on failure push to buffer + backoff → on success flush buffer.
|
- `main_loop()` — the 30s loop: collect → try POST → on failure push to buffer + backoff → on success flush buffer. Container logs are stripped from a sample before it's buffered (metrics survive an outage; stale logs are dropped).
|
||||||
|
|
||||||
**Target: ~300 lines total including docstrings.** More than that is a smell that the agent is over-scoping.
|
**Target: ~300 lines total including docstrings.** More than that is a smell that the agent is over-scoping.
|
||||||
|
|
||||||
@@ -255,6 +269,7 @@ Content-Type: application/json
|
|||||||
- **`metadata` is sent on every POST**, not just on change. Server-side diff detects actual changes and only writes on change. Cost per POST is one dict — negligible. Benefit: server can cleanly detect agent restarts.
|
- **`metadata` is sent on every POST**, not just on change. Server-side diff detects actual changes and only writes on change. Cost per POST is one dict — negligible. Benefit: server can cleanly detect agent restarts.
|
||||||
- **Raw bytes, not percentages, for memory and storage.** Percentages are derived server-side. Changing the "what counts as used" math doesn't require re-releasing the agent.
|
- **Raw bytes, not percentages, for memory and storage.** Percentages are derived server-side. Changing the "what counts as used" math doesn't require re-releasing the agent.
|
||||||
- **CPU is the one exception** — reported as a percentage because it's inherently a derivative (delta over time), not a snapshot. The agent must sample twice to compute it.
|
- **CPU is the one exception** — reported as a percentage because it's inherently a derivative (delta over time), not a snapshot. The agent must sample twice to compute it.
|
||||||
|
- **Container logs (m79) ride in the same push** as `docker_logs`: a list of `{container, stream, ts, line}` records — incremental since the previous interval. This pushes the *same direction* as metrics, so batched log history needs no inbound channel (only sub-second live-follow would). The server ingests them into `docker_logs`, enforces the global toggle + exclude list on ingest, and bounds storage with a per-container size+age ring. `docker_logs` is omitted when there's nothing new.
|
||||||
|
|
||||||
### Server expansion into `PluginMetric` rows
|
### Server expansion into `PluginMetric` rows
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,8 @@ def get_scheduled_tasks() -> list:
|
|||||||
def get_blueprint():
|
def get_blueprint():
|
||||||
from .routes import docker_bp
|
from .routes import docker_bp
|
||||||
return docker_bp
|
return docker_bp
|
||||||
|
|
||||||
|
|
||||||
|
def get_nav() -> list[dict]:
|
||||||
|
# Surfaces in the sidebar's Infrastructure group (fleet-wide container view).
|
||||||
|
return [{"label": "Docker", "href": "/plugins/docker/"}]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Model-free Docker view helpers.
|
||||||
|
|
||||||
|
Kept separate from routes.py/models.py so tests can import it directly: importing
|
||||||
|
a module that re-imports plugins.docker.models AFTER the app has loaded the docker
|
||||||
|
plugin raises "Table 'docker_containers' is already defined". This module touches
|
||||||
|
no ORM models, so it's safe to import in either environment.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def dedup_by_container_id(containers: list) -> list:
|
||||||
|
"""Collapse containers double-reported across hosts. Swarm-aware agents on
|
||||||
|
every manager list cluster-wide tasks, so the same container (identical
|
||||||
|
container_id) arrives once per manager and would otherwise be counted N times.
|
||||||
|
Keep the first occurrence per non-empty container_id — callers order
|
||||||
|
running-first, so a running report wins over a stale stopped one. Rows without
|
||||||
|
a container_id (older agents) can't be matched, so they're kept as-is."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
out = []
|
||||||
|
for c in containers:
|
||||||
|
cid = (c.container_id or "").strip()
|
||||||
|
if cid:
|
||||||
|
if cid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(cid)
|
||||||
|
out.append(c)
|
||||||
|
return out
|
||||||
@@ -92,6 +92,55 @@ def _derive_events(old_state: dict, new_containers: list) -> list:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _log_rows(log_batches, host_id: str):
|
||||||
|
"""Pure: flatten the agent's log batches → docker_logs row kwargs (no DB).
|
||||||
|
|
||||||
|
`log_batches` is a list of (recorded_at, records) where each record is
|
||||||
|
{"container", "stream", "ts", "line"}. A line's own Docker timestamp wins;
|
||||||
|
recorded_at is the fallback when the agent couldn't parse one. Drops the
|
||||||
|
agent's advisory truncation marker (container "_steward" — it signals a
|
||||||
|
deferral, not a real container), malformed records, and lineless records;
|
||||||
|
normalises an unknown stream to stdout. Unit-testable in isolation.
|
||||||
|
"""
|
||||||
|
for recorded_at, records in log_batches:
|
||||||
|
if not isinstance(records, list):
|
||||||
|
continue
|
||||||
|
for rec in records:
|
||||||
|
if not isinstance(rec, dict):
|
||||||
|
continue
|
||||||
|
name = rec.get("container")
|
||||||
|
line = rec.get("line")
|
||||||
|
if not name or name == "_steward" or line is None:
|
||||||
|
continue
|
||||||
|
ts = _parse_started_at(rec.get("ts")) or recorded_at
|
||||||
|
stream = rec.get("stream")
|
||||||
|
if stream not in ("stdout", "stderr"):
|
||||||
|
stream = "stdout"
|
||||||
|
yield {"host_id": host_id, "container_name": str(name)[:255],
|
||||||
|
"ts": ts, "stream": stream, "line": str(line)}
|
||||||
|
|
||||||
|
|
||||||
|
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. 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))
|
||||||
|
|
||||||
|
|
||||||
async def _persist_swarm(session, host, swarm: dict) -> None:
|
async def _persist_swarm(session, host, swarm: dict) -> None:
|
||||||
"""Upsert this manager's swarm topology; drop rows no longer reported.
|
"""Upsert this manager's swarm topology; drop rows no longer reported.
|
||||||
|
|
||||||
@@ -201,7 +250,8 @@ async def _persist_disk(session, host, disk: dict) -> None:
|
|||||||
await session.execute(stale)
|
await session.execute(stale)
|
||||||
|
|
||||||
|
|
||||||
async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -> None:
|
async def persist_host_docker(session, host, snapshots, swarm=None, disk=None,
|
||||||
|
logs=None) -> None:
|
||||||
"""Upsert containers + time-series + lifecycle events + swarm for one host.
|
"""Upsert containers + time-series + lifecycle events + swarm for one host.
|
||||||
|
|
||||||
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
|
`snapshots` is a list of (recorded_at: datetime, containers: list[dict]) —
|
||||||
@@ -211,7 +261,9 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -
|
|||||||
alert pipeline, and lifecycle-event derivation. `swarm` is the newest
|
alert pipeline, and lifecycle-event derivation. `swarm` is the newest
|
||||||
sample's swarm object (or None off managers) — persisted when present.
|
sample's swarm object (or None off managers) — persisted when present.
|
||||||
`disk` is the newest sample's /system/df summary (or None on Docker-less
|
`disk` is the newest sample's /system/df summary (or None on Docker-less
|
||||||
hosts) — persisted when present.
|
hosts) — persisted when present. `logs` is a list of (recorded_at, records)
|
||||||
|
log batches (m79) — appended one row per line when present, independent of
|
||||||
|
whether this sample also carried container metrics.
|
||||||
"""
|
"""
|
||||||
from steward.core.alerts import record_metric
|
from steward.core.alerts import record_metric
|
||||||
from .models import DockerContainer, DockerEvent, DockerMetric
|
from .models import DockerContainer, DockerEvent, DockerMetric
|
||||||
@@ -220,6 +272,8 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -
|
|||||||
await _persist_swarm(session, host, swarm)
|
await _persist_swarm(session, host, swarm)
|
||||||
if disk is not None:
|
if disk is not None:
|
||||||
await _persist_disk(session, host, disk)
|
await _persist_disk(session, host, disk)
|
||||||
|
if logs:
|
||||||
|
await _persist_logs(session, host, logs)
|
||||||
|
|
||||||
if not snapshots:
|
if not snapshots:
|
||||||
return
|
return
|
||||||
@@ -321,3 +375,16 @@ async def persist_host_docker(session, host, snapshots, swarm=None, disk=None) -
|
|||||||
resource_name=resource, metric_name="is_healthy",
|
resource_name=resource, metric_name="is_healthy",
|
||||||
value=1.0 if health == "healthy" else 0.0,
|
value=1.0 if health == "healthy" else 0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Reap containers that vanished from the host's latest listing. Without this,
|
||||||
|
# removed one-shot containers (CI job runners, buildkit builders, codex jobs)
|
||||||
|
# accumulate forever as permanent "stopped" rows and inflate every count.
|
||||||
|
# The listing is authoritative (event derivation already treats absence as
|
||||||
|
# removal), so anything not in the newest snapshot no longer exists on the
|
||||||
|
# host. Mirrors the image/swarm/disk persisters. An empty snapshot legitimately
|
||||||
|
# means the host has no containers, so the unfiltered delete is correct.
|
||||||
|
seen_names = {c["name"] for c in latest_containers if c.get("name")}
|
||||||
|
reap = delete(DockerContainer).where(DockerContainer.host_id == host.id)
|
||||||
|
if seen_names:
|
||||||
|
reap = reap.where(DockerContainer.name.notin_(seen_names))
|
||||||
|
await session.execute(reap)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Widen memory byte columns to BIGINT
|
||||||
|
|
||||||
|
docker_metrics.mem_usage_bytes and docker_containers.mem_usage_bytes /
|
||||||
|
mem_limit_bytes were int4, which overflows for any container using >2.1 GB
|
||||||
|
(asyncpg: "value out of int32 range"), failing the whole ingest batch. Widen to
|
||||||
|
BIGINT — a safe in-place int4→int8 promotion, no data rewrite.
|
||||||
|
|
||||||
|
Revision ID: docker_008_bigint_mem
|
||||||
|
Revises: docker_007_disk_usage
|
||||||
|
Create Date: 2026-06-20
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_008_bigint_mem"
|
||||||
|
down_revision: Union[str, None] = "docker_007_disk_usage"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column("docker_metrics", "mem_usage_bytes", type_=sa.BigInteger())
|
||||||
|
op.alter_column("docker_containers", "mem_usage_bytes", type_=sa.BigInteger())
|
||||||
|
op.alter_column("docker_containers", "mem_limit_bytes", type_=sa.BigInteger())
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Narrowing back to int4 will error if any stored value exceeds 2^31 — that's
|
||||||
|
# the very condition this migration fixed, so downgrade is best-effort.
|
||||||
|
op.alter_column("docker_containers", "mem_limit_bytes", type_=sa.Integer())
|
||||||
|
op.alter_column("docker_containers", "mem_usage_bytes", type_=sa.Integer())
|
||||||
|
op.alter_column("docker_metrics", "mem_usage_bytes", type_=sa.Integer())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Docker container logs table
|
||||||
|
|
||||||
|
Adds docker_logs — one row per container log line, pushed by the host agent and
|
||||||
|
folded into its metrics push. Time-series, host-scoped (container names are only
|
||||||
|
unique within a host). Bounded by a per-container size+age ring in retention, so
|
||||||
|
a chatty container keeps a shorter window rather than growing without limit.
|
||||||
|
Additive create_table + twin indexes (viewer lookup by (host, container, ts);
|
||||||
|
age-cutoff prune by ts).
|
||||||
|
|
||||||
|
Revision ID: docker_009_container_logs
|
||||||
|
Revises: docker_008_bigint_mem
|
||||||
|
Create Date: 2026-07-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "docker_009_container_logs"
|
||||||
|
down_revision: Union[str, None] = "docker_008_bigint_mem"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"docker_logs",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("host_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("container_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("stream", sa.String(length=8), nullable=False, server_default="stdout"),
|
||||||
|
sa.Column("line", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_docker_logs_host_id", "docker_logs", ["host_id"])
|
||||||
|
op.create_index("ix_docker_logs_container_name", "docker_logs", ["container_name"])
|
||||||
|
op.create_index("ix_docker_logs_host_container_time",
|
||||||
|
"docker_logs", ["host_id", "container_name", "ts"])
|
||||||
|
op.create_index("ix_docker_logs_ts", "docker_logs", ["ts"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_docker_logs_ts", table_name="docker_logs")
|
||||||
|
op.drop_index("ix_docker_logs_host_container_time", table_name="docker_logs")
|
||||||
|
op.drop_index("ix_docker_logs_container_name", table_name="docker_logs")
|
||||||
|
op.drop_index("ix_docker_logs_host_id", table_name="docker_logs")
|
||||||
|
op.drop_table("docker_logs")
|
||||||
@@ -29,8 +29,10 @@ class DockerContainer(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
|
||||||
# running | stopped | paused | exited | dead
|
# running | stopped | paused | exited | dead
|
||||||
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
mem_usage_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
# BigInteger: a container's memory usage/limit routinely exceeds 2^31 bytes
|
||||||
mem_limit_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
# (~2.1 GB), which overflows int4 and fails the insert.
|
||||||
|
mem_usage_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
mem_limit_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||||
@@ -78,7 +80,8 @@ 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)
|
# BigInteger: per-container memory usage exceeds 2^31 bytes (~2.1 GB) routinely.
|
||||||
|
mem_usage_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||||
|
|
||||||
# Per-container history lookups filter on (host_id, container_name) then sort
|
# Per-container history lookups filter on (host_id, container_name) then sort
|
||||||
# by time — a composite index keeps the rows() sparkline queries cheap.
|
# by time — a composite index keeps the rows() sparkline queries cheap.
|
||||||
@@ -160,6 +163,43 @@ class DockerEvent(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DockerLog(Base):
|
||||||
|
"""Container log lines pushed by the host agent — one row per line.
|
||||||
|
|
||||||
|
Time-series, scoped to the reporting host (container names are only unique
|
||||||
|
within a host, same identity as docker_metrics). The agent tails each running
|
||||||
|
container incrementally and folds new lines into its metrics push; `ts` is the
|
||||||
|
line's own Docker timestamp. Bounded by a per-container size+age ring
|
||||||
|
(retention), so a chatty container just keeps a shorter window rather than
|
||||||
|
growing without limit.
|
||||||
|
"""
|
||||||
|
__tablename__ = "docker_logs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
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)
|
||||||
|
ts: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
stream: Mapped[str] = mapped_column(String(8), nullable=False, default="stdout")
|
||||||
|
# stdout | stderr
|
||||||
|
line: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
|
||||||
|
# Viewer filters on (host_id, container_name) and sorts by time; the ring
|
||||||
|
# prune walks the same key newest-first. A second index on `ts` alone serves
|
||||||
|
# the age-cutoff delete. Twin-index idiom, mirroring docker_events.
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_docker_logs_host_container_time",
|
||||||
|
"host_id", "container_name", "ts"),
|
||||||
|
Index("ix_docker_logs_ts", "ts"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DockerSwarmService(Base):
|
class DockerSwarmService(Base):
|
||||||
"""A Swarm service as seen by a manager host: desired vs running replicas.
|
"""A Swarm service as seen by a manager host: desired vs running replicas.
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ async def run_docker_retention(
|
|||||||
events_days: int,
|
events_days: int,
|
||||||
metrics_raw_days: int,
|
metrics_raw_days: int,
|
||||||
metrics_rollup_days: int,
|
metrics_rollup_days: int,
|
||||||
|
logs_retention_days: int = 3,
|
||||||
|
logs_max_bytes_per_container: int = 5_000_000,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Roll up + prune Docker time-series. Returns a counts dict for logging.
|
"""Roll up + prune Docker time-series. Returns a counts dict for logging.
|
||||||
@@ -47,18 +49,22 @@ async def run_docker_retention(
|
|||||||
re-run is idempotent, then delete those raw rows.
|
re-run is idempotent, then delete those raw rows.
|
||||||
2. Prune rolled-up rows older than the rollup window.
|
2. Prune rolled-up rows older than the rollup window.
|
||||||
3. Prune docker_events older than the events window.
|
3. Prune docker_events older than the events window.
|
||||||
|
4. Prune docker_logs with a per-container size+age ring (m79): drop lines
|
||||||
|
older than the age window, then keep only the newest ~cap bytes per
|
||||||
|
(host, container).
|
||||||
"""
|
"""
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from .models import DockerEvent, DockerMetric, DockerMetricHourly
|
from .models import DockerEvent, DockerLog, DockerMetric, DockerMetricHourly
|
||||||
|
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
rolled = rolled_rows = events_pruned = rollup_pruned = 0
|
rolled = rolled_rows = events_pruned = rollup_pruned = 0
|
||||||
|
logs_age_pruned = logs_size_pruned = 0
|
||||||
|
|
||||||
# ── 1. Roll up raw metrics older than the raw window into hourly buckets ──
|
# ── 1. Roll up raw metrics older than the raw window into hourly buckets ──
|
||||||
raw_cutoff = _rollup_cutoff(now, metrics_raw_days)
|
raw_cutoff = _rollup_cutoff(now, metrics_raw_days)
|
||||||
@@ -120,9 +126,35 @@ async def run_docker_retention(
|
|||||||
)
|
)
|
||||||
events_pruned = res.rowcount or 0
|
events_pruned = res.rowcount or 0
|
||||||
|
|
||||||
|
# ── 4. Container-log ring: age cutoff, then per-container byte cap (m79) ──
|
||||||
|
logs_cutoff = now - timedelta(days=logs_retention_days)
|
||||||
|
res = await session.execute(
|
||||||
|
delete(DockerLog).where(DockerLog.ts < logs_cutoff)
|
||||||
|
)
|
||||||
|
logs_age_pruned = res.rowcount or 0
|
||||||
|
|
||||||
|
# Size ring: per (host, container), sum line bytes newest-first; delete a row
|
||||||
|
# once its strictly-newer siblings already fill the cap. Using the EXCLUSIVE
|
||||||
|
# prefix (running total minus this row) means the newest row always survives,
|
||||||
|
# so a single line larger than the cap is never wiped out.
|
||||||
|
running = func.sum(func.length(DockerLog.line)).over(
|
||||||
|
partition_by=[DockerLog.host_id, DockerLog.container_name],
|
||||||
|
order_by=[DockerLog.ts.desc(), DockerLog.id.desc()],
|
||||||
|
)
|
||||||
|
prefix_excl = (running - func.length(DockerLog.line)).label("prefix_excl")
|
||||||
|
ranked = select(DockerLog.id, prefix_excl).subquery()
|
||||||
|
over_cap = select(ranked.c.id).where(
|
||||||
|
ranked.c.prefix_excl >= logs_max_bytes_per_container)
|
||||||
|
res = await session.execute(
|
||||||
|
delete(DockerLog).where(DockerLog.id.in_(over_cap))
|
||||||
|
)
|
||||||
|
logs_size_pruned = res.rowcount or 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"buckets_rolled": rolled,
|
"buckets_rolled": rolled,
|
||||||
"raw_rows_rolled": rolled_rows,
|
"raw_rows_rolled": rolled_rows,
|
||||||
"rollup_pruned": rollup_pruned,
|
"rollup_pruned": rollup_pruned,
|
||||||
"events_pruned": events_pruned,
|
"events_pruned": events_pruned,
|
||||||
|
"logs_age_pruned": logs_age_pruned,
|
||||||
|
"logs_size_pruned": logs_size_pruned,
|
||||||
}
|
}
|
||||||
|
|||||||
+467
-25
@@ -1,20 +1,60 @@
|
|||||||
# plugins/docker/routes.py
|
# plugins/docker/routes.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from quart import Blueprint, current_app, render_template, request
|
from quart import (
|
||||||
from sqlalchemy import select
|
Blueprint, current_app, jsonify, redirect, render_template, request,
|
||||||
|
session, url_for,
|
||||||
|
)
|
||||||
|
from sqlalchemy import func, 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.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
from steward.core.time_range import parse_range, DEFAULT_RANGE
|
from steward.core.time_range import parse_range, DEFAULT_RANGE
|
||||||
from .models import DockerContainer, DockerMetric
|
from .dedup import dedup_by_container_id
|
||||||
|
from .swarm_view import build_swarm_services
|
||||||
|
from .models import (
|
||||||
|
DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerLog,
|
||||||
|
DockerMetric, DockerSwarmNode, DockerSwarmService,
|
||||||
|
)
|
||||||
|
|
||||||
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
docker_bp = Blueprint("docker", __name__, template_folder="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _error(status: int, code: str, detail: str | None = None):
|
||||||
|
body: dict = {"ok": False, "error": code}
|
||||||
|
if detail:
|
||||||
|
body["detail"] = detail
|
||||||
|
return jsonify(body), status
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_extra_vars(prune_target: str) -> dict:
|
||||||
|
"""Extra-vars for the bundled docker_prune playbook. "Prune unused images"
|
||||||
|
means ALL unused (docker image prune -a), not just dangling; the full system
|
||||||
|
prune stays conservative (-f, no -a) per the operator's choice (m78). Pure
|
||||||
|
helper so the mapping is unit-tested without the request/DB stack."""
|
||||||
|
extra_vars: dict = {"prune_target": prune_target}
|
||||||
|
if prune_target == "images":
|
||||||
|
extra_vars["prune_all_images"] = True
|
||||||
|
return extra_vars
|
||||||
|
|
||||||
|
|
||||||
|
def _human_bytes(n: int | None) -> str:
|
||||||
|
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
|
||||||
|
if n is None:
|
||||||
|
return "—"
|
||||||
|
size = float(n)
|
||||||
|
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||||
|
if abs(size) < 1024.0 or unit == "TiB":
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(size)} {unit}"
|
||||||
|
return f"{size:.1f} {unit}"
|
||||||
|
size /= 1024.0
|
||||||
|
return f"{size:.1f} TiB"
|
||||||
|
|
||||||
|
|
||||||
def _human_uptime(started_at: datetime | None) -> str | None:
|
def _human_uptime(started_at: datetime | None) -> str | None:
|
||||||
"""Compact 'how long running' string (e.g. '3d 4h', '5h 12m', '8m')."""
|
"""Compact 'how long running' string (e.g. '3d 4h', '5h 12m', '8m')."""
|
||||||
if started_at is None:
|
if started_at is None:
|
||||||
@@ -92,10 +132,19 @@ async def _container_history(db, host_id: str, name: str, since) -> tuple[list,
|
|||||||
async def index():
|
async def index():
|
||||||
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||||
current_range = request.args.get("range", DEFAULT_RANGE)
|
current_range = request.args.get("range", DEFAULT_RANGE)
|
||||||
|
# Show the Swarm link only when a manager has actually reported topology, so
|
||||||
|
# non-swarm installs aren't offered an always-empty page.
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
has_swarm = (await db.execute(
|
||||||
|
select(DockerSwarmService.host_id).limit(1))).first() is not None
|
||||||
|
has_disk = (await db.execute(
|
||||||
|
select(DockerDiskUsage.host_id).limit(1))).first() is not None
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"docker/index.html",
|
"docker/index.html",
|
||||||
poll_interval=poll_interval,
|
poll_interval=poll_interval,
|
||||||
current_range=current_range,
|
current_range=current_range,
|
||||||
|
has_swarm=has_swarm,
|
||||||
|
has_disk=has_disk,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +160,8 @@ async def _host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
|||||||
@docker_bp.get("/rows")
|
@docker_bp.get("/rows")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def rows():
|
async def rows():
|
||||||
"""HTMX fragment: containers grouped by host, with resource sparklines."""
|
"""HTMX fragment: swarm services (collapsed, cluster-complete) on top, then
|
||||||
|
non-swarm 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"))
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
@@ -121,12 +171,24 @@ async def rows():
|
|||||||
DockerContainer.name,
|
DockerContainer.name,
|
||||||
)
|
)
|
||||||
)).scalars())
|
)).scalars())
|
||||||
hosts = await _host_map(db, {c.host_id for c in containers})
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||||
|
hosts = await _host_map(
|
||||||
|
db,
|
||||||
|
{c.host_id for c in containers}
|
||||||
|
| {n.host_id for n in nodes} | {s.host_id for s in services},
|
||||||
|
)
|
||||||
|
|
||||||
# Group by host so each container is clearly attributed to the box it
|
# Swarm tasks collapse into per-service rows (each replica labelled with
|
||||||
# runs on (names are only unique within a host now).
|
# its host, plus "ghost" replicas for tasks on agent-less nodes, drawn
|
||||||
|
# from the managers' placement). Non-swarm containers keep the host view.
|
||||||
|
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||||
|
host_name = {h.id: h.name for h in hosts.values()}
|
||||||
|
swarm_view = build_swarm_services(containers, services, node_hostname, host_name)
|
||||||
|
|
||||||
|
standalone = [c for c in containers if not c.service_name]
|
||||||
groups: dict[str, dict] = {}
|
groups: dict[str, dict] = {}
|
||||||
for c in containers:
|
for c in standalone:
|
||||||
g = groups.get(c.host_id)
|
g = groups.get(c.host_id)
|
||||||
if g is None:
|
if g is None:
|
||||||
host = hosts.get(c.host_id)
|
host = hosts.get(c.host_id)
|
||||||
@@ -149,15 +211,34 @@ async def rows():
|
|||||||
else:
|
else:
|
||||||
g["stopped"] += 1
|
g["stopped"] += 1
|
||||||
|
|
||||||
|
# Sub-group each host's containers by compose project, preserving the
|
||||||
|
# running-first ordering. Containers with no project fall into an unlabelled
|
||||||
|
# bucket rendered flat, so plain hosts look unchanged.
|
||||||
|
for g in groups.values():
|
||||||
|
subs: dict[str, dict] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for item in g["containers"]:
|
||||||
|
c = item["container"]
|
||||||
|
label = c.compose_project or None
|
||||||
|
key = label or ""
|
||||||
|
if key not in subs:
|
||||||
|
subs[key] = {"label": label, "containers": []}
|
||||||
|
order.append(key)
|
||||||
|
subs[key]["containers"].append(item)
|
||||||
|
g["subgroups"] = [subs[k] for k in order]
|
||||||
|
g["grouped"] = any(s["label"] for s in g["subgroups"])
|
||||||
|
|
||||||
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
|
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
|
||||||
running = sum(g["running"] for g in host_groups)
|
swarm_services = swarm_view["services"]
|
||||||
total = len(containers)
|
standalone_running = sum(g["running"] for g in host_groups)
|
||||||
|
ghost_total = sum(r["count"] for s in swarm_services for r in s["replicas"] if r["ghost"])
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"docker/rows.html",
|
"docker/rows.html",
|
||||||
host_groups=host_groups,
|
host_groups=host_groups,
|
||||||
running=running,
|
swarm_services=swarm_services,
|
||||||
stopped=total - running,
|
running=standalone_running + sum(s["running"] for s in swarm_services),
|
||||||
total=total,
|
stopped=len(standalone) - standalone_running,
|
||||||
|
total=len(containers) + ghost_total,
|
||||||
range_key=range_key,
|
range_key=range_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,26 +249,46 @@ async def widget():
|
|||||||
"""HTMX dashboard widget: container status overview, grouped by host."""
|
"""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")
|
||||||
|
since_24h = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
all_containers = list((await db.execute(
|
all_containers = dedup_by_container_id(list((await db.execute(
|
||||||
select(DockerContainer).order_by(
|
select(DockerContainer).order_by(
|
||||||
(DockerContainer.status == "running").desc(),
|
(DockerContainer.status == "running").desc(),
|
||||||
DockerContainer.name,
|
DockerContainer.name,
|
||||||
)
|
)
|
||||||
)).scalars())
|
)).scalars()))
|
||||||
hosts = await _host_map(db, {c.host_id for c in all_containers})
|
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||||
|
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||||
|
# Recent failures are more actionable than a static "stopped" tally. Count
|
||||||
|
# DISTINCT container names so the two managers' duplicate die/oom events
|
||||||
|
# don't double it.
|
||||||
|
failed_24h = (await db.execute(
|
||||||
|
select(func.count(func.distinct(DockerEvent.container_name)))
|
||||||
|
.where(DockerEvent.event.in_(("die", "oom")), DockerEvent.at >= since_24h)
|
||||||
|
)).scalar() or 0
|
||||||
|
hosts = await _host_map(
|
||||||
|
db,
|
||||||
|
{c.host_id for c in all_containers}
|
||||||
|
| {n.host_id for n in nodes} | {s.host_id for s in services},
|
||||||
|
)
|
||||||
|
|
||||||
running = [c for c in all_containers if c.status == "running"]
|
# Swarm tasks collapse into per-service rows (each replica tagged with its
|
||||||
stopped = [c for c in all_containers if c.status != "running"]
|
# host); non-swarm containers stay grouped by host.
|
||||||
display = all_containers if show_stopped else running
|
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||||
|
host_name = {h.id: h.name for h in hosts.values()}
|
||||||
|
swarm_services = build_swarm_services(all_containers, services, node_hostname, host_name)["services"]
|
||||||
|
|
||||||
|
standalone = [c for c in all_containers if not c.service_name]
|
||||||
|
running = [c for c in standalone if c.status == "running"]
|
||||||
|
display = standalone if show_stopped else running
|
||||||
|
|
||||||
# Group for display so multi-host fleets read clearly; single-host stays flat.
|
# Group for display so multi-host fleets read clearly; single-host stays flat.
|
||||||
groups: dict[str, dict] = {}
|
groups: dict[str, dict] = {}
|
||||||
for c in display:
|
for c in display:
|
||||||
g = groups.setdefault(c.host_id, {
|
g = groups.setdefault(c.host_id, {
|
||||||
"host_id": 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,
|
"host_name": host_name.get(c.host_id, c.host_id),
|
||||||
"containers": [],
|
"containers": [],
|
||||||
})
|
})
|
||||||
g["containers"].append(c)
|
g["containers"].append(c)
|
||||||
@@ -197,8 +298,10 @@ async def widget():
|
|||||||
"docker/widget.html",
|
"docker/widget.html",
|
||||||
host_groups=host_groups,
|
host_groups=host_groups,
|
||||||
multi_host=len(host_groups) > 1,
|
multi_host=len(host_groups) > 1,
|
||||||
running_count=len(running),
|
swarm_services=swarm_services,
|
||||||
stopped_count=len(stopped),
|
running_count=len(running) + sum(s["running"] for s in swarm_services),
|
||||||
|
failed_24h=failed_24h,
|
||||||
|
total_count=len(all_containers) + len(swarm_services),
|
||||||
show_stopped=show_stopped,
|
show_stopped=show_stopped,
|
||||||
widget_id=widget_id,
|
widget_id=widget_id,
|
||||||
)
|
)
|
||||||
@@ -212,11 +315,13 @@ async def widget_resources():
|
|||||||
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:
|
||||||
containers = list((await db.execute(
|
# Dedup before the limit, else swarm tasks reported by both managers can
|
||||||
|
# fill the list with duplicates of the same container.
|
||||||
|
containers = dedup_by_container_id(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]
|
)).scalars()))[:limit]
|
||||||
hosts = await _host_map(db, {c.host_id for c in containers})
|
hosts = await _host_map(db, {c.host_id for c in containers})
|
||||||
|
|
||||||
rows_data = [
|
rows_data = [
|
||||||
@@ -258,3 +363,340 @@ async def host_panel(host_id: str):
|
|||||||
running=running,
|
running=running,
|
||||||
stopped=len(containers) - running,
|
stopped=len(containers) - running,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Glyph + colour per lifecycle event, so the timeline reads at a glance.
|
||||||
|
_EVENT_STYLE = {
|
||||||
|
"start": ("▲", "var(--green)"),
|
||||||
|
"stop": ("■", "var(--text-muted)"),
|
||||||
|
"die": ("✕", "var(--red)"),
|
||||||
|
"oom": ("☠", "var(--red)"),
|
||||||
|
"health_change": ("◐", "var(--orange)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/container/<host_id>/<name>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def container_detail(host_id: str, name: str):
|
||||||
|
"""Full detail page for one container: facts, resource history, lifecycle."""
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
c = await db.get(DockerContainer, (host_id, name))
|
||||||
|
host = await db.get(Host, host_id)
|
||||||
|
events = []
|
||||||
|
if c is not None:
|
||||||
|
events = list((await db.execute(
|
||||||
|
select(DockerEvent)
|
||||||
|
.where(DockerEvent.host_id == host_id)
|
||||||
|
.where(DockerEvent.container_name == name)
|
||||||
|
.order_by(DockerEvent.at.desc())
|
||||||
|
.limit(50)
|
||||||
|
)).scalars())
|
||||||
|
|
||||||
|
if c is None:
|
||||||
|
return await render_template(
|
||||||
|
"docker/container_detail.html",
|
||||||
|
container=None, host_id=host_id, name=name,
|
||||||
|
), 404
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"docker/container_detail.html",
|
||||||
|
container=c,
|
||||||
|
host=host,
|
||||||
|
host_id=host_id,
|
||||||
|
name=name,
|
||||||
|
ports=json.loads(c.ports_json) if c.ports_json else [],
|
||||||
|
uptime=_human_uptime(c.started_at) if c.status == "running" else None,
|
||||||
|
net_rx=_human_bytes(c.net_rx_bytes), net_tx=_human_bytes(c.net_tx_bytes),
|
||||||
|
blk_read=_human_bytes(c.blk_read_bytes), blk_write=_human_bytes(c.blk_write_bytes),
|
||||||
|
events=[
|
||||||
|
{"event": e.event, "detail": e.detail, "at": e.at,
|
||||||
|
"glyph": _EVENT_STYLE.get(e.event, ("•", "var(--text-muted)"))[0],
|
||||||
|
"colour": _EVENT_STYLE.get(e.event, ("•", "var(--text-muted)"))[1]}
|
||||||
|
for e in events
|
||||||
|
],
|
||||||
|
current_range=request.args.get("range", DEFAULT_RANGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/swarm")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def swarm():
|
||||||
|
"""Swarm topology page: services with replica health, nodes, placement.
|
||||||
|
|
||||||
|
Swarm services/nodes are cluster-global — every manager's API returns the
|
||||||
|
same list — but each manager reports them independently (rows keyed by
|
||||||
|
host_id). So we group the reporting managers into swarms (managers that share
|
||||||
|
any node_id are the same cluster) and dedup within each: one row per service
|
||||||
|
(by name) and per node (by node_id), keeping the freshest. Without this, two
|
||||||
|
managers in one cluster list every service/node twice.
|
||||||
|
"""
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
services = list((await db.execute(
|
||||||
|
select(DockerSwarmService).order_by(DockerSwarmService.service_name)
|
||||||
|
)).scalars())
|
||||||
|
nodes = list((await db.execute(
|
||||||
|
select(DockerSwarmNode).order_by(
|
||||||
|
DockerSwarmNode.role, DockerSwarmNode.hostname)
|
||||||
|
)).scalars())
|
||||||
|
hosts = await _host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes})
|
||||||
|
|
||||||
|
# ── Group reporting managers into swarms by shared node membership ──────────
|
||||||
|
manager_ids = {s.host_id for s in services} | {n.host_id for n in nodes}
|
||||||
|
nodes_by_mgr: dict[str, set[str]] = {}
|
||||||
|
for n in nodes:
|
||||||
|
nodes_by_mgr.setdefault(n.host_id, set()).add(n.node_id)
|
||||||
|
|
||||||
|
parent = {m: m for m in manager_ids}
|
||||||
|
|
||||||
|
def _find(x: str) -> str:
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
mgrs = list(manager_ids)
|
||||||
|
for i in range(len(mgrs)):
|
||||||
|
for j in range(i + 1, len(mgrs)):
|
||||||
|
a, b = mgrs[i], mgrs[j]
|
||||||
|
if nodes_by_mgr.get(a) and nodes_by_mgr.get(b) and (nodes_by_mgr[a] & nodes_by_mgr[b]):
|
||||||
|
parent[_find(a)] = _find(b)
|
||||||
|
|
||||||
|
swarm_members: dict[str, set[str]] = {}
|
||||||
|
for m in manager_ids:
|
||||||
|
swarm_members.setdefault(_find(m), set()).add(m)
|
||||||
|
|
||||||
|
# ── Dedup services (by name) + nodes (by node_id) within each swarm ─────────
|
||||||
|
swarms = []
|
||||||
|
for members in swarm_members.values():
|
||||||
|
svc_by_name: dict[str, DockerSwarmService] = {}
|
||||||
|
for s in services:
|
||||||
|
if s.host_id in members and (
|
||||||
|
s.service_name not in svc_by_name
|
||||||
|
or s.updated_at > svc_by_name[s.service_name].updated_at
|
||||||
|
):
|
||||||
|
svc_by_name[s.service_name] = s
|
||||||
|
node_by_id: dict[str, DockerSwarmNode] = {}
|
||||||
|
for n in nodes:
|
||||||
|
if n.host_id in members and (
|
||||||
|
n.node_id not in node_by_id
|
||||||
|
or n.updated_at > node_by_id[n.node_id].updated_at
|
||||||
|
):
|
||||||
|
node_by_id[n.node_id] = n
|
||||||
|
|
||||||
|
node_names = {nid: (nrow.hostname or nid) for nid, nrow in node_by_id.items()}
|
||||||
|
svc_dicts = []
|
||||||
|
for s in sorted(svc_by_name.values(), key=lambda s: s.service_name):
|
||||||
|
placement = []
|
||||||
|
for p in (json.loads(s.placement_json) if s.placement_json else []):
|
||||||
|
nid = p.get("node_id", "")
|
||||||
|
placement.append({
|
||||||
|
"node": node_names.get(nid, nid[:12] or "?"),
|
||||||
|
"running": p.get("running", 0),
|
||||||
|
})
|
||||||
|
svc_dicts.append({
|
||||||
|
"name": s.service_name, "mode": s.mode,
|
||||||
|
"running": s.running, "desired": s.desired, "image": s.image,
|
||||||
|
"healthy": s.running >= s.desired and s.desired > 0,
|
||||||
|
"degraded": 0 < s.running < s.desired,
|
||||||
|
"down": s.running == 0 and s.desired > 0,
|
||||||
|
"placement": placement,
|
||||||
|
})
|
||||||
|
node_rows = sorted(
|
||||||
|
node_by_id.values(), key=lambda n: (n.role, (n.hostname or n.node_id).lower())
|
||||||
|
)
|
||||||
|
manager_names = sorted(hosts[m].name if m in hosts else m for m in members)
|
||||||
|
swarms.append({
|
||||||
|
"managers": manager_names,
|
||||||
|
"services": svc_dicts,
|
||||||
|
"nodes": node_rows,
|
||||||
|
})
|
||||||
|
|
||||||
|
swarms.sort(key=lambda g: g["managers"][0].lower() if g["managers"] else "")
|
||||||
|
return await render_template("docker/swarm.html", swarms=swarms)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/disk")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def disk():
|
||||||
|
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
|
||||||
|
|
||||||
|
Admins can reclaim space per host via the prune buttons, which fire the
|
||||||
|
audited bundled prune playbook through Ansible (m78) — the collection agent
|
||||||
|
itself stays read-only.
|
||||||
|
"""
|
||||||
|
from steward.core.capabilities import has_capability
|
||||||
|
from steward.models.ansible_inventory import AnsibleTarget
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||||
|
images = list((await db.execute(
|
||||||
|
select(DockerImage).order_by(DockerImage.size.desc())
|
||||||
|
)).scalars())
|
||||||
|
# Stopped-container count per host, surfaced alongside reclaimable space.
|
||||||
|
stopped_rows = (await db.execute(
|
||||||
|
select(DockerContainer.host_id)
|
||||||
|
.where(DockerContainer.status != "running")
|
||||||
|
)).scalars()
|
||||||
|
stopped_by_host: dict[str, int] = {}
|
||||||
|
for hid in stopped_rows:
|
||||||
|
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
|
||||||
|
hosts = await _host_map(db, {s.host_id for s in summaries})
|
||||||
|
# Which hosts have a linked Ansible target — gates the prune buttons
|
||||||
|
# (a target is required to route the playbook run to that host).
|
||||||
|
host_ids = {s.host_id for s in summaries}
|
||||||
|
target_by_host: dict[str, str] = {}
|
||||||
|
if host_ids:
|
||||||
|
for hid, tid in (await db.execute(
|
||||||
|
select(AnsibleTarget.host_id, AnsibleTarget.id)
|
||||||
|
.where(AnsibleTarget.host_id.in_(host_ids))
|
||||||
|
)).all():
|
||||||
|
target_by_host[hid] = tid
|
||||||
|
|
||||||
|
images_by_host: dict[str, list] = {}
|
||||||
|
for im in images:
|
||||||
|
images_by_host.setdefault(im.host_id, []).append({
|
||||||
|
"repo_tag": im.repo_tag, "size": _human_bytes(im.size),
|
||||||
|
"shared": _human_bytes(im.shared_size), "containers": im.containers,
|
||||||
|
"reclaimable": im.containers == 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
host_groups = [
|
||||||
|
{
|
||||||
|
"host_id": s.host_id,
|
||||||
|
"host_name": hosts[s.host_id].name if s.host_id in hosts else s.host_id,
|
||||||
|
"summary": {
|
||||||
|
"images_total": s.images_total, "images_active": s.images_active,
|
||||||
|
"images_size": _human_bytes(s.images_size),
|
||||||
|
"images_reclaimable": _human_bytes(s.images_reclaimable),
|
||||||
|
"layers_size": _human_bytes(s.layers_size),
|
||||||
|
"containers_count": s.containers_count,
|
||||||
|
"containers_size": _human_bytes(s.containers_size),
|
||||||
|
"volumes_count": s.volumes_count,
|
||||||
|
"volumes_size": _human_bytes(s.volumes_size),
|
||||||
|
"build_cache_size": _human_bytes(s.build_cache_size),
|
||||||
|
},
|
||||||
|
"stopped": stopped_by_host.get(s.host_id, 0),
|
||||||
|
"images": images_by_host.get(s.host_id, []),
|
||||||
|
"has_target": s.host_id in target_by_host,
|
||||||
|
}
|
||||||
|
for s in summaries
|
||||||
|
]
|
||||||
|
host_groups.sort(key=lambda g: g["host_name"].lower())
|
||||||
|
return await render_template(
|
||||||
|
"docker/disk.html", host_groups=host_groups,
|
||||||
|
ansible_available=has_capability("ansible.run_playbook"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.post("/disk/<host_id>/prune")
|
||||||
|
@require_role(UserRole.admin)
|
||||||
|
async def disk_prune(host_id: str):
|
||||||
|
"""Reclaim Docker disk on a host by firing the bundled prune playbook via
|
||||||
|
Ansible (audited, admin-gated) — the collection agent stays read-only (m78).
|
||||||
|
`target` selects the scope: containers | images | system.
|
||||||
|
"""
|
||||||
|
from steward.core.capabilities import has_capability, invoke_capability
|
||||||
|
from steward.ansible.sources import BUILTIN_SOURCE_NAME
|
||||||
|
from steward.models.ansible_inventory import AnsibleTarget
|
||||||
|
|
||||||
|
if not has_capability("ansible.run_playbook"):
|
||||||
|
return _error(400, "ansible_unavailable", "Ansible is not available")
|
||||||
|
|
||||||
|
form = await request.form
|
||||||
|
prune_target = (form.get("target", "") or "").strip()
|
||||||
|
if prune_target not in ("containers", "images", "system"):
|
||||||
|
return _error(400, "bad_target", "Unknown prune target")
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
target = (await db.execute(
|
||||||
|
select(AnsibleTarget).where(AnsibleTarget.host_id == host_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if target is None:
|
||||||
|
return _error(400, "no_target",
|
||||||
|
"Link an Ansible target to this host before pruning")
|
||||||
|
|
||||||
|
# extra_vars_map outranks the playbook's own `vars:` defaults.
|
||||||
|
extra_vars = _prune_extra_vars(prune_target)
|
||||||
|
|
||||||
|
actor_role = UserRole(session.get("user_role", "viewer"))
|
||||||
|
run, _source, err = await invoke_capability(
|
||||||
|
"ansible.run_playbook", actor_role,
|
||||||
|
current_app._get_current_object(), # type: ignore[attr-defined]
|
||||||
|
source_name=BUILTIN_SOURCE_NAME,
|
||||||
|
playbook_path="maintenance/docker_prune.yml",
|
||||||
|
inventory_scope=f"steward:target:{target.id}",
|
||||||
|
params={"extra_vars_map": extra_vars},
|
||||||
|
triggered_by=session.get("user_id"),
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return _error(400, "prune_failed", err)
|
||||||
|
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
||||||
|
|
||||||
|
|
||||||
|
@docker_bp.get("/container/<host_id>/<name>/history")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def container_history(host_id: str, name: str):
|
||||||
|
"""HTMX fragment: CPU + memory sparklines for the selected time range."""
|
||||||
|
since, range_key = parse_range(request.args.get("range"))
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
cpu_hist, mem_hist = await _container_history(db, host_id, name, since)
|
||||||
|
return await render_template(
|
||||||
|
"docker/_container_history.html",
|
||||||
|
sparkline_cpu=_sparkline(cpu_hist, width=320, height=48),
|
||||||
|
sparkline_mem=_sparkline(mem_hist, width=320, height=48),
|
||||||
|
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/<host_id>/<name>/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/<host_id>/<name>/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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Model-free builder for the swarm-aware container view.
|
||||||
|
|
||||||
|
Merges two sources into one service-grouped, cluster-complete picture:
|
||||||
|
• docker_containers — real container rows, collected LOCAL-per-node, so each
|
||||||
|
carries the host (= node) it runs on plus its swarm service/node labels.
|
||||||
|
• DockerSwarmService.placement_json — the managers' cluster-wide task→node
|
||||||
|
counts, which include tasks on nodes that run no Steward agent (and so have
|
||||||
|
no container row of their own).
|
||||||
|
|
||||||
|
For every swarm service we list the real replicas we have detail for, then add
|
||||||
|
"ghost" replicas for the remaining placement count on each node (agent-less
|
||||||
|
nodes). Non-swarm containers pass through grouped by host, unchanged.
|
||||||
|
|
||||||
|
Kept model-free (no ORM imports) so it's unit-testable and safe to import in
|
||||||
|
tests without the plugin-loader "table already defined" gotcha.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def _service_state(running: int, desired: int) -> str:
|
||||||
|
if desired > 0 and running >= desired:
|
||||||
|
return "healthy"
|
||||||
|
if running > 0:
|
||||||
|
return "degraded"
|
||||||
|
return "down"
|
||||||
|
|
||||||
|
|
||||||
|
def _placement(service) -> list[dict]:
|
||||||
|
raw = getattr(service, "placement_json", None) or "[]"
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
return [p for p in data if isinstance(p, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_swarm_services(containers, services, node_hostname: dict, host_name: dict) -> dict:
|
||||||
|
"""Return {"services": [...], "standalone": [...]}.
|
||||||
|
|
||||||
|
services[i] = {name, mode, desired, running, state, replicas:[...]}
|
||||||
|
real replica = {ghost:False, host, host_id, name, status, cpu_pct, mem_pct,
|
||||||
|
health, restart_count}
|
||||||
|
ghost replica = {ghost:True, host, count} # placement with no local row
|
||||||
|
standalone[i] = {host_id, host, containers:[...]} # non-swarm, by host
|
||||||
|
"""
|
||||||
|
containers = list(containers)
|
||||||
|
swarm_cs = [c for c in containers if getattr(c, "service_name", None)]
|
||||||
|
standalone_cs = [c for c in containers if not getattr(c, "service_name", None)]
|
||||||
|
|
||||||
|
# Dedup service rows by name (every manager reports the same cluster-global
|
||||||
|
# set); keep the first — placement is identical across managers.
|
||||||
|
svc_by_name: dict[str, object] = {}
|
||||||
|
for s in services:
|
||||||
|
name = getattr(s, "service_name", None)
|
||||||
|
if name and name not in svc_by_name:
|
||||||
|
svc_by_name[name] = s
|
||||||
|
|
||||||
|
# Real containers grouped by service name.
|
||||||
|
cs_by_service: dict[str, list] = {}
|
||||||
|
for c in swarm_cs:
|
||||||
|
cs_by_service.setdefault(c.service_name, []).append(c)
|
||||||
|
|
||||||
|
def _host_of(c):
|
||||||
|
return (host_name.get(getattr(c, "host_id", None))
|
||||||
|
or node_hostname.get(getattr(c, "node_id", None))
|
||||||
|
or getattr(c, "host_id", None) or "?")
|
||||||
|
|
||||||
|
out_services = []
|
||||||
|
for name in sorted(set(svc_by_name) | set(cs_by_service)):
|
||||||
|
svc = svc_by_name.get(name)
|
||||||
|
reals = cs_by_service.get(name, [])
|
||||||
|
|
||||||
|
replicas = []
|
||||||
|
for c in sorted(reals, key=lambda c: (_host_of(c).lower(), c.name)):
|
||||||
|
replicas.append({
|
||||||
|
"ghost": False,
|
||||||
|
"host": _host_of(c),
|
||||||
|
"host_id": getattr(c, "host_id", None),
|
||||||
|
"name": c.name,
|
||||||
|
"status": getattr(c, "status", "unknown"),
|
||||||
|
"cpu_pct": getattr(c, "cpu_pct", None),
|
||||||
|
"mem_pct": getattr(c, "mem_pct", None),
|
||||||
|
"health": getattr(c, "health", None),
|
||||||
|
"restart_count": getattr(c, "restart_count", 0) or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Real running replicas per node, to subtract from placement.
|
||||||
|
real_running_by_node: dict[str, int] = {}
|
||||||
|
for c in reals:
|
||||||
|
if (getattr(c, "status", "") or "").lower() == "running":
|
||||||
|
nid = getattr(c, "node_id", None)
|
||||||
|
if nid:
|
||||||
|
real_running_by_node[nid] = real_running_by_node.get(nid, 0) + 1
|
||||||
|
|
||||||
|
ghosts = []
|
||||||
|
for p in _placement(svc) if svc is not None else []:
|
||||||
|
nid = p.get("node_id", "")
|
||||||
|
placed = int(p.get("running", 0) or 0)
|
||||||
|
ghost = placed - real_running_by_node.get(nid, 0)
|
||||||
|
if ghost > 0:
|
||||||
|
ghosts.append({
|
||||||
|
"ghost": True,
|
||||||
|
"host": node_hostname.get(nid) or (nid[:12] if nid else "?"),
|
||||||
|
"count": ghost,
|
||||||
|
})
|
||||||
|
ghosts.sort(key=lambda g: g["host"].lower())
|
||||||
|
replicas.extend(ghosts)
|
||||||
|
|
||||||
|
running = getattr(svc, "running", None) if svc is not None else None
|
||||||
|
desired = getattr(svc, "desired", None) if svc is not None else None
|
||||||
|
# No service row (manager didn't report it): infer from what we can see.
|
||||||
|
if running is None:
|
||||||
|
running = sum(1 for r in replicas
|
||||||
|
if (not r["ghost"] and (r["status"] or "").lower() == "running"))
|
||||||
|
if desired is None:
|
||||||
|
desired = len(replicas)
|
||||||
|
|
||||||
|
out_services.append({
|
||||||
|
"name": name,
|
||||||
|
"mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated",
|
||||||
|
"running": running,
|
||||||
|
"desired": desired,
|
||||||
|
"state": _service_state(int(running or 0), int(desired or 0)),
|
||||||
|
"replicas": replicas,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Non-swarm containers grouped by host, running-first order preserved.
|
||||||
|
groups: dict[str, dict] = {}
|
||||||
|
for c in standalone_cs:
|
||||||
|
hid = getattr(c, "host_id", None)
|
||||||
|
g = groups.get(hid)
|
||||||
|
if g is None:
|
||||||
|
g = groups[hid] = {"host_id": hid, "host": host_name.get(hid) or hid or "?",
|
||||||
|
"containers": []}
|
||||||
|
g["containers"].append(c)
|
||||||
|
standalone = sorted(groups.values(), key=lambda g: g["host"].lower())
|
||||||
|
|
||||||
|
return {"services": out_services, "standalone": standalone}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{# docker/_container_history.html — CPU/mem sparklines for the selected range #}
|
||||||
|
{% if have_data %}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem;">
|
||||||
|
<div>
|
||||||
|
<div class="section-title" style="margin-bottom:0.4rem;">CPU %</div>
|
||||||
|
{{ sparkline_cpu | safe }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="section-title" style="margin-bottom:0.4rem;">Memory %</div>
|
||||||
|
{{ sparkline_mem | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="color:var(--text-muted);font-size:0.85rem;">
|
||||||
|
Not enough samples in this range yet — history fills in as the agent reports.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -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 %}
|
||||||
|
<div style="display:flex;gap:0.6rem;white-space:pre-wrap;word-break:break-word;">
|
||||||
|
<span style="color:var(--text-dim);flex-shrink:0;" title="{{ l.ts }}">{{ l.ts.strftime("%m-%d %H:%M:%S") }}</span>
|
||||||
|
<span style="flex-shrink:0;width:3.2rem;color:{% if l.stream == 'stderr' %}var(--red){% else %}var(--text-muted){% endif %};">{{ l.stream }}</span>
|
||||||
|
<span style="flex:1;min-width:0;{% if l.stream == 'stderr' %}color:var(--text);{% endif %}">{{ l.line }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div style="color:var(--text-muted);padding:1rem 0.25rem;">
|
||||||
|
{% if filtered %}
|
||||||
|
No lines match the current filter.
|
||||||
|
{% else %}
|
||||||
|
No logs collected yet for <code>{{ name }}</code>. 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
|
||||||
|
<a href="/settings/thresholds/">Settings → Thresholds & Retention</a>.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{# docker/container_detail.html — full detail page for one container #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs %}
|
||||||
|
{% block title %}{{ name }} — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), (name, "")]) }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if container is none %}
|
||||||
|
<div class="card" style="text-align:center;padding:3rem;">
|
||||||
|
<div style="font-weight:600;margin-bottom:0.4rem;">Container not found</div>
|
||||||
|
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||||
|
No container named <code>{{ name }}</code> is currently reported for this host.
|
||||||
|
It may have been removed, or the host agent hasn't reported recently.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ── Header ──────────────────────────────────────────────────────────────── #}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.35rem;flex-wrap:wrap;">
|
||||||
|
<span class="dot {% if container.status == 'running' %}dot-up{% elif container.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||||
|
<h1 class="page-title" style="margin-bottom:0;">{{ container.name }}</h1>
|
||||||
|
{% if container.health == 'healthy' %}<span style="font-size:0.72rem;color:var(--green);border:1px solid var(--green);border-radius:3px;padding:0.05rem 0.4rem;">healthy</span>
|
||||||
|
{% elif container.health == 'unhealthy' %}<span style="font-size:0.72rem;color:var(--red);border:1px solid var(--red);border-radius:3px;padding:0.05rem 0.4rem;">unhealthy</span>
|
||||||
|
{% elif container.health == 'starting' %}<span style="font-size:0.72rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0.05rem 0.4rem;">starting</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||||
|
{{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %}
|
||||||
|
{% if host %} · on <a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %}
|
||||||
|
· <a href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">View logs</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Facts grid ──────────────────────────────────────────────────────────── #}
|
||||||
|
{% macro fact(label, value, colour="") %}
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.3rem;">{{ label }}</div>
|
||||||
|
<div style="font-size:0.95rem;{% if colour %}color:{{ colour }};{% endif %}font-family:ui-monospace,monospace;">{{ value }}</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||||
|
{{ fact("CPU", "%.1f%%" | format(container.cpu_pct) if container.cpu_pct is not none else "—") }}
|
||||||
|
{{ fact("Memory", "%.1f%%" | format(container.mem_pct) if container.mem_pct is not none else "—") }}
|
||||||
|
{{ fact("Restarts", container.restart_count, "var(--orange)" if container.restart_count else "") }}
|
||||||
|
{{ fact("Last exit", (container.exit_code ~ (" (OOM)" if container.oom_killed else "")) if container.exit_code is not none else "—",
|
||||||
|
"var(--red)" if (container.exit_code is not none and container.exit_code != 0) else "") }}
|
||||||
|
{{ fact("Net in", net_rx) }}
|
||||||
|
{{ fact("Net out", net_tx) }}
|
||||||
|
{{ fact("Block read", blk_read) }}
|
||||||
|
{{ fact("Block write", blk_write) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Image / placement ───────────────────────────────────────────────────── #}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem;">
|
||||||
|
<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 1.25rem;font-size:0.86rem;">
|
||||||
|
<span style="color:var(--text-muted);">Image</span>
|
||||||
|
<span style="font-family:ui-monospace,monospace;word-break:break-all;">{{ container.image or "—" }}</span>
|
||||||
|
{% if container.compose_project %}
|
||||||
|
<span style="color:var(--text-muted);">Compose project</span><span>{{ container.compose_project }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if container.service_name %}
|
||||||
|
<span style="color:var(--text-muted);">Swarm service</span><span>{{ container.service_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if container.node_id %}
|
||||||
|
<span style="color:var(--text-muted);">Node</span><span style="font-family:ui-monospace,monospace;">{{ container.node_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if ports %}
|
||||||
|
<span style="color:var(--text-muted);">Ports</span>
|
||||||
|
<span style="font-family:ui-monospace,monospace;">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Resource history (range-toggled) ────────────────────────────────────── #}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||||
|
<h3 class="section-title" style="margin-bottom:0;">Resource history</h3>
|
||||||
|
{% include "_time_range.html" %}
|
||||||
|
</div>
|
||||||
|
<div id="container-history"
|
||||||
|
hx-get="/plugins/docker/container/{{ host_id }}/{{ name }}/history"
|
||||||
|
hx-trigger="load, rangeChange from:body"
|
||||||
|
hx-include="#time-range"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div style="color:var(--text-muted);font-size:0.85rem;">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Lifecycle timeline ──────────────────────────────────────────────────── #}
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="section-title" style="margin-bottom:0.75rem;">Lifecycle</h3>
|
||||||
|
{% if events %}
|
||||||
|
<div style="display:grid;gap:0.5rem;">
|
||||||
|
{% for e in events %}
|
||||||
|
<div style="display:flex;align-items:baseline;gap:0.6rem;font-size:0.85rem;">
|
||||||
|
<span style="color:{{ e.colour }};width:1rem;text-align:center;flex-shrink:0;">{{ e.glyph }}</span>
|
||||||
|
<span style="font-weight:500;width:6.5rem;flex-shrink:0;">{{ e.event }}</span>
|
||||||
|
<span style="color:var(--text-muted);flex:1;min-width:0;">{{ e.detail or "" }}</span>
|
||||||
|
<span style="color:var(--text-dim);font-size:0.78rem;white-space:nowrap;" title="{{ e.at }}">{{ e.at.strftime("%Y-%m-%d %H:%M") }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="color:var(--text-muted);font-size:0.85rem;">
|
||||||
|
No lifecycle events recorded yet. Start/stop/health changes appear here as the
|
||||||
|
agent reports them over time.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.35rem;flex-wrap:wrap;">
|
||||||
|
<h1 class="page-title" style="margin-bottom:0;">{{ name }}</h1>
|
||||||
|
<span style="font-size:0.9rem;color:var(--text-muted);">logs</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||||
|
Recent lines (newest first) collected by the host agent{% if host %} on
|
||||||
|
<a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %} —
|
||||||
|
updated every few seconds.
|
||||||
|
<a href="/plugins/docker/container/{{ host_id }}/{{ name }}">← back to container</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Controls: stream filter + text search (drive the fragment via HTMX) ───── #}
|
||||||
|
<form id="log-controls" onsubmit="return false;"
|
||||||
|
style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.6rem;">
|
||||||
|
<label style="font-size:0.8rem;color:var(--text-muted);display:flex;align-items:center;gap:0.35rem;">
|
||||||
|
Stream
|
||||||
|
<select name="stream" style="padding:0.25rem 0.4rem;">
|
||||||
|
<option value="all">all</option>
|
||||||
|
<option value="stdout">stdout</option>
|
||||||
|
<option value="stderr">stderr</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<input type="search" name="q" placeholder="Filter lines…" autocomplete="off"
|
||||||
|
aria-label="Filter log lines"
|
||||||
|
style="flex:1;min-width:180px;padding:0.3rem 0.5rem;">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-flush">
|
||||||
|
<div id="log-lines"
|
||||||
|
hx-get="/plugins/docker/container/{{ host_id }}/{{ name }}/logs/lines"
|
||||||
|
hx-trigger="load, every 5s, change from:#log-controls, keyup changed delay:400ms from:#log-controls"
|
||||||
|
hx-include="#log-controls"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
role="log" aria-live="polite" tabindex="0"
|
||||||
|
style="max-height:70vh;overflow:auto;padding:0.5rem 0.75rem;
|
||||||
|
font-family:ui-monospace,monospace;font-size:0.8rem;line-height:1.5;">
|
||||||
|
<div style="color:var(--text-muted);">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{# docker/disk.html — image/disk usage: reclaimable space, per-image sizes #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs %}
|
||||||
|
{% block title %}Disk — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Image & disk usage", "")]) }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1 class="page-title" style="margin-bottom:0.4rem;">Image & disk usage</h1>
|
||||||
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||||
|
Reclaimable = space held by images no container references. Admins can prune
|
||||||
|
per host below; each action runs an audited Ansible playbook on that host.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if host_groups %}
|
||||||
|
{% for g in host_groups %}
|
||||||
|
<div style="margin-bottom:2rem;">
|
||||||
|
<div style="font-weight:600;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}</div>
|
||||||
|
|
||||||
|
{# ── Summary stats ────────────────────────────────────────────────────── #}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.3rem;">Reclaimable</div>
|
||||||
|
<span class="stat-val" style="color:{% if g.summary.images_reclaimable != '0 B' %}var(--orange){% else %}var(--green){% endif %};">{{ g.summary.images_reclaimable }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.3rem;">Images</div>
|
||||||
|
<span class="stat-val">{{ g.summary.images_size }}</span>
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.images_active }}/{{ g.summary.images_total }} in use</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.3rem;">Stopped</div>
|
||||||
|
<span class="stat-val" style="{% if g.stopped %}color:var(--text-muted){% endif %};">{{ g.stopped }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.3rem;">Writable layers</div>
|
||||||
|
<span class="stat-val">{{ g.summary.containers_size }}</span>
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.containers_count }} containers</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.3rem;">Volumes</div>
|
||||||
|
<span class="stat-val">{{ g.summary.volumes_size }}</span>
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.volumes_count }} volumes</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.3rem;">Build cache</div>
|
||||||
|
<span class="stat-val">{{ g.summary.build_cache_size }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Cleanup actions (admin only; audited Ansible prune run) ───────────── #}
|
||||||
|
{% if session.user_role == 'admin' %}
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
{% if ansible_available and g.has_target %}
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Remove all STOPPED containers on {{ g.host_name|e }}? This cannot be undone."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="containers">
|
||||||
|
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Remove ALL unused images on {{ g.host_name|e }} (docker image prune -a)? Any image not used by a container is deleted."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="images">
|
||||||
|
<button type="submit" class="btn btn-sm">Prune unused images</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||||
|
data-msg="Run a full system prune on {{ g.host_name|e }}? Removes stopped containers, unused networks, dangling images and build cache."
|
||||||
|
onsubmit="return confirm(this.dataset.msg);">
|
||||||
|
<input type="hidden" name="target" value="system">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.4rem;">
|
||||||
|
Reclaimed space appears on the next agent sample.
|
||||||
|
</div>
|
||||||
|
{% elif not ansible_available %}
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">Cleanup needs the Ansible runner (currently unavailable).</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:0.74rem;color:var(--text-muted);">
|
||||||
|
Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Per-image table ──────────────────────────────────────────────────── #}
|
||||||
|
<div class="card-flush">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Image</th><th>Size</th><th>Shared</th><th>Containers</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for im in g.images %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:0.84rem;font-family:ui-monospace,monospace;max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||||
|
{{ im.repo_tag }}
|
||||||
|
{% if im.reclaimable %}<span title="not referenced by any container" style="font-size:0.68rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0 0.3rem;margin-left:0.4rem;">reclaimable</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;">{{ im.size }}</td>
|
||||||
|
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;color:var(--text-muted);">{{ im.shared }}</td>
|
||||||
|
<td style="font-size:0.86rem;color:{% if im.containers %}var(--text){% else %}var(--text-muted){% endif %};">{{ im.containers }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No images reported.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card" style="text-align:center;padding:3rem;">
|
||||||
|
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||||
|
No disk usage reported yet. The host agent reports image/disk usage from
|
||||||
|
<code>docker system df</code> on hosts running Docker.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{% for c in containers %}
|
{% for c in containers %}
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;font-size:0.85rem;padding:0.2rem 0;">
|
<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 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>
|
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit;text-decoration:none;" title="{{ c.image }}">{{ c.name }}</a>
|
||||||
<span style="font-size:0.74rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
|
<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.cpu_pct is not none %}{{ "%.1f" | format(c.cpu_pct) }}%{% endif %}
|
||||||
{% if c.mem_pct is not none %} · {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
|
{% if c.mem_pct is not none %} · {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
{% block title %}Docker — Steward{% endblock %}
|
{% block title %}Docker — Steward{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:1rem;">
|
||||||
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
|
||||||
|
{% if has_swarm %}
|
||||||
|
<a href="/plugins/docker/swarm" style="font-size:0.85rem;color:var(--accent);text-decoration:none;">Swarm →</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_disk %}
|
||||||
|
<a href="/plugins/docker/disk" style="font-size:0.85rem;color:var(--accent);text-decoration:none;">Disk →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% include "_time_range.html" %}
|
{% include "_time_range.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,61 @@
|
|||||||
<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">{{ total }}</span>
|
<span class="stat-val">{{ total }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if swarm_services %}
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">Services</div>
|
||||||
|
<span class="stat-val">{{ swarm_services | length }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">Hosts</div>
|
<div class="section-title" style="margin-bottom:0.35rem;">Hosts</div>
|
||||||
<span class="stat-val">{{ host_groups | length }}</span>
|
<span class="stat-val">{{ host_groups | length }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Swarm services: collapsed per-service, each replica labelled with its host.
|
||||||
|
Ghost replicas (tasks on nodes with no Steward agent) come from the managers'
|
||||||
|
placement data so the picture is cluster-complete. ──────────────────────── #}
|
||||||
|
{% if swarm_services %}
|
||||||
|
<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);">
|
||||||
|
<span style="font-weight:600;font-size:0.95rem;">Swarm services</span>
|
||||||
|
<a href="/plugins/docker/swarm" style="font-size:0.78rem;color:var(--text-muted);">Topology →</a>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0.5rem 0.75rem;display:grid;gap:0.85rem;">
|
||||||
|
{% for s in swarm_services %}
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.55rem;flex-wrap:wrap;margin-bottom:0.35rem;">
|
||||||
|
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||||
|
<span style="font-weight:600;font-size:0.9rem;">{{ s.name }}</span>
|
||||||
|
<span style="font-size:0.72rem;color:var(--text-dim);">{{ s.mode }}</span>
|
||||||
|
<span style="font-size:0.78rem;color:{% if s.state == 'healthy' %}var(--green){% elif s.state == 'degraded' %}var(--orange){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">
|
||||||
|
{{ s.running }}/{{ s.desired }} running
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:0.4rem;padding-left:1.1rem;">
|
||||||
|
{% for r in s.replicas %}
|
||||||
|
{% if r.ghost %}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.8rem;background:var(--bg-elevated);border:1px dashed var(--border-mid);border-radius:5px;padding:0.3rem 0.55rem;" title="Running on a node with no Steward agent — detail unavailable">
|
||||||
|
<span class="dot dot-warn" style="opacity:0.6;"></span>
|
||||||
|
<span style="font-weight:500;">{{ r.host }}</span>
|
||||||
|
<span style="color:var(--text-dim);margin-left:auto;">{{ r.count }} · no agent</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.8rem;background:var(--bg-elevated);border:1px solid var(--border);border-radius:5px;padding:0.3rem 0.55rem;">
|
||||||
|
<span class="dot {% if r.status == 'running' %}dot-up{% elif r.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||||
|
<a href="/plugins/docker/container/{{ r.host_id }}/{{ r.name }}" style="font-weight:500;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ r.name }}">{{ r.host }}</a>
|
||||||
|
{% if r.cpu_pct is not none %}<span style="color:var(--text-muted);font-family:ui-monospace,monospace;margin-left:auto;">{{ "%.0f" | format(r.cpu_pct) }}%</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Container tables, one per host ───────────────────────────────────────── #}
|
{# ── Container tables, one per host ───────────────────────────────────────── #}
|
||||||
{% if host_groups %}
|
{% if host_groups %}
|
||||||
{% for g in host_groups %}
|
{% for g in host_groups %}
|
||||||
@@ -47,7 +96,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in g.containers %}
|
{% for sub in g.subgroups %}
|
||||||
|
{% if g.grouped and sub.label %}
|
||||||
|
<tr><td colspan="7" style="padding:0.4rem 0.75rem 0.2rem;font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-weight:600;">{{ sub.label }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% for item in sub.containers %}
|
||||||
{% set c = item.container %}
|
{% set c = item.container %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -55,7 +108,7 @@
|
|||||||
<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>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:500;font-size:0.9rem;">
|
<div style="font-weight:500;font-size:0.9rem;">
|
||||||
{{ c.name }}
|
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" style="color:inherit;text-decoration:none;border-bottom:1px dotted var(--border-mid);">{{ c.name }}</a>
|
||||||
{% if c.health == 'healthy' %}<span title="healthy" style="color:var(--green);font-size:0.7rem;">●</span>
|
{% if c.health == 'healthy' %}<span title="healthy" style="color:var(--green);font-size:0.7rem;">●</span>
|
||||||
{% elif c.health == 'unhealthy' %}<span title="unhealthy" style="color:var(--red);font-size:0.7rem;">●</span>
|
{% elif c.health == 'unhealthy' %}<span title="unhealthy" style="color:var(--red);font-size:0.7rem;">●</span>
|
||||||
{% elif c.health == 'starting' %}<span title="health: starting" style="color:var(--orange);font-size:0.7rem;">◐</span>{% endif %}
|
{% elif c.health == 'starting' %}<span title="health: starting" style="color:var(--orange);font-size:0.7rem;">◐</span>{% endif %}
|
||||||
@@ -105,11 +158,12 @@
|
|||||||
<td>{{ item.sparkline_mem | safe }}</td>
|
<td>{{ item.sparkline_mem | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% elif not swarm_services %}
|
||||||
<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 reported yet. Containers are collected by the Steward host agent —
|
No containers reported yet. Containers are collected by the Steward host agent —
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{# docker/swarm.html — Swarm topology: services, replica health, nodes, placement #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs %}
|
||||||
|
{% block title %}Swarm — Docker — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Swarm", "")]) }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1 class="page-title" style="margin-bottom:1.5rem;">Swarm</h1>
|
||||||
|
|
||||||
|
{% if swarms %}
|
||||||
|
{% for g in swarms %}
|
||||||
|
<div style="margin-bottom:2rem;">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||||
|
<span style="font-weight:600;font-size:0.95rem;">Swarm</span>
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-muted);">
|
||||||
|
reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Services ─────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="card-flush" style="margin-bottom:1rem;">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Service</th><th>Mode</th><th>Replicas</th><th>Image</th><th>Placement</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in g.services %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;font-size:0.9rem;">{{ s.name }}</td>
|
||||||
|
<td style="font-size:0.82rem;color:var(--text-muted);">{{ s.mode }}</td>
|
||||||
|
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
||||||
|
<span style="color:{% if s.healthy %}var(--green){% elif s.down %}var(--red){% elif s.degraded %}var(--orange){% else %}var(--text-muted){% endif %};">
|
||||||
|
{{ s.running }}/{{ s.desired }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:0.8rem;color:var(--text-muted);max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.image or "—" }}</td>
|
||||||
|
<td style="font-size:0.78rem;color:var(--text-muted);">
|
||||||
|
{% for p in s.placement %}{{ p.node }} ({{ p.running }}){% if not loop.last %}, {% endif %}{% else %}—{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No services in this swarm.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Nodes ────────────────────────────────────────────────────────────── #}
|
||||||
|
<div class="card-flush">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Node</th><th>Role</th><th>Availability</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for n in g.nodes %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;font-size:0.9rem;">
|
||||||
|
{{ n.hostname or n.node_id }}
|
||||||
|
{% if n.leader %}<span title="cluster leader" style="font-size:0.68rem;color:var(--accent);border:1px solid var(--accent);border-radius:3px;padding:0 0.3rem;margin-left:0.3rem;">leader</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:0.82rem;color:var(--text-muted);">{{ n.role }}</td>
|
||||||
|
<td style="font-size:0.82rem;color:{% if n.availability == 'active' %}var(--text){% else %}var(--orange){% endif %};">{{ n.availability }}</td>
|
||||||
|
<td style="font-size:0.82rem;">
|
||||||
|
<span class="dot {% if n.status == 'ready' %}dot-up{% else %}dot-down{% endif %}"></span>
|
||||||
|
<span style="color:{% if n.status == 'ready' %}var(--green){% else %}var(--red){% endif %};">{{ n.status }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No nodes reported.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card" style="text-align:center;padding:3rem;">
|
||||||
|
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||||
|
No Swarm topology reported. A Steward host agent running on a Swarm
|
||||||
|
<strong>manager</strong> reports services, nodes, and task placement here —
|
||||||
|
workers and non-swarm hosts contribute only their local containers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,20 +1,44 @@
|
|||||||
{# docker/widget.html — dashboard widget: container status overview, by host #}
|
{# docker/widget.html — dashboard widget: container status overview, by host #}
|
||||||
{% if running_count == 0 and stopped_count == 0 %}
|
{% if total_count == 0 %}
|
||||||
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No containers reported yet.</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:1.25rem;margin-bottom:0.65rem;">
|
||||||
<div>
|
<div>
|
||||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ running_count }}</span>
|
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ running_count }}</span>
|
||||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">running</span>
|
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">running</span>
|
||||||
</div>
|
</div>
|
||||||
{% if stopped_count %}
|
<div title="Distinct containers with a die or OOM event in the last 24h">
|
||||||
|
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:{% if failed_24h %}var(--red){% else %}var(--text-muted){% endif %};">{{ failed_24h }}</span>
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">failed (24h)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Swarm services — collapsed, each with its replicas' host chips (dashed = a
|
||||||
|
node with no Steward agent, count-only from placement). #}
|
||||||
|
{% if swarm_services %}
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.2rem;">Swarm services</div>
|
||||||
|
<div style="display:grid;gap:0.4rem;margin-bottom:0.4rem;">
|
||||||
|
{% for s in swarm_services %}
|
||||||
<div>
|
<div>
|
||||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ stopped_count }}</span>
|
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;">
|
||||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">stopped</span>
|
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||||
|
<span style="font-weight:500;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.name }}</span>
|
||||||
|
<span style="color:var(--text-muted);font-family:ui-monospace,monospace;font-size:0.74rem;margin-left:auto;flex-shrink:0;">{{ s.running }}/{{ s.desired }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.25rem;margin:0.2rem 0 0 1rem;">
|
||||||
|
{% for r in s.replicas %}
|
||||||
|
{% if r.ghost %}
|
||||||
|
<span title="no Steward agent on this node — count from swarm placement" style="font-size:0.68rem;color:var(--text-dim);background:var(--bg-elevated);border:1px dashed var(--border-mid);border-radius:4px;padding:0.05rem 0.35rem;">{{ r.host }} ×{{ r.count }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;padding:0.05rem 0.35rem;">{{ r.host }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
{% for g in host_groups %}
|
{% for g in host_groups %}
|
||||||
{% if multi_host %}
|
{% if multi_host %}
|
||||||
@@ -24,9 +48,12 @@
|
|||||||
{% for c in g.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;">
|
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:inherit;text-decoration:none;">
|
||||||
{{ c.name }}
|
{{ c.name }}{% if c.health == 'unhealthy' %}<span title="unhealthy" style="color:var(--red);"> ●</span>{% elif c.health == 'healthy' %}<span title="healthy" style="color:var(--green);"> ●</span>{% endif %}{% if c.restart_count %}<span title="restarts" style="color:var(--orange);font-size:0.7rem;"> ⟳{{ c.restart_count }}</span>{% endif %}
|
||||||
</span>
|
</a>
|
||||||
|
{% if c.status != 'running' and c.exit_code is not none and c.exit_code != 0 %}
|
||||||
|
<span title="last exit code" style="font-size:0.7rem;color:var(--red);flex-shrink:0;">exit {{ c.exit_code }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if c.cpu_pct is not none %}
|
{% if c.cpu_pct is not none %}
|
||||||
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
|
<span style="font-size:0.72rem;color:var(--text-muted);font-family:ui-monospace,monospace;flex-shrink:0;">
|
||||||
{{ "%.1f" | format(c.cpu_pct) }}%
|
{{ "%.1f" | format(c.cpu_pct) }}%
|
||||||
|
|||||||
@@ -1,36 +1,28 @@
|
|||||||
{# docker/widget_resources.html — dashboard widget: CPU + memory bars, busiest containers #}
|
{# docker/widget_resources.html — dashboard widget: the busiest running containers
|
||||||
|
by CPU, each with labeled CPU + memory utilisation bars. #}
|
||||||
{% if not rows %}
|
{% 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;">
|
{% macro bar(label, pct, warn, crit) %}
|
||||||
|
<div style="display:flex;align-items:center;gap:0.45rem;margin-bottom:2px;">
|
||||||
|
<span style="font-size:0.62rem;color:var(--text-dim);width:1.9rem;flex-shrink:0;letter-spacing:0.03em;">{{ label }}</span>
|
||||||
|
<div style="flex:1;height:6px;background:var(--bg-elevated);border-radius:3px;overflow:hidden;">
|
||||||
|
<div style="height:100%;border-radius:3px;width:{{ [pct, 100] | min }}%;
|
||||||
|
background:{% if pct > crit %}var(--red){% elif pct > warn %}var(--orange){% else %}var(--accent){% endif %};
|
||||||
|
transition:width 0.4s;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.7rem;color:var(--text-muted);font-family:ui-monospace,monospace;width:3rem;text-align:right;flex-shrink:0;">{{ "%.1f" | format(pct) }}%</span>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
<div style="display:grid;gap:0.6rem;">
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
{% set c = r.c %}
|
{% set c = r.c %}
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.15rem;">
|
<div style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:0.25rem;">
|
||||||
<span style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%;">
|
|
||||||
{{ c.name }}{% if multi_host %}<span style="color:var(--text-dim);font-weight:normal;"> · {{ r.host_name }}</span>{% endif %}
|
{{ c.name }}{% if multi_host %}<span style="color:var(--text-dim);font-weight:normal;"> · {{ r.host_name }}</span>{% endif %}
|
||||||
</span>
|
|
||||||
<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.mem_pct is not none %} · Mem {{ "%.1f" | format(c.mem_pct) }}%{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% if c.cpu_pct is not none %}
|
{% if c.cpu_pct is not none %}{{ bar("CPU", c.cpu_pct, 50, 80) }}{% endif %}
|
||||||
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;margin-bottom:2px;">
|
{% if c.mem_pct is not none %}{{ bar("MEM", c.mem_pct, 70, 90) }}{% endif %}
|
||||||
<div style="height:100%;border-radius:2px;width:{{ [c.cpu_pct, 100] | min }}%;
|
|
||||||
background:{% if c.cpu_pct > 80 %}var(--red){% elif c.cpu_pct > 50 %}var(--orange){% else %}var(--accent){% endif %};
|
|
||||||
transition:width 0.4s;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if c.mem_pct is not none %}
|
|
||||||
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;">
|
|
||||||
<div style="height:100%;border-radius:2px;width:{{ [c.mem_pct, 100] | min }}%;
|
|
||||||
background:{% if c.mem_pct > 90 %}var(--red){% elif c.mem_pct > 70 %}var(--orange){% else %}var(--green){% endif %};
|
|
||||||
transition:width 0.4s;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+254
-10
@@ -20,7 +20,7 @@ from collections import deque
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
AGENT_VERSION = "1.6.0"
|
AGENT_VERSION = "1.7.0"
|
||||||
|
|
||||||
# Default path to the local Docker Engine socket. Overridable via the
|
# Default path to the local Docker Engine socket. Overridable via the
|
||||||
# `docker_socket` config key; collection is silently skipped if it's absent or
|
# `docker_socket` config key; collection is silently skipped if it's absent or
|
||||||
@@ -34,7 +34,10 @@ class ConfigError(Exception):
|
|||||||
|
|
||||||
REQUIRED_KEYS = ("url", "token")
|
REQUIRED_KEYS = ("url", "token")
|
||||||
INT_KEYS = ("interval_seconds",)
|
INT_KEYS = ("interval_seconds",)
|
||||||
LIST_KEYS = ("mounts",)
|
LIST_KEYS = ("mounts", "docker_log_exclude")
|
||||||
|
# Truthy strings for bool keys; anything else (incl. empty) is False.
|
||||||
|
BOOL_KEYS = ("docker_logs_enabled",)
|
||||||
|
_BOOL_TRUE = ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
def read_config(path: str) -> dict:
|
def read_config(path: str) -> dict:
|
||||||
@@ -58,6 +61,8 @@ def read_config(path: str) -> dict:
|
|||||||
raise ConfigError(f"{path}:{lineno}: {key} must be int")
|
raise ConfigError(f"{path}:{lineno}: {key} must be int")
|
||||||
elif key in LIST_KEYS:
|
elif key in LIST_KEYS:
|
||||||
cfg[key] = [v.strip() for v in value.split(",") if v.strip()]
|
cfg[key] = [v.strip() for v in value.split(",") if v.strip()]
|
||||||
|
elif key in BOOL_KEYS:
|
||||||
|
cfg[key] = value.lower() in _BOOL_TRUE
|
||||||
else:
|
else:
|
||||||
cfg[key] = value
|
cfg[key] = value
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -69,6 +74,10 @@ def read_config(path: str) -> dict:
|
|||||||
|
|
||||||
cfg.setdefault("interval_seconds", 30)
|
cfg.setdefault("interval_seconds", 30)
|
||||||
cfg.setdefault("docker_socket", DEFAULT_DOCKER_SOCKET)
|
cfg.setdefault("docker_socket", DEFAULT_DOCKER_SOCKET)
|
||||||
|
# Container-log collection is on by default (operator preference); a host can
|
||||||
|
# opt out with `docker_logs_enabled = false` or thin it with docker_log_exclude.
|
||||||
|
cfg.setdefault("docker_logs_enabled", True)
|
||||||
|
cfg.setdefault("docker_log_exclude", [])
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +412,15 @@ def _rates(cur: dict, prev: dict, dt: float) -> dict:
|
|||||||
|
|
||||||
DOCKER_API_TIMEOUT = 5.0
|
DOCKER_API_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
# Container-log collection (m79). Logs ride in the same push as metrics, so one
|
||||||
|
# interval's batch is capped to keep a chatty container from bloating a POST (and
|
||||||
|
# the backoff ring): once the cap is hit a marker line is emitted and collection
|
||||||
|
# stops — the deferred lines come on the next interval. On a container's first
|
||||||
|
# interval we seed from a short tail, then switch to an incremental since-cursor
|
||||||
|
# kept per container in the agent's rate-state.
|
||||||
|
DOCKER_LOG_BATCH_MAX_BYTES = 262144 # 256 KiB of log text per push, all containers
|
||||||
|
DOCKER_LOG_FIRST_TAIL = 50 # lines seeded on a container's first interval
|
||||||
|
|
||||||
|
|
||||||
def _dechunk(body: bytes) -> bytes:
|
def _dechunk(body: bytes) -> bytes:
|
||||||
"""Decode an HTTP/1.1 chunked-transfer body into the raw payload."""
|
"""Decode an HTTP/1.1 chunked-transfer body into the raw payload."""
|
||||||
@@ -650,6 +668,198 @@ def collect_docker(socket_path: str) -> list:
|
|||||||
return list(ex.map(lambda c: _collect_one_container(socket_path, c), containers))
|
return list(ex.map(lambda c: _collect_one_container(socket_path, c), containers))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── container logs (m79) ─────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The logs endpoint returns a raw multiplexed byte stream, not JSON, so it needs
|
||||||
|
# a sibling of _docker_request that hands back bytes. Non-TTY containers frame
|
||||||
|
# stdout/stderr with an 8-byte header (stream byte + 4-byte big-endian length);
|
||||||
|
# TTY containers emit unframed bytes. We request timestamps=1, so every line is
|
||||||
|
# prefixed with an RFC3339Nano timestamp we parse for the since-cursor.
|
||||||
|
|
||||||
|
_LOG_STREAM_NAMES = {0: "stdout", 1: "stdout", 2: "stderr"}
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_request_raw(socket_path: str, path: str,
|
||||||
|
timeout: float = DOCKER_API_TIMEOUT) -> bytes:
|
||||||
|
"""GET `path` from the Docker API over the Unix socket; return the raw body.
|
||||||
|
|
||||||
|
Sibling of `_docker_request` for non-JSON endpoints (container logs). Reuses
|
||||||
|
the connect / send / de-chunk scaffolding; raises OSError on any transport
|
||||||
|
problem or non-2xx status so callers can silent-skip. A 200 with an empty
|
||||||
|
body (no new lines since the cursor) returns b"".
|
||||||
|
"""
|
||||||
|
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/octet-stream\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)
|
||||||
|
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else 0
|
||||||
|
if not (200 <= status < 300):
|
||||||
|
raise OSError(f"docker API {path} returned {status}")
|
||||||
|
if "transfer-encoding: chunked" in header_text.lower():
|
||||||
|
body = _dechunk(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _demux_docker_logs(raw: bytes) -> list:
|
||||||
|
"""Split a Docker logs stream into [(stream_name, payload_bytes), …].
|
||||||
|
|
||||||
|
Non-TTY containers multiplex with an 8-byte frame header (stream byte in
|
||||||
|
{0,1,2} + 4-byte big-endian length). The stream is treated as framed only if
|
||||||
|
the whole buffer parses as clean back-to-back frames; anything else (a TTY
|
||||||
|
container's raw stream, or a malformed header) falls back to a single stdout
|
||||||
|
blob so no bytes are lost.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
frames = []
|
||||||
|
i, n = 0, len(raw)
|
||||||
|
while i + 8 <= n:
|
||||||
|
stream = raw[i]
|
||||||
|
if stream > 2 or raw[i + 1] or raw[i + 2] or raw[i + 3]:
|
||||||
|
break # not a valid frame header → not framed
|
||||||
|
size = int.from_bytes(raw[i + 4:i + 8], "big")
|
||||||
|
if i + 8 + size > n:
|
||||||
|
break # frame overruns the buffer → not framed
|
||||||
|
frames.append((_LOG_STREAM_NAMES.get(stream, "stdout"),
|
||||||
|
raw[i + 8:i + 8 + size]))
|
||||||
|
i += 8 + size
|
||||||
|
if frames and i == n:
|
||||||
|
return frames
|
||||||
|
return [("stdout", raw)]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_log_ts(token: str):
|
||||||
|
"""Parse a Docker RFC3339Nano timestamp token into a datetime, or None.
|
||||||
|
|
||||||
|
Normalises `Z` → `+00:00` and trims Docker's nanosecond precision to the
|
||||||
|
microseconds datetime.fromisoformat accepts (stdlib, Python 3.8+ safe).
|
||||||
|
"""
|
||||||
|
t = token.strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
if t.endswith("Z"):
|
||||||
|
t = t[:-1] + "+00:00"
|
||||||
|
m = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(.*)$", t)
|
||||||
|
if m:
|
||||||
|
frac = m.group(2) or ""
|
||||||
|
if len(frac) > 7: # "." + up to 6 fractional digits
|
||||||
|
frac = frac[:7]
|
||||||
|
t = m.group(1) + frac + m.group(3)
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(t)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_container_logs(raw: bytes) -> list:
|
||||||
|
"""Demux + line-split a logs stream → [(dt|None, stream, line), …] in order.
|
||||||
|
|
||||||
|
Each line is timestamp-prefixed (we request timestamps=1); an unparseable
|
||||||
|
prefix yields dt=None with the full raw line kept.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for stream, payload in _demux_docker_logs(raw):
|
||||||
|
text = payload.decode("utf-8", "replace")
|
||||||
|
for raw_line in text.split("\n"):
|
||||||
|
if not raw_line:
|
||||||
|
continue
|
||||||
|
token, _, msg = raw_line.partition(" ")
|
||||||
|
dt = _parse_log_ts(token)
|
||||||
|
if dt is not None:
|
||||||
|
out.append((dt, stream, msg))
|
||||||
|
else:
|
||||||
|
out.append((None, stream, raw_line))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def collect_docker_logs(socket_path: str, containers: list, state: dict,
|
||||||
|
exclude=None,
|
||||||
|
max_bytes: int = DOCKER_LOG_BATCH_MAX_BYTES) -> list:
|
||||||
|
"""New container-log lines since the last interval, for running containers.
|
||||||
|
|
||||||
|
Keeps a per-container since-cursor in `state["docker_log_cursors"]`
|
||||||
|
(name → unix ts) so each interval fetches only what's new; a container's
|
||||||
|
first interval seeds from a short tail. The whole batch is capped at
|
||||||
|
`max_bytes` of log text — once exceeded a truncation marker is appended and
|
||||||
|
collection stops (logs must never balloon a push). `exclude` container names
|
||||||
|
are skipped entirely (a per-host bandwidth opt-out). Best-effort: a
|
||||||
|
per-container transport error just contributes nothing this interval.
|
||||||
|
Returns [{"container", "stream", "ts", "line"}, …].
|
||||||
|
"""
|
||||||
|
exclude_set = set(exclude or ())
|
||||||
|
cursors = state.setdefault("docker_log_cursors", {})
|
||||||
|
running = {c.get("name") for c in (containers or [])
|
||||||
|
if c.get("name") and c.get("status") == "running"}
|
||||||
|
# Forget cursors for containers no longer running so state can't grow without
|
||||||
|
# bound over the agent's lifetime.
|
||||||
|
for gone in [k for k in cursors if k not in running]:
|
||||||
|
del cursors[gone]
|
||||||
|
|
||||||
|
out: list = []
|
||||||
|
total = 0
|
||||||
|
truncated = False
|
||||||
|
for c in (containers or []):
|
||||||
|
name = c.get("name")
|
||||||
|
if not name or name not in running or name in exclude_set:
|
||||||
|
continue
|
||||||
|
prev = cursors.get(name)
|
||||||
|
base = f"/containers/{name}/logs?stdout=1&stderr=1×tamps=1"
|
||||||
|
# since accepts a fractional Unix ts; we still dedup the boundary line.
|
||||||
|
path = (f"{base}&tail={DOCKER_LOG_FIRST_TAIL}" if prev is None
|
||||||
|
else f"{base}&since={prev:.9f}")
|
||||||
|
try:
|
||||||
|
raw = _docker_request_raw(socket_path, path)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
newest = prev
|
||||||
|
for dt, stream, line in _parse_container_logs(raw):
|
||||||
|
ts_unix = dt.timestamp() if dt is not None else None
|
||||||
|
if prev is not None and ts_unix is not None and ts_unix <= prev:
|
||||||
|
continue # boundary line already sent last interval
|
||||||
|
if total >= max_bytes:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
out.append({"container": name, "stream": stream,
|
||||||
|
"ts": dt.isoformat() if dt is not None else None,
|
||||||
|
"line": line})
|
||||||
|
total += len(line)
|
||||||
|
if ts_unix is not None and (newest is None or ts_unix > newest):
|
||||||
|
newest = ts_unix
|
||||||
|
if newest is not None:
|
||||||
|
cursors[name] = newest
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
out.append({"container": "_steward", "stream": "stderr",
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"line": (f"[steward] log batch truncated at {max_bytes} bytes; "
|
||||||
|
"remaining lines deferred to the next interval")})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ─── swarm (manager-only) ────────────────────────────────────────────────────
|
# ─── swarm (manager-only) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -852,13 +1062,17 @@ class RingBuffer:
|
|||||||
|
|
||||||
|
|
||||||
def build_sample(mounts: list[str], state: dict,
|
def build_sample(mounts: list[str], state: dict,
|
||||||
docker_socket: str = DEFAULT_DOCKER_SOCKET) -> dict:
|
docker_socket: str = DEFAULT_DOCKER_SOCKET, *,
|
||||||
|
docker_logs_enabled: bool = True,
|
||||||
|
docker_log_exclude=None) -> 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 (and the per-container log
|
||||||
`docker_socket` is probed best-effort — the `docker` key is omitted entirely
|
since-cursors); it is mutated in place. `docker_socket` is probed
|
||||||
when no containers are found, so non-Docker hosts add nothing to the payload.
|
best-effort — the `docker` key is omitted entirely when no containers are
|
||||||
|
found, so non-Docker hosts add nothing to the payload. `docker_logs_enabled`
|
||||||
|
/ `docker_log_exclude` gate incremental container-log collection.
|
||||||
"""
|
"""
|
||||||
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
|
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
|
||||||
try:
|
try:
|
||||||
@@ -940,6 +1154,18 @@ def build_sample(mounts: list[str], state: dict,
|
|||||||
if disk is not None:
|
if disk is not None:
|
||||||
sample["docker_disk"] = disk
|
sample["docker_disk"] = disk
|
||||||
|
|
||||||
|
# Container logs (m79): incremental per-container tail folded into the same
|
||||||
|
# push as metrics. Skipped when there are no containers or logging is off;
|
||||||
|
# the key is omitted when nothing new arrived this interval.
|
||||||
|
if docker and docker_logs_enabled:
|
||||||
|
try:
|
||||||
|
logs = collect_docker_logs(docker_socket, docker, state,
|
||||||
|
exclude=docker_log_exclude)
|
||||||
|
except Exception:
|
||||||
|
logs = []
|
||||||
|
if logs:
|
||||||
|
sample["docker_logs"] = logs
|
||||||
|
|
||||||
return sample
|
return sample
|
||||||
|
|
||||||
|
|
||||||
@@ -958,6 +1184,18 @@ def build_payload(samples: list[dict], hostname: str, metadata: dict) -> dict:
|
|||||||
BACKOFF_CAP = 300
|
BACKOFF_CAP = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_logs(sample: dict) -> dict:
|
||||||
|
"""Strip container logs from a sample before it's buffered for retry.
|
||||||
|
|
||||||
|
Logs are the one payload we never carry across a backoff — they'd bloat the
|
||||||
|
ring buffer and go stale — while metrics are kept so an outage doesn't lose
|
||||||
|
them. The since-cursor already advanced when the logs were collected, so the
|
||||||
|
dropped lines are simply not re-sent (accepted loss during an outage).
|
||||||
|
"""
|
||||||
|
sample.pop("docker_logs", None)
|
||||||
|
return sample
|
||||||
|
|
||||||
|
|
||||||
def next_backoff(current: int) -> int:
|
def next_backoff(current: int) -> int:
|
||||||
if current <= 0:
|
if current <= 0:
|
||||||
return 30
|
return 30
|
||||||
@@ -1022,6 +1260,8 @@ def main_loop(conf_path: str) -> int:
|
|||||||
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
|
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
||||||
|
docker_logs_enabled = cfg.get("docker_logs_enabled", True)
|
||||||
|
docker_log_exclude = cfg.get("docker_log_exclude") or []
|
||||||
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.
|
||||||
@@ -1036,13 +1276,17 @@ def main_loop(conf_path: str) -> int:
|
|||||||
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
|
docker_socket = cfg.get("docker_socket") or DEFAULT_DOCKER_SOCKET
|
||||||
|
docker_logs_enabled = cfg.get("docker_logs_enabled", True)
|
||||||
|
docker_log_exclude = cfg.get("docker_log_exclude") or []
|
||||||
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, docker_socket)
|
sample = build_sample(mounts, rate_state, docker_socket,
|
||||||
|
docker_logs_enabled=docker_logs_enabled,
|
||||||
|
docker_log_exclude=docker_log_exclude)
|
||||||
buffered = buffer.drain()
|
buffered = buffer.drain()
|
||||||
payload = build_payload(
|
payload = build_payload(
|
||||||
samples=buffered + [sample],
|
samples=buffered + [sample],
|
||||||
@@ -1061,12 +1305,12 @@ def main_loop(conf_path: str) -> int:
|
|||||||
_log("ERROR", "server rejected payload (400) — dropping sample")
|
_log("ERROR", "server rejected payload (400) — dropping sample")
|
||||||
elif status == 401:
|
elif status == 401:
|
||||||
_log("ERROR", "token rejected (401) — check config + UI")
|
_log("ERROR", "token rejected (401) — check config + UI")
|
||||||
buffer.push(sample)
|
buffer.push(_drop_logs(sample))
|
||||||
else:
|
else:
|
||||||
_log("WARN", f"POST failed (status={status}); buffering")
|
_log("WARN", f"POST failed (status={status}); buffering")
|
||||||
for s in buffered:
|
for s in buffered:
|
||||||
buffer.push(s)
|
buffer.push(_drop_logs(s))
|
||||||
buffer.push(sample)
|
buffer.push(_drop_logs(sample))
|
||||||
backoff = next_backoff(backoff)
|
backoff = next_backoff(backoff)
|
||||||
sleep_for = backoff
|
sleep_for = backoff
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Host-metric read helpers (latest snapshot + bucketed history).
|
||||||
|
|
||||||
|
Kept separate from routes.py so it imports only the core PluginMetric model — not
|
||||||
|
the host_agent ORM models — which lets integration tests import these helpers
|
||||||
|
without tripping the plugin-loader's double-registration ("Table already
|
||||||
|
defined") guard.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
|
||||||
|
from steward.core.time_range import bucket_seconds
|
||||||
|
from steward.models.metrics import PluginMetric, PluginMetricHourly
|
||||||
|
|
||||||
|
SOURCE_MODULE = "host_agent"
|
||||||
|
|
||||||
|
# Host-level metrics charted on the detail page (sub-resources are shown as
|
||||||
|
# current-value lists, not time series, to keep the page readable).
|
||||||
|
HISTORY_METRICS = (
|
||||||
|
"cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m",
|
||||||
|
"net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
|
||||||
|
"temp_c_max", "psi_mem_some_avg10",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]:
|
||||||
|
"""{resource_name: {metric: value}} — latest sample per (resource, metric) for
|
||||||
|
a host + its sub-resources.
|
||||||
|
|
||||||
|
DISTINCT ON picks the newest row per group in one index-ordered pass over
|
||||||
|
ix_plugin_metrics_module_resource_metric_recorded, instead of a GROUP-BY-max
|
||||||
|
subquery self-joined back to the table (two passes over the whole history).
|
||||||
|
"""
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(PluginMetric)
|
||||||
|
.where(
|
||||||
|
PluginMetric.source_module == SOURCE_MODULE,
|
||||||
|
or_(
|
||||||
|
PluginMetric.resource_name == host_name,
|
||||||
|
PluginMetric.resource_name.like(host_name + ":%"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.distinct(PluginMetric.resource_name, PluginMetric.metric_name)
|
||||||
|
.order_by(
|
||||||
|
PluginMetric.resource_name,
|
||||||
|
PluginMetric.metric_name,
|
||||||
|
PluginMetric.recorded_at.desc(),
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
out: dict[str, dict[str, float]] = {}
|
||||||
|
for r in rows:
|
||||||
|
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _history_for_host(session, host_name: str, since, *, raw_days: int = 7) -> dict[str, list]:
|
||||||
|
"""{metric: [[epoch_ms, avg_value], …]} host-level series since `since`.
|
||||||
|
|
||||||
|
Retention rolls raw plugin_metrics older than `raw_days` into hourly averages
|
||||||
|
(plugin_metrics_hourly) and deletes the raw rows. So we read the rollup for the
|
||||||
|
part of the range older than that boundary and raw (bucket-averaged in SQL,
|
||||||
|
capped to ≤1h to match the rollup) for the recent part — never shipping raw
|
||||||
|
samples to Python. The two windows don't overlap, so appending hourly-then-raw
|
||||||
|
keeps each metric's series time-ordered. Epoch-ms x feeds a linear chart axis.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
raw_cutoff = (now - timedelta(days=raw_days)).replace(minute=0, second=0, microsecond=0)
|
||||||
|
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
|
||||||
|
|
||||||
|
# ── Older than the raw window: the hourly rollup ──
|
||||||
|
if since < raw_cutoff:
|
||||||
|
hourly = (await session.execute(
|
||||||
|
select(PluginMetricHourly.metric_name, PluginMetricHourly.bucket,
|
||||||
|
PluginMetricHourly.value_avg)
|
||||||
|
.where(
|
||||||
|
PluginMetricHourly.source_module == SOURCE_MODULE,
|
||||||
|
PluginMetricHourly.resource_name == host_name,
|
||||||
|
PluginMetricHourly.metric_name.in_(HISTORY_METRICS),
|
||||||
|
PluginMetricHourly.bucket >= since,
|
||||||
|
PluginMetricHourly.bucket < raw_cutoff,
|
||||||
|
)
|
||||||
|
.order_by(PluginMetricHourly.bucket)
|
||||||
|
)).all()
|
||||||
|
for metric_name, bucket, avg in hourly:
|
||||||
|
series[metric_name].append([int(bucket.timestamp() * 1000), round(float(avg), 2)])
|
||||||
|
|
||||||
|
# ── Recent part: raw, bucket-averaged in SQL ──
|
||||||
|
raw_since = since if since >= raw_cutoff else raw_cutoff
|
||||||
|
width_s = bucket_seconds(raw_since, 120)
|
||||||
|
if since < raw_cutoff:
|
||||||
|
width_s = min(width_s, 3600) # ≤ 1h so it matches the hourly portion
|
||||||
|
rbucket = func.date_bin(
|
||||||
|
func.make_interval(0, 0, 0, 0, 0, 0, width_s),
|
||||||
|
PluginMetric.recorded_at,
|
||||||
|
func.to_timestamp(0), # epoch origin
|
||||||
|
).label("bucket")
|
||||||
|
raw = (await session.execute(
|
||||||
|
select(PluginMetric.metric_name, rbucket, func.avg(PluginMetric.value))
|
||||||
|
.where(
|
||||||
|
PluginMetric.source_module == SOURCE_MODULE,
|
||||||
|
PluginMetric.resource_name == host_name,
|
||||||
|
PluginMetric.metric_name.in_(HISTORY_METRICS),
|
||||||
|
PluginMetric.recorded_at >= raw_since,
|
||||||
|
)
|
||||||
|
.group_by(PluginMetric.metric_name, rbucket)
|
||||||
|
.order_by(rbucket)
|
||||||
|
)).all()
|
||||||
|
for metric_name, b, avg in raw:
|
||||||
|
series[metric_name].append([int(b.timestamp() * 1000), round(float(avg), 2)])
|
||||||
|
return series
|
||||||
+120
-97
@@ -14,16 +14,21 @@ from steward.models.users import UserRole
|
|||||||
from sqlalchemy import select, func, or_, and_
|
from sqlalchemy import select, func, or_, and_
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from steward.core.settings import public_base_url
|
from steward.core.settings import get_setting, public_base_url
|
||||||
from steward.core.time_range import parse_range, RANGE_OPTIONS
|
from steward.core.time_range import parse_range, RANGE_OPTIONS
|
||||||
from steward.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
from steward.models.metrics import PluginMetric
|
from steward.models.metrics import PluginMetric
|
||||||
from .models import HostAgentRegistration
|
from .models import HostAgentRegistration
|
||||||
|
# Query helpers live in a model-free module so integration tests can import them
|
||||||
|
# without the plugin-loader double-registration guard tripping (see metrics_query).
|
||||||
|
from .metrics_query import (
|
||||||
|
SOURCE_MODULE,
|
||||||
|
_history_for_host,
|
||||||
|
_latest_metrics_for_host,
|
||||||
|
)
|
||||||
|
|
||||||
host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates")
|
host_agent_bp = Blueprint("host_agent", __name__, template_folder="templates")
|
||||||
|
|
||||||
SOURCE_MODULE = "host_agent"
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_token(raw: str) -> str:
|
def _hash_token(raw: str) -> str:
|
||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
@@ -197,6 +202,7 @@ async def ingest():
|
|||||||
accepted = 0
|
accepted = 0
|
||||||
latest_ts: datetime | None = None
|
latest_ts: datetime | None = None
|
||||||
docker_snapshots: list[tuple[datetime, list]] = []
|
docker_snapshots: list[tuple[datetime, list]] = []
|
||||||
|
docker_log_batches: list[tuple[datetime, list]] = []
|
||||||
latest_swarm: dict | None = None
|
latest_swarm: dict | None = None
|
||||||
latest_swarm_ts: datetime | None = None
|
latest_swarm_ts: datetime | None = None
|
||||||
latest_disk: dict | None = None
|
latest_disk: dict | None = None
|
||||||
@@ -212,6 +218,11 @@ async def ingest():
|
|||||||
docker = sample.get("docker")
|
docker = sample.get("docker")
|
||||||
if isinstance(docker, list) and docker:
|
if isinstance(docker, list) and docker:
|
||||||
docker_snapshots.append((recorded_at, docker))
|
docker_snapshots.append((recorded_at, docker))
|
||||||
|
# Container logs are time-series (append every line, not newest-only);
|
||||||
|
# each record carries its own Docker ts, recorded_at is the fallback.
|
||||||
|
docker_logs = sample.get("docker_logs")
|
||||||
|
if isinstance(docker_logs, list) and docker_logs:
|
||||||
|
docker_log_batches.append((recorded_at, docker_logs))
|
||||||
# Swarm is current-state, not time-series — keep only the newest
|
# Swarm is current-state, not time-series — keep only the newest
|
||||||
# sample's topology (a manager re-reports it every interval).
|
# sample's topology (a manager re-reports it every interval).
|
||||||
swarm = sample.get("swarm")
|
swarm = sample.get("swarm")
|
||||||
@@ -235,7 +246,8 @@ async def ingest():
|
|||||||
# (opportunistic synergy via the capability registry — no hard import,
|
# (opportunistic synergy via the capability registry — no hard import,
|
||||||
# no-op when docker is disabled). A failure here must never sink the
|
# no-op when docker is disabled). A failure here must never sink the
|
||||||
# whole ingest, so the metrics above still land.
|
# whole ingest, so the metrics above still land.
|
||||||
if docker_snapshots or latest_swarm is not None or latest_disk is not None:
|
if (docker_snapshots or latest_swarm is not None
|
||||||
|
or latest_disk is not None or docker_log_batches):
|
||||||
from steward.core.capabilities import has_capability, invoke_capability
|
from steward.core.capabilities import has_capability, invoke_capability
|
||||||
if has_capability("docker.persist_host_samples"):
|
if has_capability("docker.persist_host_samples"):
|
||||||
try:
|
try:
|
||||||
@@ -245,7 +257,7 @@ async def ingest():
|
|||||||
await invoke_capability(
|
await invoke_capability(
|
||||||
"docker.persist_host_samples", UserRole.admin,
|
"docker.persist_host_samples", UserRole.admin,
|
||||||
session, host, docker_snapshots, latest_swarm,
|
session, host, docker_snapshots, latest_swarm,
|
||||||
latest_disk,
|
latest_disk, docker_log_batches,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
current_app.logger.exception(
|
current_app.logger.exception(
|
||||||
@@ -540,89 +552,25 @@ def _downsample(series: list[list], target: int = 120) -> list[list]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# Host-level metrics charted on the detail page (sub-resources are shown as
|
# Full-metrics refresh cadences. The agent pushes every ~30s by default, so
|
||||||
# current-value lists, not time series, to keep the page readable).
|
# polling the current snapshot at 15s keeps it ≤ one cadence stale; the history
|
||||||
HISTORY_METRICS = (
|
# charts move slowly over a multi-hour/day window, so they refresh less often.
|
||||||
"cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m",
|
_DETAIL_POLL_SECONDS = 15
|
||||||
"net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps",
|
_CHART_POLL_SECONDS = 60
|
||||||
"temp_c_max", "psi_mem_some_avg10",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]:
|
def _split_host_metrics(host_name: str, latest: dict) -> tuple:
|
||||||
"""{resource_name: {metric: value}} of the latest sample for a host + sub-resources."""
|
"""Split a host's latest snapshot into
|
||||||
subq = (
|
(hostlvl, cores, nets, disks_io, temps, mounts) for the current-state fragment."""
|
||||||
select(
|
hostlvl = latest.get(host_name, {})
|
||||||
PluginMetric.resource_name,
|
|
||||||
PluginMetric.metric_name,
|
|
||||||
func.max(PluginMetric.recorded_at).label("max_ts"),
|
|
||||||
)
|
|
||||||
.where(PluginMetric.source_module == SOURCE_MODULE)
|
|
||||||
.where(or_(
|
|
||||||
PluginMetric.resource_name == host_name,
|
|
||||||
PluginMetric.resource_name.like(host_name + ":%"),
|
|
||||||
))
|
|
||||||
.group_by(PluginMetric.resource_name, PluginMetric.metric_name)
|
|
||||||
).subquery()
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(PluginMetric).join(
|
|
||||||
subq,
|
|
||||||
(PluginMetric.resource_name == subq.c.resource_name) &
|
|
||||||
(PluginMetric.metric_name == subq.c.metric_name) &
|
|
||||||
(PluginMetric.recorded_at == subq.c.max_ts),
|
|
||||||
).where(PluginMetric.source_module == SOURCE_MODULE)
|
|
||||||
)).scalars().all()
|
|
||||||
out: dict[str, dict[str, float]] = {}
|
|
||||||
for r in rows:
|
|
||||||
out.setdefault(r.resource_name, {})[r.metric_name] = r.value
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def _history_for_host(session, host_name: str, since) -> dict[str, list]:
|
|
||||||
"""{metric: [[epoch_ms, value], …]} host-level series since `since`.
|
|
||||||
|
|
||||||
Epoch-ms x values let the charts use a linear axis (no Chart.js date adapter).
|
|
||||||
"""
|
|
||||||
rows = (await session.execute(
|
|
||||||
select(PluginMetric).where(
|
|
||||||
PluginMetric.source_module == SOURCE_MODULE,
|
|
||||||
PluginMetric.resource_name == host_name,
|
|
||||||
PluginMetric.metric_name.in_(HISTORY_METRICS),
|
|
||||||
PluginMetric.recorded_at >= since,
|
|
||||||
).order_by(PluginMetric.recorded_at)
|
|
||||||
)).scalars().all()
|
|
||||||
series: dict[str, list] = {m: [] for m in HISTORY_METRICS}
|
|
||||||
for p in rows:
|
|
||||||
series[p.metric_name].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)])
|
|
||||||
# Downsample to a readable point count (see _downsample) — raw agent cadence
|
|
||||||
# is too dense to read over a multi-hour window.
|
|
||||||
return {m: _downsample(v) for m, v in series.items()}
|
|
||||||
|
|
||||||
|
|
||||||
@host_agent_bp.get("/<host_id>/")
|
|
||||||
@require_role(UserRole.viewer)
|
|
||||||
async def host_detail(host_id: str):
|
|
||||||
"""Netdata-style per-host detail: current gauges + history charts."""
|
|
||||||
since, range_key = parse_range(request.args.get("range"))
|
|
||||||
async with current_app.db_sessionmaker() as session:
|
|
||||||
host = (await session.execute(
|
|
||||||
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
|
||||||
if host is None:
|
|
||||||
return _error(404, "not_found")
|
|
||||||
reg = (await session.execute(select(HostAgentRegistration).where(
|
|
||||||
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
|
||||||
latest = await _latest_metrics_for_host(session, host.name)
|
|
||||||
series = await _history_for_host(session, host.name, since)
|
|
||||||
|
|
||||||
hostlvl = latest.get(host.name, {})
|
|
||||||
cores: list = []
|
cores: list = []
|
||||||
nets: dict = {}
|
nets: dict = {}
|
||||||
disks_io: dict = {}
|
disks_io: dict = {}
|
||||||
temps: dict = {}
|
temps: dict = {}
|
||||||
mounts: dict = {}
|
mounts: dict = {}
|
||||||
plen = len(host.name) + 1
|
plen = len(host_name) + 1
|
||||||
for res, metrics in latest.items():
|
for res, metrics in latest.items():
|
||||||
if res == host.name:
|
if res == host_name:
|
||||||
continue
|
continue
|
||||||
suffix = res[plen:]
|
suffix = res[plen:]
|
||||||
if suffix.startswith("core"):
|
if suffix.startswith("core"):
|
||||||
@@ -640,35 +588,80 @@ async def host_detail(host_id: str):
|
|||||||
else:
|
else:
|
||||||
mounts[suffix] = metrics
|
mounts[suffix] = metrics
|
||||||
cores.sort(key=lambda c: c[0])
|
cores.sort(key=lambda c: c[0])
|
||||||
|
return hostlvl, cores, nets, disks_io, temps, mounts
|
||||||
|
|
||||||
ls = reg.last_seen_at if reg else None
|
|
||||||
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
|
|
||||||
|
|
||||||
|
@host_agent_bp.get("/<host_id>/")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def host_detail(host_id: str):
|
||||||
|
"""Full-metrics shell: header + range toggle only. The current-state and
|
||||||
|
history sections stream in via HTMX (host_detail_metrics / host_detail_charts)
|
||||||
|
so the heavy history query never blocks first paint and the data live-updates."""
|
||||||
|
_, range_key = parse_range(request.args.get("range"))
|
||||||
|
async with current_app.db_sessionmaker() as session:
|
||||||
|
host = (await session.execute(
|
||||||
|
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||||
|
if host is None:
|
||||||
|
return _error(404, "not_found")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"host_detail.html",
|
"host_detail.html",
|
||||||
|
host=host, current_range=range_key, range_options=RANGE_OPTIONS,
|
||||||
|
poll_seconds=_DETAIL_POLL_SECONDS, chart_poll_seconds=_CHART_POLL_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@host_agent_bp.get("/<host_id>/metrics")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def host_detail_metrics(host_id: str):
|
||||||
|
"""Current-state fragment (gauges/cores/filesystems/IO/temps) — polled live so
|
||||||
|
the page shows the latest snapshot the server holds without a full reload."""
|
||||||
|
async with current_app.db_sessionmaker() as session:
|
||||||
|
host = (await session.execute(
|
||||||
|
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||||
|
if host is None:
|
||||||
|
return "", 404
|
||||||
|
reg = (await session.execute(select(HostAgentRegistration).where(
|
||||||
|
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
||||||
|
latest = await _latest_metrics_for_host(session, host.name)
|
||||||
|
hostlvl, cores, nets, disks_io, temps, mounts = _split_host_metrics(host.name, latest)
|
||||||
|
ls = reg.last_seen_at if reg else None
|
||||||
|
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
|
||||||
|
return await render_template(
|
||||||
|
"_host_metrics.html",
|
||||||
host=host, reg=reg, stale=stale, hostlvl=hostlvl,
|
host=host, reg=reg, stale=stale, hostlvl=hostlvl,
|
||||||
cores=cores, nets=nets, disks_io=disks_io, temps=temps, mounts=mounts,
|
cores=cores, nets=nets, disks_io=disks_io, temps=temps, mounts=mounts,
|
||||||
series=series, range_key=range_key, range_options=RANGE_OPTIONS,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@host_agent_bp.get("/<host_id>/charts")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def host_detail_charts(host_id: str):
|
||||||
|
"""History-charts fragment — lazy-loaded so the date_bin query never blocks
|
||||||
|
the page shell; refreshed on a slow cadence and on range change."""
|
||||||
|
since, range_key = parse_range(request.args.get("range"))
|
||||||
|
async with current_app.db_sessionmaker() as session:
|
||||||
|
host = (await session.execute(
|
||||||
|
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||||
|
if host is None:
|
||||||
|
return "", 404
|
||||||
|
raw_days = int(await get_setting(session, "metrics.retention.raw_days") or 7)
|
||||||
|
series = await _history_for_host(session, host.name, since, raw_days=raw_days)
|
||||||
|
return await render_template("_host_charts.html", series=series, range_key=range_key)
|
||||||
|
|
||||||
|
|
||||||
def _new_token_pair() -> tuple[str, str]:
|
def _new_token_pair() -> tuple[str, str]:
|
||||||
raw = secrets.token_urlsafe(32)
|
raw = secrets.token_urlsafe(32)
|
||||||
return raw, _hash_token(raw)
|
return raw, _hash_token(raw)
|
||||||
|
|
||||||
|
|
||||||
@host_agent_bp.get("/panel/<host_id>")
|
@host_agent_bp.get("/vitals/<host_id>")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def host_panel(host_id: str):
|
async def host_vitals(host_id: str):
|
||||||
"""Per-host agent panel embedded into the core Hosts hub via HTMX.
|
"""Compact live vitals strip (CPU/MEM/DISK/LOAD + pressure) for the Hosts hub.
|
||||||
|
|
||||||
Shows live agent metrics if the host reports, otherwise the provisioning
|
Self-contained fragment, polled by hosts/detail.html. Renders nothing until
|
||||||
actions — tied to the host's linked Ansible target (the SSH connection).
|
the agent reports — the Agent panel below then carries provisioning.
|
||||||
Self-contained fragment (no base.html) so it can be hx-swapped in.
|
|
||||||
"""
|
"""
|
||||||
from steward.core.capabilities import has_capability
|
|
||||||
from steward.models.ansible_inventory import AnsibleTarget
|
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
host = (await db.execute(
|
host = (await db.execute(
|
||||||
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||||
@@ -677,8 +670,6 @@ async def host_panel(host_id: str):
|
|||||||
reg = (await db.execute(select(HostAgentRegistration).where(
|
reg = (await db.execute(select(HostAgentRegistration).where(
|
||||||
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
||||||
latest = await _latest_metrics_for_host(db, host.name) if reg else {}
|
latest = await _latest_metrics_for_host(db, host.name) if reg else {}
|
||||||
target = (await db.execute(select(AnsibleTarget).where(
|
|
||||||
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
|
|
||||||
|
|
||||||
# Recent series for the at-a-glance sparklines (cpu/mem/load host-level,
|
# Recent series for the at-a-glance sparklines (cpu/mem/load host-level,
|
||||||
# disk from the root mount sub-resource).
|
# disk from the root mount sub-resource).
|
||||||
@@ -721,6 +712,40 @@ async def host_panel(host_id: str):
|
|||||||
"mem": hostlvl.get("psi_mem_some_avg10"),
|
"mem": hostlvl.get("psi_mem_some_avg10"),
|
||||||
"io": hostlvl.get("psi_io_some_avg10"),
|
"io": hostlvl.get("psi_io_some_avg10"),
|
||||||
}
|
}
|
||||||
|
ls = reg.last_seen_at if reg else None
|
||||||
|
reporting = bool(reg) and ls is not None
|
||||||
|
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
|
||||||
|
return await render_template(
|
||||||
|
"_host_vitals.html",
|
||||||
|
reg=reg, cpu=hostlvl.get("cpu_pct"), mem=hostlvl.get("mem_used_pct"),
|
||||||
|
disk_root=disk_root, load_per_core=load_per_core, sparks=sparks, psi=psi,
|
||||||
|
reporting=reporting, stale=stale,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@host_agent_bp.get("/panel/<host_id>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def host_panel(host_id: str):
|
||||||
|
"""Per-host agent management panel embedded into the Hosts hub via HTMX.
|
||||||
|
|
||||||
|
Lifecycle actions (update / rotate / remove / re-provision) when the host
|
||||||
|
reports, otherwise provisioning — tied to the host's linked Ansible target.
|
||||||
|
The live vitals are a separate strip (host_vitals); this panel is management
|
||||||
|
only. Self-contained fragment (no base.html) so it can be hx-swapped in.
|
||||||
|
"""
|
||||||
|
from steward.core.capabilities import has_capability
|
||||||
|
from steward.models.ansible_inventory import AnsibleTarget
|
||||||
|
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
host = (await db.execute(
|
||||||
|
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||||
|
if host is None:
|
||||||
|
return "", 404
|
||||||
|
reg = (await db.execute(select(HostAgentRegistration).where(
|
||||||
|
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
|
||||||
|
target = (await db.execute(select(AnsibleTarget).where(
|
||||||
|
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
|
||||||
|
|
||||||
ls = reg.last_seen_at if reg else None
|
ls = reg.last_seen_at if reg else None
|
||||||
# "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on
|
# "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on
|
||||||
# the registration row — that row is minted before the playbook runs, so a
|
# the registration row — that row is minted before the playbook runs, so a
|
||||||
@@ -731,9 +756,7 @@ async def host_panel(host_id: str):
|
|||||||
ansible_cfg = current_app.config.get("ANSIBLE", {})
|
ansible_cfg = current_app.config.get("ANSIBLE", {})
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"panel.html",
|
"panel.html",
|
||||||
host=host, reg=reg, hostlvl=hostlvl, disk_root=disk_root,
|
host=host, reg=reg, reporting=reporting, stale=stale, target=target,
|
||||||
sparks=sparks, cores=cores, load1=load1, load_per_core=load_per_core, psi=psi,
|
|
||||||
reporting=reporting, stale=stale, target=target,
|
|
||||||
ansible_available=has_capability("ansible.run_playbook"),
|
ansible_available=has_capability("ansible.run_playbook"),
|
||||||
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
|
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{# History data-only fragment. Swapped into the hidden #hm-charts-data poller;
|
||||||
|
the script runs and feeds the persistent charts created by host_detail.html,
|
||||||
|
updating them in place (no canvas swap → no flicker / no re-animation). #}
|
||||||
|
<script>
|
||||||
|
if (window.applyHostSeries) window.applyHostSeries({{ series|tojson }}, {{ range_key|tojson }});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
{# Current-state fragment for the full-metrics page — polled live via HTMX.
|
||||||
|
Self-contained (defines its own format macros) so each poll re-renders the
|
||||||
|
latest snapshot the server has. #}
|
||||||
|
{% macro fmt_bps(v) %}
|
||||||
|
{%- if v is none -%}—
|
||||||
|
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
|
||||||
|
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
|
||||||
|
{%- else -%}{{ "%.0f"|format(v) }} B/s
|
||||||
|
{%- endif -%}
|
||||||
|
{% endmacro %}
|
||||||
|
{% macro fmt_bytes(v) %}
|
||||||
|
{%- if v is none -%}—
|
||||||
|
{%- elif v >= 1125899906842624 -%}{{ "%.1f"|format(v / 1125899906842624) }} PB
|
||||||
|
{%- elif v >= 1099511627776 -%}{{ "%.1f"|format(v / 1099511627776) }} TB
|
||||||
|
{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB
|
||||||
|
{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB
|
||||||
|
{%- else -%}{{ "%.0f"|format(v / 1024) }} KB
|
||||||
|
{%- endif -%}
|
||||||
|
{% endmacro %}
|
||||||
|
{% macro bar(pct) %}
|
||||||
|
{%- set p = pct if pct is not none else 0 -%}
|
||||||
|
<div style="height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
|
||||||
|
<div style="height:100%;width:{{ p|round(1) }}%;background:{% if pct is none %}var(--text-dim){% elif pct < 70 %}var(--green){% elif pct < 90 %}var(--yellow){% else %}var(--red){% endif %};"></div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# ── Identity / metadata ─────────────────────────────────────────────────── #}
|
||||||
|
<div class="card" style="display:flex;flex-wrap:wrap;gap:0.5rem 1.25rem;align-items:center;font-size:0.82rem;color:var(--text-muted);">
|
||||||
|
<span>{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}</span>
|
||||||
|
<span><span style="color:var(--text-dim);">Address</span> {{ host.address or "—" }}</span>
|
||||||
|
{% if reg %}
|
||||||
|
{% if reg.distro %}<span><span style="color:var(--text-dim);">OS</span> {{ reg.distro }}</span>{% endif %}
|
||||||
|
{% if reg.kernel %}<span><span style="color:var(--text-dim);">Kernel</span> {{ reg.kernel }}</span>{% endif %}
|
||||||
|
{% if reg.arch %}<span><span style="color:var(--text-dim);">Arch</span> {{ reg.arch }}</span>{% endif %}
|
||||||
|
{% if reg.agent_version %}<span><span style="color:var(--text-dim);">Agent</span> v{{ reg.agent_version }}</span>{% endif %}
|
||||||
|
{% set up = hostlvl.get('uptime_secs') %}
|
||||||
|
{% if up %}<span><span style="color:var(--text-dim);">Uptime</span> {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h</span>{% endif %}
|
||||||
|
<span><span style="color:var(--text-dim);">Last seen</span> {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>No agent registration found for this host.</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not hostlvl %}
|
||||||
|
<div class="card empty">No metrics reported yet. Once the agent checks in, CPU, memory, network, disk I/O, temperatures, and pressure will appear here.</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ── Current headline gauges ─────────────────────────────────────────────── #}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">CPU</div>
|
||||||
|
{% set c = hostlvl.get('cpu_pct') %}
|
||||||
|
<span class="stat-val" style="font-size:1.6rem;color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}</span>
|
||||||
|
<div style="margin-top:0.5rem;">{{ bar(c) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">Memory</div>
|
||||||
|
{% set mp = hostlvl.get('mem_used_pct') %}
|
||||||
|
<span class="stat-val" style="font-size:1.6rem;color:{% if mp is none %}var(--text-dim){% elif mp < 70 %}var(--green){% elif mp < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}</span>
|
||||||
|
<div style="margin-top:0.5rem;">{{ bar(mp) }}</div>
|
||||||
|
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
||||||
|
avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">Load</div>
|
||||||
|
<span class="stat-val" style="font-size:1.6rem;">{% if hostlvl.get('load_1m') is not none %}{{ "%.2f"|format(hostlvl.get('load_1m')) }}{% else %}—{% endif %}</span>
|
||||||
|
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
||||||
|
5m {% if hostlvl.get('load_5m') is not none %}{{ "%.2f"|format(hostlvl.get('load_5m')) }}{% else %}—{% endif %} ·
|
||||||
|
15m {% if hostlvl.get('load_15m') is not none %}{{ "%.2f"|format(hostlvl.get('load_15m')) }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">Network</div>
|
||||||
|
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
||||||
|
<div><span style="color:var(--green);">↓</span> {{ fmt_bps(hostlvl.get('net_rx_bps')) }}</div>
|
||||||
|
<div><span style="color:var(--red);">↑</span> {{ fmt_bps(hostlvl.get('net_tx_bps')) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">Disk I/O</div>
|
||||||
|
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
||||||
|
<div><span style="color:var(--text-dim);">rd</span> {{ fmt_bps(hostlvl.get('disk_read_bps')) }}</div>
|
||||||
|
<div><span style="color:var(--text-dim);">wr</span> {{ fmt_bps(hostlvl.get('disk_write_bps')) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if hostlvl.get('temp_c_max') is not none %}
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;">Temp (max)</div>
|
||||||
|
{% set t = hostlvl.get('temp_c_max') %}
|
||||||
|
<span class="stat-val" style="font-size:1.6rem;color:{% if t < 70 %}var(--green){% elif t < 85 %}var(--yellow){% else %}var(--red){% endif %};">{{ "%.0f"|format(t) }}°C</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.35rem;" title="Pressure stall — % of time stalled, 10s avg">Pressure (10s)</div>
|
||||||
|
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;color:var(--text-muted);">
|
||||||
|
<div>mem {{ "%.1f"|format(hostlvl.get('psi_mem_some_avg10')) }}%</div>
|
||||||
|
{% if hostlvl.get('psi_cpu_some_avg10') is not none %}<div>cpu {{ "%.1f"|format(hostlvl.get('psi_cpu_some_avg10')) }}%</div>{% endif %}
|
||||||
|
{% if hostlvl.get('psi_io_some_avg10') is not none %}<div>io {{ "%.1f"|format(hostlvl.get('psi_io_some_avg10')) }}%</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Per-core CPU ─────────────────────────────────────────────────────────── #}
|
||||||
|
{% if cores %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title" style="margin-bottom:0.6rem;">Per-core CPU</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:0.5rem 0.9rem;">
|
||||||
|
{% for idx, pct in cores %}
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;">
|
||||||
|
<span>core {{ idx }}</span><span>{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{{ bar(pct) }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Filesystems ──────────────────────────────────────────────────────────── #}
|
||||||
|
{% if mounts %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title" style="margin-bottom:0.6rem;">Filesystems</div>
|
||||||
|
{% for mount, m in mounts.items() %}
|
||||||
|
<div style="margin-bottom:0.6rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.2rem;">
|
||||||
|
<span style="font-family:ui-monospace,monospace;">{{ mount }}</span>
|
||||||
|
<span style="color:var(--text-muted);">{{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{{ bar(m.get('disk_used_pct')) }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Interfaces / disks (short panels, side by side at natural height) ─────── #}
|
||||||
|
{% if nets or disks_io %}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem;align-items:start;">
|
||||||
|
{% if nets %}
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.5rem;">Interfaces</div>
|
||||||
|
{% for iface, m in nets.items() %}
|
||||||
|
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||||
|
<span style="font-family:ui-monospace,monospace;">{{ iface }}</span>
|
||||||
|
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if disks_io %}
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.5rem;">Disks</div>
|
||||||
|
{% for dev, m in disks_io.items() %}
|
||||||
|
<div style="display:flex;justify-content:space-between;gap:0.75rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||||
|
<span style="font-family:ui-monospace,monospace;">{{ dev }}</span>
|
||||||
|
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;">rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Temperatures (full width; cores flow into a compact multi-column grid) ── #}
|
||||||
|
{% if temps %}
|
||||||
|
<div class="card" style="margin-top:1rem;margin-bottom:0;">
|
||||||
|
<div class="section-title" style="margin-bottom:0.5rem;">Temperatures</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.1rem 1.25rem;">
|
||||||
|
{% for label, c in temps.items() %}
|
||||||
|
<div style="display:flex;justify-content:space-between;gap:0.5rem;font-size:0.8rem;padding:0.15rem 0;">
|
||||||
|
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</span>
|
||||||
|
<span style="color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 85 %}var(--yellow){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{# Host vitals strip — compact CPU/MEM/DISK/LOAD + pressure, live-polled into the
|
||||||
|
host hub. Renders nothing until the agent reports (the Agent panel below then
|
||||||
|
shows provisioning). #}
|
||||||
|
{% if reporting %}
|
||||||
|
{% set lbl = "font-size:0.68rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;" %}
|
||||||
|
{% macro vital(label, value, text, kind, spark) %}
|
||||||
|
<div style="min-width:88px;">
|
||||||
|
<div style="{{ lbl }}">{{ label }}</div>
|
||||||
|
<div style="font-size:1.5rem;font-weight:700;line-height:1.1;{{ threshold_style(value, kind) }}">{{ text }}</div>
|
||||||
|
<div style="margin-top:0.2rem;">{{ spark | safe }}</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
<div class="card" style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem 2rem;margin-bottom:1rem;">
|
||||||
|
{{ vital("CPU", cpu, ('%.0f%%'|format(cpu) if cpu is not none else '—'), 'cpu', sparks.cpu) }}
|
||||||
|
{{ vital("Memory", mem, ('%.0f%%'|format(mem) if mem is not none else '—'), 'mem', sparks.mem) }}
|
||||||
|
{{ vital("Disk /", disk_root, ('%.0f%%'|format(disk_root) if disk_root is not none else '—'), 'disk', sparks.disk) }}
|
||||||
|
{{ vital("Load /core", load_per_core, ('%d%%'|format(load_per_core) if load_per_core is not none else '—'), 'load', sparks.load) }}
|
||||||
|
|
||||||
|
<div style="margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;gap:0.3rem;font-size:0.75rem;color:var(--text-muted);text-align:right;">
|
||||||
|
<span>
|
||||||
|
{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}
|
||||||
|
{% if reg and reg.agent_version %}<span style="margin-left:0.3rem;">v{{ reg.agent_version }}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
|
||||||
|
<span title="Pressure stall — % of the last 10s tasks waited for the resource">
|
||||||
|
<span style="color:var(--text-dim);text-transform:uppercase;font-size:0.66rem;letter-spacing:0.04em;">Pressure 10s</span>
|
||||||
|
cpu {{ '%.0f%%'|format(psi.cpu) if psi.cpu is not none else '—' }}
|
||||||
|
· mem {{ '%.0f%%'|format(psi.mem) if psi.mem is not none else '—' }}
|
||||||
|
· io {{ '%.0f%%'|format(psi.io) if psi.io is not none else '—' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if reg and reg.last_seen_at %}<span>seen {{ reg.last_seen_at.strftime("%H:%M:%S") }} UTC</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,215 +1,56 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import crumbs %}
|
{% from "_macros.html" import crumbs %}
|
||||||
{% block title %}{{ host.name }} — Host Agent — Steward{% endblock %}
|
{% block title %}{{ host.name }} — Metrics — Steward{% endblock %}
|
||||||
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Metrics", "")]) }}{% endblock %}
|
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Metrics", "")]) }}{% endblock %}
|
||||||
|
|
||||||
{% macro fmt_bps(v) %}
|
|
||||||
{%- if v is none -%}—
|
|
||||||
{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s
|
|
||||||
{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s
|
|
||||||
{%- else -%}{{ "%.0f"|format(v) }} B/s
|
|
||||||
{%- endif -%}
|
|
||||||
{% endmacro %}
|
|
||||||
{% macro fmt_bytes(v) %}
|
|
||||||
{%- if v is none -%}—
|
|
||||||
{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB
|
|
||||||
{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB
|
|
||||||
{%- else -%}{{ "%.0f"|format(v / 1024) }} KB
|
|
||||||
{%- endif -%}
|
|
||||||
{% endmacro %}
|
|
||||||
{% macro bar(pct) %}
|
|
||||||
{%- set p = pct if pct is not none else 0 -%}
|
|
||||||
<div style="height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
|
|
||||||
<div style="height:100%;width:{{ p|round(1) }}%;background:{% if pct is none %}var(--text-dim){% elif pct < 70 %}var(--green){% elif pct < 90 %}var(--yellow){% else %}var(--red){% endif %};"></div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# Shell only: current-state + history data stream in via HTMX so the heavy
|
||||||
|
history query never blocks first paint and the data refreshes live (≈ the
|
||||||
|
agent's push cadence). The chart CANVASES live here permanently — only their
|
||||||
|
data is polled and applied in place, so refreshes never re-create/flicker. #}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.75rem;">
|
||||||
<div style="display:flex;align-items:center;gap:0.6rem;">
|
|
||||||
<a href="/hosts/{{ host.id }}" class="btn btn-ghost btn-sm">← Host</a>
|
|
||||||
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
|
<h1 class="page-title" style="margin-bottom:0;">{{ host.name }}</h1>
|
||||||
{% if stale %}<span class="badge badge-red">stale</span>{% else %}<span class="badge badge-green">live</span>{% endif %}
|
{% include "_time_range.html" %}
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:0.3rem;">
|
|
||||||
{% for r in range_options %}
|
|
||||||
<a class="range-btn {% if r == range_key %}active{% endif %}" href="?range={{ r }}">{{ r }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Identity / metadata ─────────────────────────────────────────────────── #}
|
{# Current state — lazy + polled live. Atomic innerHTML swap of fixed-height
|
||||||
<div class="card" style="display:flex;flex-wrap:wrap;gap:0.5rem 1.25rem;align-items:center;font-size:0.82rem;color:var(--text-muted);">
|
cards, so values update without a layout jump. #}
|
||||||
<span><span style="color:var(--text-dim);">Address</span> {{ host.address or "—" }}</span>
|
<div id="hm-current"
|
||||||
{% if reg %}
|
hx-get="/plugins/host_agent/{{ host.id }}/metrics"
|
||||||
{% if reg.distro %}<span><span style="color:var(--text-dim);">OS</span> {{ reg.distro }}</span>{% endif %}
|
hx-trigger="load, every {{ poll_seconds }}s"
|
||||||
{% if reg.kernel %}<span><span style="color:var(--text-dim);">Kernel</span> {{ reg.kernel }}</span>{% endif %}
|
hx-swap="innerHTML">
|
||||||
{% if reg.arch %}<span><span style="color:var(--text-dim);">Arch</span> {{ reg.arch }}</span>{% endif %}
|
<div class="card empty">Loading metrics…</div>
|
||||||
{% if reg.agent_version %}<span><span style="color:var(--text-dim);">Agent</span> v{{ reg.agent_version }}</span>{% endif %}
|
|
||||||
{% set up = hostlvl.get('uptime_secs') %}
|
|
||||||
{% if up %}<span><span style="color:var(--text-dim);">Uptime</span> {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h</span>{% endif %}
|
|
||||||
<span><span style="color:var(--text-dim);">Last seen</span> {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span>No agent registration found for this host.</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not hostlvl %}
|
{# History charts — persistent canvases (created once below); only data is polled. #}
|
||||||
<div class="card empty">No metrics reported yet. Once the agent checks in, CPU, memory, network, disk I/O, temperatures, and pressure will appear here.</div>
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
{# ── Current headline gauges ─────────────────────────────────────────────── #}
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">CPU</div>
|
|
||||||
{% set c = hostlvl.get('cpu_pct') %}
|
|
||||||
<span class="stat-val" style="font-size:1.6rem;color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %}</span>
|
|
||||||
<div style="margin-top:0.5rem;">{{ bar(c) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">Memory</div>
|
|
||||||
{% set mp = hostlvl.get('mem_used_pct') %}
|
|
||||||
<span class="stat-val" style="font-size:1.6rem;color:{% if mp is none %}var(--text-dim){% elif mp < 70 %}var(--green){% elif mp < 90 %}var(--yellow){% else %}var(--red){% endif %};">{% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %}</span>
|
|
||||||
<div style="margin-top:0.5rem;">{{ bar(mp) }}</div>
|
|
||||||
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
|
||||||
avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">Load</div>
|
|
||||||
<span class="stat-val" style="font-size:1.6rem;">{% if hostlvl.get('load_1m') is not none %}{{ "%.2f"|format(hostlvl.get('load_1m')) }}{% else %}—{% endif %}</span>
|
|
||||||
<div style="margin-top:0.45rem;font-size:0.72rem;color:var(--text-dim);">
|
|
||||||
5m {% if hostlvl.get('load_5m') is not none %}{{ "%.2f"|format(hostlvl.get('load_5m')) }}{% else %}—{% endif %} ·
|
|
||||||
15m {% if hostlvl.get('load_15m') is not none %}{{ "%.2f"|format(hostlvl.get('load_15m')) }}{% else %}—{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">Network</div>
|
|
||||||
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
|
||||||
<div><span style="color:var(--green);">↓</span> {{ fmt_bps(hostlvl.get('net_rx_bps')) }}</div>
|
|
||||||
<div><span style="color:var(--red);">↑</span> {{ fmt_bps(hostlvl.get('net_tx_bps')) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">Disk I/O</div>
|
|
||||||
<div style="font-size:0.95rem;font-variant-numeric:tabular-nums;">
|
|
||||||
<div><span style="color:var(--text-dim);">rd</span> {{ fmt_bps(hostlvl.get('disk_read_bps')) }}</div>
|
|
||||||
<div><span style="color:var(--text-dim);">wr</span> {{ fmt_bps(hostlvl.get('disk_write_bps')) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if hostlvl.get('temp_c_max') is not none %}
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;">Temp (max)</div>
|
|
||||||
{% set t = hostlvl.get('temp_c_max') %}
|
|
||||||
<span class="stat-val" style="font-size:1.6rem;color:{% if t < 70 %}var(--green){% elif t < 85 %}var(--yellow){% else %}var(--red){% endif %};">{{ "%.0f"|format(t) }}°C</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if hostlvl.get('psi_mem_some_avg10') is not none %}
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.35rem;" title="Pressure stall — % of time stalled, 10s avg">Pressure (10s)</div>
|
|
||||||
<div style="font-size:0.78rem;font-variant-numeric:tabular-nums;color:var(--text-muted);">
|
|
||||||
<div>mem {{ "%.1f"|format(hostlvl.get('psi_mem_some_avg10')) }}%</div>
|
|
||||||
{% if hostlvl.get('psi_cpu_some_avg10') is not none %}<div>cpu {{ "%.1f"|format(hostlvl.get('psi_cpu_some_avg10')) }}%</div>{% endif %}
|
|
||||||
{% if hostlvl.get('psi_io_some_avg10') is not none %}<div>io {{ "%.1f"|format(hostlvl.get('psi_io_some_avg10')) }}%</div>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ── Per-core CPU ─────────────────────────────────────────────────────────── #}
|
|
||||||
{% if cores %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="section-title" style="margin-bottom:0.6rem;">Per-core CPU</div>
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:0.5rem 0.9rem;">
|
|
||||||
{% for idx, pct in cores %}
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;">
|
|
||||||
<span>core {{ idx }}</span><span>{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{{ bar(pct) }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── Filesystems ──────────────────────────────────────────────────────────── #}
|
|
||||||
{% if mounts %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="section-title" style="margin-bottom:0.6rem;">Filesystems</div>
|
|
||||||
{% for mount, m in mounts.items() %}
|
|
||||||
<div style="margin-bottom:0.6rem;">
|
|
||||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;margin-bottom:0.2rem;">
|
|
||||||
<span style="font-family:ui-monospace,monospace;">{{ mount }}</span>
|
|
||||||
<span style="color:var(--text-muted);">{{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{{ bar(m.get('disk_used_pct')) }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── Interfaces / disks / sensors current detail ──────────────────────────── #}
|
|
||||||
{% if nets or disks_io or temps %}
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;">
|
|
||||||
{% if nets %}
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.5rem;">Interfaces</div>
|
|
||||||
{% for iface, m in nets.items() %}
|
|
||||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;">
|
|
||||||
<span style="font-family:ui-monospace,monospace;">{{ iface }}</span>
|
|
||||||
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;">↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if disks_io %}
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.5rem;">Disks</div>
|
|
||||||
{% for dev, m in disks_io.items() %}
|
|
||||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;">
|
|
||||||
<span style="font-family:ui-monospace,monospace;">{{ dev }}</span>
|
|
||||||
<span style="color:var(--text-muted);font-variant-numeric:tabular-nums;">rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if temps %}
|
|
||||||
<div class="card" style="margin-bottom:0;">
|
|
||||||
<div class="section-title" style="margin-bottom:0.5rem;">Temperatures</div>
|
|
||||||
{% for label, c in temps.items() %}
|
|
||||||
<div style="display:flex;justify-content:space-between;font-size:0.8rem;padding:0.15rem 0;">
|
|
||||||
<span>{{ label }}</span>
|
|
||||||
<span style="color:{% if c is none %}var(--text-dim){% elif c < 70 %}var(--green){% elif c < 85 %}var(--yellow){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">{% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── History charts ───────────────────────────────────────────────────────── #}
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1rem;margin-top:1rem;">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1rem;margin-top:1rem;">
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="section-title" style="margin-bottom:0.5rem;">Utilization % — last {{ range_key }}</div>
|
<div class="section-title" style="margin-bottom:0.5rem;">Utilization % — last <span class="hm-chart-range">{{ current_range }}</span></div>
|
||||||
<div style="height:220px;"><canvas id="chart-util"></canvas></div>
|
<div style="height:220px;"><canvas id="chart-util"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="section-title" style="margin-bottom:0.5rem;">Throughput (B/s) — last {{ range_key }}</div>
|
<div class="section-title" style="margin-bottom:0.5rem;">Throughput (B/s) — last <span class="hm-chart-range">{{ current_range }}</span></div>
|
||||||
<div style="height:220px;"><canvas id="chart-net"></canvas></div>
|
<div style="height:220px;"><canvas id="chart-net"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div class="section-title" style="margin-bottom:0.5rem;">Load & pressure — last {{ range_key }}</div>
|
<div class="section-title" style="margin-bottom:0.5rem;">Load & pressure — last <span class="hm-chart-range">{{ current_range }}</span></div>
|
||||||
<div style="height:220px;"><canvas id="chart-pressure"></canvas></div>
|
<div style="height:220px;"><canvas id="chart-pressure"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{# Hidden poller: fetches the history series and applies it to the charts above
|
||||||
|
(no visible swap). Lazy on load → never blocks paint; refreshed slowly + on
|
||||||
|
range change. #}
|
||||||
|
<div id="hm-charts-data" style="display:none;"
|
||||||
|
hx-get="/plugins/host_agent/{{ host.id }}/charts"
|
||||||
|
hx-trigger="load, every {{ chart_poll_seconds }}s, rangeChange from:body"
|
||||||
|
hx-include="#time-range"
|
||||||
|
hx-swap="innerHTML"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const series = {{ series|tojson }};
|
|
||||||
const fmtTime = (v) => { const d = new Date(v); return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); };
|
const fmtTime = (v) => { const d = new Date(v); return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); };
|
||||||
const baseOpts = (ymax) => ({
|
const baseOpts = (ymax) => ({
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
animation: false, // no grow-in / re-animate on refresh
|
||||||
interaction: { mode: "index", intersect: false },
|
interaction: { mode: "index", intersect: false },
|
||||||
elements: { point: { radius: 0 } },
|
elements: { point: { radius: 0 } },
|
||||||
scales: {
|
scales: {
|
||||||
@@ -218,39 +59,50 @@
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
|
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
|
||||||
tooltip: { callbacks: { title: function (items) { return items.length ? fmtTime(items[0].parsed.x) : ""; } } },
|
tooltip: { callbacks: { title: (items) => items.length ? fmtTime(items[0].parsed.x) : "" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mk = (id, datasets, ymax) => {
|
const defs = {
|
||||||
const el = document.getElementById(id);
|
"chart-util": { ymax: 100, ds: [
|
||||||
if (!el) return;
|
|
||||||
new Chart(el.getContext("2d"), {
|
|
||||||
type: "line",
|
|
||||||
data: { datasets: datasets.map((d) => ({
|
|
||||||
label: d.label,
|
|
||||||
data: (series[d.key] || []).map((p) => ({ x: p[0], y: p[1] })),
|
|
||||||
borderColor: d.color, backgroundColor: d.color, borderWidth: 1.5, tension: 0.25,
|
|
||||||
})) },
|
|
||||||
options: baseOpts(ymax),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
mk("chart-util", [
|
|
||||||
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
|
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
|
||||||
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
|
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
|
||||||
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" },
|
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" },
|
||||||
], 100);
|
] },
|
||||||
mk("chart-net", [
|
"chart-net": { ymax: undefined, ds: [
|
||||||
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" },
|
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" },
|
||||||
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" },
|
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" },
|
||||||
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" },
|
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" },
|
||||||
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" },
|
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" },
|
||||||
], undefined);
|
] },
|
||||||
mk("chart-pressure", [
|
"chart-pressure": { ymax: undefined, ds: [
|
||||||
{ key: "load_1m", label: "Load 1m", color: "#c8a840" },
|
{ key: "load_1m", label: "Load 1m", color: "#c8a840" },
|
||||||
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" },
|
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" },
|
||||||
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" },
|
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" },
|
||||||
], undefined);
|
] },
|
||||||
|
};
|
||||||
|
const charts = {};
|
||||||
|
for (const id of Object.keys(defs)) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) continue;
|
||||||
|
charts[id] = new Chart(el.getContext("2d"), {
|
||||||
|
type: "line",
|
||||||
|
data: { datasets: defs[id].ds.map((d) => ({ label: d.label, data: [], borderColor: d.color, backgroundColor: d.color, borderWidth: 1.5, tension: 0.25 })) },
|
||||||
|
options: baseOpts(defs[id].ymax),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Called by the polled /charts data fragment. Updates data in place (no
|
||||||
|
// re-create, no animation), so refreshes never flicker or shift layout.
|
||||||
|
window.applyHostSeries = function (series, rangeKey) {
|
||||||
|
for (const id of Object.keys(defs)) {
|
||||||
|
const ch = charts[id];
|
||||||
|
if (!ch) continue;
|
||||||
|
defs[id].ds.forEach((d, i) => {
|
||||||
|
ch.data.datasets[i].data = (series[d.key] || []).map((p) => ({ x: p[0], y: p[1] }));
|
||||||
|
});
|
||||||
|
ch.update("none");
|
||||||
|
}
|
||||||
|
if (rangeKey) document.querySelectorAll(".hm-chart-range").forEach((s) => { s.textContent = rangeKey; });
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ cat > "$CONF_FILE" <<EOF
|
|||||||
url = $STEWARD_URL
|
url = $STEWARD_URL
|
||||||
token = $AGENT_TOKEN
|
token = $AGENT_TOKEN
|
||||||
interval_seconds = 30
|
interval_seconds = 30
|
||||||
|
# Container logs are collected by default. To opt this host out entirely:
|
||||||
|
# docker_logs_enabled = false
|
||||||
|
# To skip specific noisy containers (comma-separated names):
|
||||||
|
# docker_log_exclude = watchtower, some-chatty-service
|
||||||
EOF
|
EOF
|
||||||
chown "root:$AGENT_USER" "$CONF_FILE"
|
chown "root:$AGENT_USER" "$CONF_FILE"
|
||||||
chmod 0640 "$CONF_FILE"
|
chmod 0640 "$CONF_FILE"
|
||||||
|
|||||||
@@ -14,39 +14,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if reporting %}
|
{% if reporting %}
|
||||||
{# ── Reporting: at-a-glance metrics (+ sparkline trend) + lifecycle ── #}
|
{# Reporting: live vitals are shown by the vitals strip above; this panel is
|
||||||
{% set cpu = hostlvl.get('cpu_pct') %}
|
lifecycle/management only. #}
|
||||||
{% set mem = hostlvl.get('mem_used_pct') %}
|
|
||||||
{% set lbl = "font-size:0.72rem;color:var(--text-muted);text-transform:uppercase;" %}
|
|
||||||
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.6rem;">
|
|
||||||
<div title="Average CPU utilization across all cores">
|
|
||||||
<div style="{{ lbl }}">CPU</div>
|
|
||||||
<div style="font-weight:600;{{ threshold_style(cpu, 'cpu') }}">{{ '%.0f%%'|format(cpu) if cpu is not none else '—' }}</div>
|
|
||||||
{{ sparks.cpu | safe }}</div>
|
|
||||||
<div title="RAM in use (total minus available; cache/buffers count as free)">
|
|
||||||
<div style="{{ lbl }}">Memory</div>
|
|
||||||
<div style="font-weight:600;{{ threshold_style(mem, 'mem') }}">{{ '%.0f%%'|format(mem) if mem is not none else '—' }}</div>
|
|
||||||
{{ sparks.mem | safe }}</div>
|
|
||||||
<div title="Root filesystem (/) usage — see Full metrics for every mount">
|
|
||||||
<div style="{{ lbl }}">Disk /</div>
|
|
||||||
<div style="font-weight:600;{{ threshold_style(disk_root, 'disk') }}">{{ '%.0f%%'|format(disk_root) if disk_root is not none else '—' }}</div>
|
|
||||||
{{ sparks.disk | safe }}</div>
|
|
||||||
{# Load /core: 100% = run queue matches CPU capacity, so warn 80 / crit 100. #}
|
|
||||||
<div title="1-minute load average ÷ {{ cores or '?' }} CPU cores (100% = run queue matches capacity). Raw 1m load: {{ '%.2f'|format(load1) if load1 is not none else '—' }}.">
|
|
||||||
<div style="{{ lbl }}">Load /core</div>
|
|
||||||
<div style="font-weight:600;{{ threshold_style(load_per_core, 'load') }}">{{ '%d%%'|format(load_per_core) if load_per_core is not none else ('%.2f'|format(load1) if load1 is not none else '—') }}</div>
|
|
||||||
{{ sparks.load | safe }}</div>
|
|
||||||
</div>
|
|
||||||
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
|
|
||||||
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:0.9rem;"
|
|
||||||
title="Pressure Stall Information — % of the last 10s that tasks were stalled waiting for the resource. Hardware-independent, comparable across hosts.">
|
|
||||||
<span style="text-transform:uppercase;font-size:0.7rem;letter-spacing:0.04em;color:var(--text-dim);">Pressure 10s</span>
|
|
||||||
CPU {{ '%.0f%%'|format(psi.cpu) if psi.cpu is not none else '—' }}
|
|
||||||
· Mem {{ '%.0f%%'|format(psi.mem) if psi.mem is not none else '—' }}
|
|
||||||
· IO {{ '%.0f%%'|format(psi.io) if psi.io is not none else '—' }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
|
||||||
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
|
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
|
||||||
{% if session.user_role == 'admin' %}
|
{% if session.user_role == 'admin' %}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;gap:1rem;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;gap:1rem;flex-wrap:wrap;">
|
||||||
<h1 class="page-title" style="margin-bottom:0;">Agent fleet</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Agent fleet</h1>
|
||||||
<a href="/hosts/" class="btn btn-ghost btn-sm">← Hosts</a>
|
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--text-muted);font-size:0.84rem;margin:-0.4rem 0 1.25rem;">
|
<p style="color:var(--text-muted);font-size:0.84rem;margin:-0.4rem 0 1.25rem;">
|
||||||
Bulk agent operations across your inventory. For a single host, use its
|
Bulk agent operations across your inventory. For a single host, use its
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ def get_scheduled_tasks() -> list:
|
|||||||
def get_blueprint():
|
def get_blueprint():
|
||||||
from .routes import snmp_bp
|
from .routes import snmp_bp
|
||||||
return snmp_bp
|
return snmp_bp
|
||||||
|
|
||||||
|
|
||||||
|
def get_nav() -> list[dict]:
|
||||||
|
# Surfaces in the sidebar's Infrastructure group (SNMP device readings).
|
||||||
|
return [{"label": "SNMP", "href": "/plugins/snmp/"}]
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ tags:
|
|||||||
config:
|
config:
|
||||||
poll_interval_seconds: 60
|
poll_interval_seconds: 60
|
||||||
# Each device is polled independently. OIDs must resolve to numeric values.
|
# Each device is polled independently. OIDs must resolve to numeric values.
|
||||||
|
#
|
||||||
|
# `host` is the SNMP poll target (IP/hostname we query). A device also shows
|
||||||
|
# up on a Steward Host's detail page when it maps to that host. By default
|
||||||
|
# that mapping is implicit: `host` is matched against the Host's address or
|
||||||
|
# name. To bind explicitly instead — e.g. when you poll by IP but the Host is
|
||||||
|
# recorded by DNS name, or for an SNMP-only switch/PDU/UPS — add ONE of:
|
||||||
|
# host_id: "<steward-host-uuid>" # exact Host.id match
|
||||||
|
# steward_host: "switch01" # Host name OR address (case-insensitive)
|
||||||
|
# An explicit binding is exclusive: the device then maps ONLY to that host,
|
||||||
|
# never implicitly to another by a coincidental address string.
|
||||||
devices:
|
devices:
|
||||||
- name: "core-switch"
|
- name: "core-switch"
|
||||||
host: "192.168.1.1"
|
host: "192.168.1.1"
|
||||||
|
|||||||
+94
-17
@@ -1,11 +1,13 @@
|
|||||||
# plugins/snmp/poller.py
|
# plugins/snmp/poller.py
|
||||||
"""
|
"""
|
||||||
Synchronous SNMP GET helper, run via executor.
|
Asynchronous SNMP GET helper.
|
||||||
|
|
||||||
Requires pysnmp-lextudio (maintained pysnmp fork):
|
Requires pysnmp-lextudio (the maintained pysnmp fork), bundled into the Docker
|
||||||
|
image via the `snmp` extra (`pip install .[snmp]`):
|
||||||
pip install 'steward[snmp]'
|
pip install 'steward[snmp]'
|
||||||
|
|
||||||
If pysnmp is not installed, poll_device() returns an empty dict and logs a warning.
|
If pysnmp is not installed, poll_device() returns an empty dict and logs a
|
||||||
|
warning — SNMP polling is then simply disabled, nothing else breaks.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
@@ -22,39 +24,114 @@ def _pysnmp_available() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _mp_model(version: str) -> int:
|
def _mp_model(version: str) -> int:
|
||||||
"""Map version string to pysnmp mpModel integer."""
|
"""Map an SNMP version string to pysnmp's mpModel int (0 = v1, 1 = v2c)."""
|
||||||
return 0 if version == "1" else 1
|
return 0 if version == "1" else 1
|
||||||
|
|
||||||
|
|
||||||
def poll_device_sync(
|
def _close_engine(engine) -> None:
|
||||||
|
"""Release the engine's UDP transport socket.
|
||||||
|
|
||||||
|
pysnmp opens a UDP socket per ``SnmpEngine`` and never closes it on its own.
|
||||||
|
Since we build a fresh engine for every poll, an unclosed engine leaks one
|
||||||
|
file descriptor each scheduler tick; over hours of polling that exhausts the
|
||||||
|
process fd limit (``OSError: [Errno 24] Too many open files``), which also
|
||||||
|
takes down the app's listening socket. So close it explicitly here.
|
||||||
|
|
||||||
|
The close method/attribute names differ across pysnmp majors (6.2.x lextudio
|
||||||
|
is camelCase, canonical 7.x is snake_case), so probe for whatever exists.
|
||||||
|
All paths are best-effort — a failed close must never break the poll loop.
|
||||||
|
"""
|
||||||
|
# 7.x exposes a convenience close directly on the engine.
|
||||||
|
for meth in ("close_dispatcher", "closeDispatcher"):
|
||||||
|
fn = getattr(engine, meth, None)
|
||||||
|
if callable(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
# 6.2.x: go through the transport dispatcher.
|
||||||
|
for attr in ("transport_dispatcher", "transportDispatcher"):
|
||||||
|
dispatcher = getattr(engine, attr, None)
|
||||||
|
if dispatcher is None:
|
||||||
|
continue
|
||||||
|
for meth in ("close_dispatcher", "closeDispatcher"):
|
||||||
|
fn = getattr(dispatcher, meth, None)
|
||||||
|
if callable(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_device(
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
community: str,
|
community: str,
|
||||||
version: str,
|
version: str,
|
||||||
oids: list[dict],
|
oids: list[dict],
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
"""
|
"""Perform an SNMP GET for each OID and return ``{label: float_value}``.
|
||||||
Perform SNMP GET for each OID and return {label: float_value}.
|
|
||||||
Non-numeric OIDs (strings, etc.) are skipped.
|
Non-numeric OIDs (strings, etc.) are skipped. Returns an empty dict on any
|
||||||
Returns empty dict on any error.
|
error (unreachable host, wrong community, …) so a flaky device never breaks
|
||||||
|
the poll loop.
|
||||||
|
|
||||||
|
pysnmp's HLAPI is asyncio-only as of v6. The import path moved between major
|
||||||
|
versions, so we support both rather than let a dependency bump silently
|
||||||
|
re-break polling:
|
||||||
|
• pysnmp-lextudio 6.2.x → ``pysnmp.hlapi.asyncio`` (``getCmd`` + a directly
|
||||||
|
constructed ``UdpTransportTarget``).
|
||||||
|
• canonical pysnmp 7.x → ``pysnmp.hlapi.v3arch.asyncio`` (``get_cmd`` + the
|
||||||
|
async ``UdpTransportTarget.create``).
|
||||||
"""
|
"""
|
||||||
if not _pysnmp_available():
|
if not _pysnmp_available():
|
||||||
logger.warning("pysnmp not installed — SNMP polling disabled. "
|
logger.warning("pysnmp not installed — SNMP polling disabled. "
|
||||||
"Install with: pip install 'steward[snmp]'")
|
"Install with: pip install 'steward[snmp]'")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
from pysnmp.hlapi import (
|
try:
|
||||||
|
# canonical pysnmp 7.x
|
||||||
|
from pysnmp.hlapi.v3arch.asyncio import (
|
||||||
CommunityData,
|
CommunityData,
|
||||||
ContextData,
|
ContextData,
|
||||||
ObjectIdentity,
|
ObjectIdentity,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
SnmpEngine,
|
SnmpEngine,
|
||||||
UdpTransportTarget,
|
UdpTransportTarget,
|
||||||
getCmd,
|
get_cmd as _get_cmd,
|
||||||
)
|
)
|
||||||
|
_transport_is_async = True
|
||||||
|
except ImportError:
|
||||||
|
# pysnmp-lextudio 6.2.x
|
||||||
|
from pysnmp.hlapi.asyncio import (
|
||||||
|
CommunityData,
|
||||||
|
ContextData,
|
||||||
|
ObjectIdentity,
|
||||||
|
ObjectType,
|
||||||
|
SnmpEngine,
|
||||||
|
UdpTransportTarget,
|
||||||
|
getCmd as _get_cmd,
|
||||||
|
)
|
||||||
|
_transport_is_async = False
|
||||||
|
|
||||||
|
engine = SnmpEngine()
|
||||||
|
|
||||||
|
# Always release the engine's UDP socket — see _close_engine for why.
|
||||||
|
try:
|
||||||
|
# Same host/port for every OID on this device, so build the transport once.
|
||||||
|
try:
|
||||||
|
if _transport_is_async:
|
||||||
|
transport = await UdpTransportTarget.create((host, port), timeout=5, retries=1)
|
||||||
|
else:
|
||||||
|
transport = UdpTransportTarget((host, port), timeout=5, retries=1)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("SNMP transport setup failed for %s:%s: %s", host, port, exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
results: dict[str, float] = {}
|
results: dict[str, float] = {}
|
||||||
engine = SnmpEngine()
|
|
||||||
|
|
||||||
for oid_cfg in oids:
|
for oid_cfg in oids:
|
||||||
oid = oid_cfg["oid"]
|
oid = oid_cfg["oid"]
|
||||||
@@ -62,15 +139,13 @@ def poll_device_sync(
|
|||||||
scale = float(oid_cfg.get("scale", 1.0))
|
scale = float(oid_cfg.get("scale", 1.0))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
error_indication, error_status, error_index, var_binds = next(
|
error_indication, error_status, error_index, var_binds = await _get_cmd(
|
||||||
getCmd(
|
|
||||||
engine,
|
engine,
|
||||||
CommunityData(community, mpModel=_mp_model(version)),
|
CommunityData(community, mpModel=_mp_model(version)),
|
||||||
UdpTransportTarget((host, port), timeout=5, retries=1),
|
transport,
|
||||||
ContextData(),
|
ContextData(),
|
||||||
ObjectType(ObjectIdentity(oid)),
|
ObjectType(ObjectIdentity(oid)),
|
||||||
)
|
)
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("SNMP GET %s@%s OID %s failed: %s", host, port, oid, exc)
|
logger.debug("SNMP GET %s@%s OID %s failed: %s", host, port, oid, exc)
|
||||||
continue
|
continue
|
||||||
@@ -88,7 +163,9 @@ def poll_device_sync(
|
|||||||
try:
|
try:
|
||||||
results[label] = float(val) * scale
|
results[label] = float(val) * scale
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
# Non-numeric type (e.g. OctetString description) — skip
|
# Non-numeric type (e.g. OctetString description) — skip.
|
||||||
logger.debug("SNMP non-numeric value for %s label=%s: %r", oid, label, val)
|
logger.debug("SNMP non-numeric value for %s label=%s: %r", oid, label, val)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
finally:
|
||||||
|
_close_engine(engine)
|
||||||
|
|||||||
@@ -93,6 +93,75 @@ async def index():
|
|||||||
poll_interval=poll_interval)
|
poll_interval=poll_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_binds_to_host(d: dict, keys: set[str], host_id: str) -> bool:
|
||||||
|
"""Does config device `d` belong on the host identified by `keys`
|
||||||
|
(lowercased {address, name}) / `host_id`?
|
||||||
|
|
||||||
|
A device may declare an **explicit** binding that decouples "which Steward
|
||||||
|
host this belongs to" from "where to poll" (the `host` field):
|
||||||
|
• `host_id` — the Steward Host UUID (exact match), the explicit link.
|
||||||
|
• `steward_host` — a friendly bind by Host name or address (case-insensitive).
|
||||||
|
An explicit binding is **exclusive**: a device bound to host A must not also
|
||||||
|
match host B by a coincidental poll-target string. Only when no explicit
|
||||||
|
binding is present do we fall back to the implicit `host`-string match
|
||||||
|
(backward compatible with configs that only set `host`).
|
||||||
|
"""
|
||||||
|
explicit_id = str(d.get("host_id", "")).strip()
|
||||||
|
explicit_name = str(d.get("steward_host", "")).strip().lower()
|
||||||
|
if explicit_id or explicit_name:
|
||||||
|
if explicit_id and host_id and explicit_id == host_id:
|
||||||
|
return True
|
||||||
|
return bool(explicit_name and explicit_name in keys)
|
||||||
|
return str(d.get("host", "")).strip().lower() in keys
|
||||||
|
|
||||||
|
|
||||||
|
def _devices_for_host(devices_cfg: list, address: str | None, name: str | None,
|
||||||
|
host_id: str | None = None) -> list[dict]:
|
||||||
|
"""SNMP devices that map onto a Steward host's page. Prefers an explicit
|
||||||
|
per-device binding (`host_id` / `steward_host`); otherwise falls back to
|
||||||
|
matching the device's `host` (poll target) against the host's address or
|
||||||
|
name (case-insensitive). See `_device_binds_to_host` for the precedence."""
|
||||||
|
keys = {(address or "").strip().lower(), (name or "").strip().lower()}
|
||||||
|
keys.discard("")
|
||||||
|
hid = (host_id or "").strip()
|
||||||
|
return [
|
||||||
|
d for d in devices_cfg
|
||||||
|
if isinstance(d, dict) and _device_binds_to_host(d, keys, hid)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@snmp_bp.get("/host/<host_id>")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def host_panel(host_id: str):
|
||||||
|
"""Per-host SNMP fragment for the Hosts hub. Surfaces any configured SNMP
|
||||||
|
device that maps to this host (by address or name) with its latest readings.
|
||||||
|
Renders nothing when no device maps, so hosts without SNMP carry no empty card.
|
||||||
|
"""
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", [])
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
host = (await db.execute(
|
||||||
|
select(Host).where(Host.id == host_id))).scalar_one_or_none()
|
||||||
|
if host is None:
|
||||||
|
return ""
|
||||||
|
matched = _devices_for_host(devices_cfg, host.address, host.name, host.id)
|
||||||
|
if not matched:
|
||||||
|
return ""
|
||||||
|
names = [d.get("name") or d.get("host", "?") for d in matched]
|
||||||
|
latest = await _latest_readings(db, names)
|
||||||
|
|
||||||
|
devices = [{
|
||||||
|
"name": d.get("name") or d.get("host", "?"),
|
||||||
|
"host": d.get("host", ""),
|
||||||
|
"oids": d.get("oids", []),
|
||||||
|
"readings": latest.get(d.get("name") or d.get("host", "?"), {}),
|
||||||
|
"bound": bool(str(d.get("host_id", "")).strip()
|
||||||
|
or str(d.get("steward_host", "")).strip()),
|
||||||
|
} for d in matched]
|
||||||
|
return await render_template("snmp/host_panel.html", devices=devices)
|
||||||
|
|
||||||
|
|
||||||
@snmp_bp.get("/widget")
|
@snmp_bp.get("/widget")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def widget():
|
async def widget():
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# plugins/snmp/scheduler.py
|
# plugins/snmp/scheduler.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -27,15 +26,13 @@ def make_poll_task(app: "Quart") -> ScheduledTask:
|
|||||||
|
|
||||||
|
|
||||||
async def _do_poll(app: "Quart") -> None:
|
async def _do_poll(app: "Quart") -> None:
|
||||||
from .poller import poll_device_sync
|
from .poller import poll_device
|
||||||
from steward.core.alerts import record_metric
|
from steward.core.alerts import record_metric
|
||||||
|
|
||||||
devices: list[dict] = app.config["PLUGINS"]["snmp"].get("devices", [])
|
devices: list[dict] = app.config["PLUGINS"]["snmp"].get("devices", [])
|
||||||
if not devices:
|
if not devices:
|
||||||
return
|
return
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
async with app.db_sessionmaker() as session:
|
async with app.db_sessionmaker() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
for device in devices:
|
for device in devices:
|
||||||
@@ -52,11 +49,7 @@ async def _do_poll(app: "Quart") -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
readings = await loop.run_in_executor(
|
readings = await poll_device(host, port, community, version, oids)
|
||||||
None,
|
|
||||||
poll_device_sync,
|
|
||||||
host, port, community, version, oids,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("SNMP poll failed for device %s (%s)", name, host)
|
logger.exception("SNMP poll failed for device %s (%s)", name, host)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{# plugins/snmp/templates/snmp/device.html #}
|
{# plugins/snmp/templates/snmp/device.html #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs %}
|
||||||
{% block title %}SNMP — {{ device_name }} — Steward{% endblock %}
|
{% block title %}SNMP — {{ device_name }} — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("SNMP", "/plugins/snmp/"), (device_name, "")]) }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||||
<a href="/plugins/snmp/" style="color:var(--text-muted);font-size:0.85rem;">← SNMP</a>
|
|
||||||
<h1 class="page-title" style="margin:0;">{{ device_name }}</h1>
|
<h1 class="page-title" style="margin:0;">{{ device_name }}</h1>
|
||||||
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
|
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
|
||||||
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{# Per-host SNMP fragment — embedded on the Hosts hub via HTMX. Self-contained.
|
||||||
|
Shows the SNMP device(s) whose address maps to this host + latest readings. #}
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.6rem;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<h3 class="section-title" style="margin-bottom:0;">SNMP</h3>
|
||||||
|
<a href="/plugins/snmp/" style="font-size:0.8rem;color:var(--text-muted);">All devices →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for device in devices %}
|
||||||
|
<div style="{% if not loop.last %}margin-bottom:1rem;{% endif %}">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem;flex-wrap:wrap;">
|
||||||
|
<span style="font-weight:600;font-size:0.92rem;">{{ device.name }}</span>
|
||||||
|
<span style="font-size:0.78rem;color:var(--text-dim);font-family:ui-monospace,monospace;">{{ device.host }}</span>
|
||||||
|
{% if device.bound %}
|
||||||
|
<span title="Explicitly bound to this host (host_id / steward_host)"
|
||||||
|
style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:3px;padding:0.05rem 0.4rem;">bound</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if device.readings %}
|
||||||
|
<span style="font-size:0.74rem;color:var(--green);">● reachable</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:0.74rem;color:var(--text-dim);">○ no data yet</span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-ghost btn-sm"
|
||||||
|
style="margin-left:auto;font-size:0.76rem;padding:0.15rem 0.55rem;">History</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if device.readings %}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.5rem;">
|
||||||
|
{% for oid_cfg in device.oids %}
|
||||||
|
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
|
||||||
|
{% set val = device.readings.get(label) %}
|
||||||
|
<div style="background:var(--bg-elevated);border-radius:5px;padding:0.5rem 0.7rem;border:1px solid var(--border);">
|
||||||
|
<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</div>
|
||||||
|
{% if val is not none %}
|
||||||
|
<div style="font-size:1rem;font-weight:600;font-variant-numeric:tabular-nums;">
|
||||||
|
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.72rem;color:var(--text-muted);">M</span>
|
||||||
|
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.72rem;color:var(--text-muted);">K</span>
|
||||||
|
{% else %}{{ "%.4g"|format(val) }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:0.85rem;color:var(--text-dim);">—</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="font-size:0.8rem;color:var(--text-dim);margin:0;">No readings yet — waiting for the next poll.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -28,3 +28,8 @@ def get_scheduled_tasks() -> list:
|
|||||||
def get_blueprint():
|
def get_blueprint():
|
||||||
from .routes import traefik_bp
|
from .routes import traefik_bp
|
||||||
return traefik_bp
|
return traefik_bp
|
||||||
|
|
||||||
|
|
||||||
|
def get_nav() -> list[dict]:
|
||||||
|
# Surfaces in the sidebar's Infrastructure group (Traefik services view).
|
||||||
|
return [{"label": "Traefik", "href": "/plugins/traefik/"}]
|
||||||
|
|||||||
@@ -26,3 +26,8 @@ def get_scheduled_tasks() -> list:
|
|||||||
def get_blueprint():
|
def get_blueprint():
|
||||||
from .routes import unifi_bp
|
from .routes import unifi_bp
|
||||||
return unifi_bp
|
return unifi_bp
|
||||||
|
|
||||||
|
|
||||||
|
def get_nav() -> list[dict]:
|
||||||
|
# Surfaces in the sidebar's Infrastructure group (UniFi network overview).
|
||||||
|
return [{"label": "UniFi", "href": "/plugins/unifi/"}]
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ logger = logging.getLogger(__name__)
|
|||||||
_client = None # UnifiClient instance, initialised on first scrape
|
_client = None # UnifiClient instance, initialised on first scrape
|
||||||
|
|
||||||
|
|
||||||
|
async def _drop_client() -> None:
|
||||||
|
"""Close and discard the cached client.
|
||||||
|
|
||||||
|
The client holds a long-lived ``httpx.AsyncClient`` (a connection pool over
|
||||||
|
real sockets). Nulling the reference without ``aclose()`` orphans that pool
|
||||||
|
until GC, so every failure tick that re-auths would leak file descriptors —
|
||||||
|
the same class of bug that took the app down via SNMP (Errno 24, "Too many
|
||||||
|
open files"). Close first, then drop. Best-effort: a failed close must not
|
||||||
|
break the poll loop.
|
||||||
|
"""
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
try:
|
||||||
|
await _client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
def make_poll_task(app: "Quart") -> ScheduledTask:
|
def make_poll_task(app: "Quart") -> ScheduledTask:
|
||||||
cfg = app.config["PLUGINS"]["unifi"]
|
cfg = app.config["PLUGINS"]["unifi"]
|
||||||
interval = int(cfg.get("poll_interval_seconds", 60))
|
interval = int(cfg.get("poll_interval_seconds", 60))
|
||||||
@@ -51,7 +70,7 @@ async def _do_poll(app: "Quart") -> None:
|
|||||||
await _client.login()
|
await _client.login()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("UniFi initial login failed: %s", exc)
|
logger.warning("UniFi initial login failed: %s", exc)
|
||||||
_client = None
|
await _drop_client()
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -60,7 +79,7 @@ async def _do_poll(app: "Quart") -> None:
|
|||||||
devices = await _client.get_devices()
|
devices = await _client.get_devices()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("UniFi poll failed — will retry next tick: %s", exc)
|
logger.warning("UniFi poll failed — will retry next tick: %s", exc)
|
||||||
_client = None # force re-auth on next tick
|
await _drop_client() # close + force re-auth on next tick
|
||||||
return
|
return
|
||||||
|
|
||||||
scraped_at = datetime.now(timezone.utc)
|
scraped_at = datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -1,29 +1,64 @@
|
|||||||
---
|
---
|
||||||
# description: Reclaim disk on Docker / Swarm nodes by pruning unused images, containers, networks and build cache.
|
# description: Reclaim disk on Docker / Swarm nodes — prune stopped containers, unused images, or a full system prune.
|
||||||
# steward:category: maintenance
|
# steward:category: maintenance
|
||||||
# steward:confirm: true
|
# steward:confirm: true
|
||||||
# Reclaim disk on Docker / Docker Swarm nodes by removing unused data.
|
# Reclaim disk on Docker / Docker Swarm nodes by removing unused data.
|
||||||
# Safe by default: prunes dangling images, stopped containers, unused networks
|
# `prune_target` selects the scope (default `system` preserves the original
|
||||||
# and build cache. Set extra-vars to widen scope:
|
# behavior for existing manual/scheduled callers that don't set it):
|
||||||
# prune_all_images=true also remove ALL unused images (not just dangling)
|
# prune_target=containers remove stopped containers only (docker container prune)
|
||||||
# prune_volumes=true also remove unused named volumes (data loss risk)
|
# prune_target=images remove unused images (docker image prune;
|
||||||
- name: Docker system prune
|
# + prune_all_images=true → -a, i.e. ALL unused, not just dangling)
|
||||||
|
# prune_target=system docker system prune (dangling images, stopped
|
||||||
|
# containers, unused networks, build cache). Widen with:
|
||||||
|
# prune_all_images=true also ALL unused images
|
||||||
|
# prune_volumes=true also unused named volumes (data loss risk)
|
||||||
|
- name: Docker prune
|
||||||
hosts: all
|
hosts: all
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
become: true
|
become: true
|
||||||
vars:
|
vars:
|
||||||
|
prune_target: system
|
||||||
prune_all_images: false
|
prune_all_images: false
|
||||||
prune_volumes: false
|
prune_volumes: false
|
||||||
tasks:
|
tasks:
|
||||||
|
- name: Validate prune_target
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: prune_target in ['containers', 'images', 'system']
|
||||||
|
fail_msg: "prune_target must be one of: containers, images, system (got '{{ prune_target }}')"
|
||||||
|
quiet: true
|
||||||
|
|
||||||
|
# ── Stopped containers only ───────────────────────────────────────────────
|
||||||
|
- name: Prune stopped containers
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: ['docker', 'container', 'prune', '-f']
|
||||||
|
register: container_prune
|
||||||
|
changed_when: "'Total reclaimed space: 0B' not in container_prune.stdout"
|
||||||
|
when: prune_target == 'containers'
|
||||||
|
|
||||||
|
# ── Unused images (dangling, or all unused with -a) ───────────────────────
|
||||||
|
- name: Prune unused images
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv: >-
|
||||||
|
{{ ['docker', 'image', 'prune', '-f']
|
||||||
|
+ (['-a'] if prune_all_images | bool else []) }}
|
||||||
|
register: image_prune
|
||||||
|
changed_when: "'Total reclaimed space: 0B' not in image_prune.stdout"
|
||||||
|
when: prune_target == 'images'
|
||||||
|
|
||||||
|
# ── Full system prune (default) ───────────────────────────────────────────
|
||||||
- name: Run docker system prune
|
- name: Run docker system prune
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
argv: >-
|
argv: >-
|
||||||
{{ ['docker', 'system', 'prune', '-f']
|
{{ ['docker', 'system', 'prune', '-f']
|
||||||
+ (['-a'] if prune_all_images | bool else [])
|
+ (['-a'] if prune_all_images | bool else [])
|
||||||
+ (['--volumes'] if prune_volumes | bool else []) }}
|
+ (['--volumes'] if prune_volumes | bool else []) }}
|
||||||
register: prune_result
|
register: system_prune
|
||||||
changed_when: "'Total reclaimed space: 0B' not in prune_result.stdout"
|
changed_when: "'Total reclaimed space: 0B' not in system_prune.stdout"
|
||||||
|
when: prune_target == 'system'
|
||||||
|
|
||||||
- name: Report reclaimed space
|
- name: Report reclaimed space
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: "{{ prune_result.stdout_lines | select | list }}"
|
msg: >-
|
||||||
|
{{ ((container_prune.stdout_lines | default([]))
|
||||||
|
+ (image_prune.stdout_lines | default([]))
|
||||||
|
+ (system_prune.stdout_lines | default([]))) | select | list }}
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ async def playbook_vars():
|
|||||||
(vars/vars_prompt), loaded when a playbook is chosen from a dropdown."""
|
(vars/vars_prompt), loaded when a playbook is chosen from a dropdown."""
|
||||||
source_name = (request.args.get("source_name", "") or "").strip()
|
source_name = (request.args.get("source_name", "") or "").strip()
|
||||||
playbook_path = (request.args.get("playbook_path", "") or "").strip()
|
playbook_path = (request.args.get("playbook_path", "") or "").strip()
|
||||||
|
# Schedule form: schedules can't prompt, so secret vars render disabled.
|
||||||
|
schedule = (request.args.get("schedule", "") or "").strip() == "1"
|
||||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||||
variables: list = []
|
variables: list = []
|
||||||
meta: dict = {"description": "", "category": "", "confirm": False}
|
meta: dict = {"description": "", "category": "", "confirm": False}
|
||||||
@@ -182,7 +184,8 @@ async def playbook_vars():
|
|||||||
meta = src_module.discover_playbook_meta(contents)
|
meta = src_module.discover_playbook_meta(contents)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"ansible/_playbook_vars.html", variables=variables,
|
"ansible/_playbook_vars.html", variables=variables,
|
||||||
description=meta["description"], category=meta["category"], confirm=meta["confirm"])
|
description=meta["description"], category=meta["category"],
|
||||||
|
confirm=meta["confirm"], schedule=schedule)
|
||||||
|
|
||||||
|
|
||||||
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
|
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
|
||||||
@@ -370,7 +373,6 @@ async def schedules():
|
|||||||
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
|
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
|
||||||
for s in _get_sources()
|
for s in _get_sources()
|
||||||
]
|
]
|
||||||
all_playbooks = sorted({p for sd in source_data for p in sd["playbooks"]})
|
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
editing = None
|
editing = None
|
||||||
@@ -383,11 +385,31 @@ async def schedules():
|
|||||||
if editing_id and s.id == editing_id:
|
if editing_id and s.id == editing_id:
|
||||||
editing = s
|
editing = s
|
||||||
|
|
||||||
|
# Default the source dropdown to the first source (or the edited schedule's
|
||||||
|
# source), and pre-render that source's playbooks so the playbook dropdown
|
||||||
|
# starts on a real item rather than a placeholder.
|
||||||
|
sel_source = editing.source_name if editing else (source_data[0]["name"] if source_data else "")
|
||||||
|
sel_playbooks = next((sd["playbooks"] for sd in source_data if sd["name"] == sel_source), [])
|
||||||
|
|
||||||
|
# Edit mode: pre-render the selected playbook's declared variables so their
|
||||||
|
# saved values show (fresh loads fetch these via HTMX from /playbook-vars).
|
||||||
|
edit_variables: list = []
|
||||||
|
edit_meta: dict = {"description": "", "category": "", "confirm": False}
|
||||||
|
if editing:
|
||||||
|
src = next((s for s in _get_sources() if s["name"] == editing.source_name), None)
|
||||||
|
if src:
|
||||||
|
contents = src_module.read_playbook(src["path"], editing.playbook_path)
|
||||||
|
if contents is not None:
|
||||||
|
edit_variables = src_module.discover_playbook_variables(contents)
|
||||||
|
edit_meta = src_module.discover_playbook_meta(contents)
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"ansible/schedules.html",
|
"ansible/schedules.html",
|
||||||
rows=rows, groups=groups, targets=targets,
|
rows=rows, groups=groups, targets=targets,
|
||||||
source_data=source_data, all_playbooks=all_playbooks,
|
source_data=source_data, sel_source=sel_source, sel_playbooks=sel_playbooks,
|
||||||
editing=editing, interval_presets=INTERVAL_PRESETS,
|
editing=editing, interval_presets=INTERVAL_PRESETS,
|
||||||
|
edit_variables=edit_variables, edit_description=edit_meta["description"],
|
||||||
|
edit_category=edit_meta["category"], edit_confirm=edit_meta["confirm"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+34
-2
@@ -1,11 +1,14 @@
|
|||||||
# steward/app.py
|
# steward/app.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from quart import Quart, render_template
|
from quart import Quart, render_template
|
||||||
from .config import load_bootstrap
|
from .config import load_bootstrap
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
config_path: Path | str | None = None,
|
config_path: Path | str | None = None,
|
||||||
@@ -50,9 +53,17 @@ def create_app(
|
|||||||
# ── 3b. Encryption-at-rest: bind the encryptor, then encrypt legacy secrets ─
|
# ── 3b. Encryption-at-rest: bind the encryptor, then encrypt legacy secrets ─
|
||||||
if not testing:
|
if not testing:
|
||||||
from .core.crypto import init_crypto
|
from .core.crypto import init_crypto
|
||||||
from .core.settings import migrate_plaintext_secrets
|
from .core.settings import migrate_plaintext_secrets, scan_undecryptable_secrets
|
||||||
init_crypto(app.config["SECRET_KEY"])
|
init_crypto(app.config["SECRET_KEY"])
|
||||||
migrate_plaintext_secrets(app.config["DATABASE_URL"])
|
migrate_plaintext_secrets(app.config["DATABASE_URL"])
|
||||||
|
# Flag any secrets sealed under a now-lost key (see the admin banner).
|
||||||
|
undecryptable = scan_undecryptable_secrets(app.config["DATABASE_URL"])
|
||||||
|
if undecryptable:
|
||||||
|
logger.warning(
|
||||||
|
"%d stored secret(s) cannot be decrypted with the current app key "
|
||||||
|
"(re-enter them in Settings): %s",
|
||||||
|
len(undecryptable), ", ".join(undecryptable),
|
||||||
|
)
|
||||||
|
|
||||||
# ── 4. Load all settings from DB → populate app.config ────────────────────
|
# ── 4. Load all settings from DB → populate app.config ────────────────────
|
||||||
if not testing:
|
if not testing:
|
||||||
@@ -166,7 +177,8 @@ def create_app(
|
|||||||
# ── 10. Template context: inject plugin_failures into every response ───────
|
# ── 10. Template context: inject plugin_failures into every response ───────
|
||||||
@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, get_plugin_nav
|
||||||
|
from .core.settings import get_undecryptable_secrets
|
||||||
# enabled_plugins lets templates gate cross-plugin embeds (e.g. the
|
# enabled_plugins lets templates gate cross-plugin embeds (e.g. the
|
||||||
# Hosts hub only fetches the docker fragment when docker is enabled),
|
# Hosts hub only fetches the docker fragment when docker is enabled),
|
||||||
# avoiding a 404 to a route whose blueprint isn't registered.
|
# avoiding a 404 to a route whose blueprint isn't registered.
|
||||||
@@ -174,9 +186,16 @@ def create_app(
|
|||||||
name for name, cfg in (app.config.get("PLUGINS") or {}).items()
|
name for name, cfg in (app.config.get("PLUGINS") or {}).items()
|
||||||
if isinstance(cfg, dict) and cfg.get("enabled")
|
if isinstance(cfg, dict) and cfg.get("enabled")
|
||||||
}
|
}
|
||||||
|
# Sidebar entries for enabled plugins that expose a UI (get_nav). Filter
|
||||||
|
# by enabled so a hot-disabled plugin's link can't linger before restart.
|
||||||
|
plugin_nav = [e for e in get_plugin_nav() if e["plugin"] in enabled_plugins]
|
||||||
return {
|
return {
|
||||||
"plugin_failures": get_plugin_failures(),
|
"plugin_failures": get_plugin_failures(),
|
||||||
"enabled_plugins": enabled_plugins,
|
"enabled_plugins": enabled_plugins,
|
||||||
|
"plugin_nav": plugin_nav,
|
||||||
|
# Read from an in-memory cache (no DB hit per render); kept current by
|
||||||
|
# the startup scan + set_setting's discard-on-write.
|
||||||
|
"undecryptable_secrets": get_undecryptable_secrets(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
||||||
@@ -249,6 +268,10 @@ def _register_core_tasks(app: Quart) -> None:
|
|||||||
from .core.reports import check_and_send
|
from .core.reports import check_and_send
|
||||||
await check_and_send(app)
|
await check_and_send(app)
|
||||||
|
|
||||||
|
async def run_self_monitor():
|
||||||
|
from .core.self_monitor import record_self_metrics
|
||||||
|
await record_self_metrics(app)
|
||||||
|
|
||||||
app._task_registry.extend([
|
app._task_registry.extend([
|
||||||
ScheduledTask(
|
ScheduledTask(
|
||||||
name="weekly_report_check",
|
name="weekly_report_check",
|
||||||
@@ -256,6 +279,15 @@ def _register_core_tasks(app: Quart) -> None:
|
|||||||
interval_seconds=3600,
|
interval_seconds=3600,
|
||||||
run_on_startup=False,
|
run_on_startup=False,
|
||||||
),
|
),
|
||||||
|
# Watch our own fd usage so a descriptor leak surfaces as a metric/alert
|
||||||
|
# instead of a silent Errno 24 lockup. Cheap; plugin_metrics roll up
|
||||||
|
# hourly so the 60s cadence is storage-bounded.
|
||||||
|
ScheduledTask(
|
||||||
|
name="self_monitor",
|
||||||
|
coro_factory=run_self_monitor,
|
||||||
|
interval_seconds=60,
|
||||||
|
run_on_startup=True,
|
||||||
|
),
|
||||||
ScheduledTask(
|
ScheduledTask(
|
||||||
name="monitor_check",
|
name="monitor_check",
|
||||||
coro_factory=run_monitor_checks,
|
coro_factory=run_monitor_checks,
|
||||||
|
|||||||
+26
-7
@@ -75,13 +75,27 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_secret_key(raw: dict) -> str:
|
def _resolve_secret_key(raw: dict) -> str:
|
||||||
"""Resolve secret_key: env var → file → auto-generate."""
|
"""Resolve secret_key: env var → file → auto-generate.
|
||||||
|
|
||||||
|
Refuses to start if a new key must be generated but cannot be persisted: an
|
||||||
|
ephemeral key changes on every restart, which silently renders every
|
||||||
|
encrypted secret (managed SSH key, SMTP/OIDC/LDAP credentials) unrecoverable.
|
||||||
|
Failing loudly with a fix beats limping along and losing data on the next
|
||||||
|
boot — exactly the footgun that bit the vdnt-docker02 deployment.
|
||||||
|
"""
|
||||||
from_env = _env("SECRET_KEY") or raw.get("secret_key")
|
from_env = _env("SECRET_KEY") or raw.get("secret_key")
|
||||||
if from_env:
|
if from_env:
|
||||||
return from_env
|
return from_env
|
||||||
|
|
||||||
if _SECRET_KEY_FILE.exists():
|
if _SECRET_KEY_FILE.exists():
|
||||||
|
try:
|
||||||
key = _SECRET_KEY_FILE.read_text().strip()
|
key = _SECRET_KEY_FILE.read_text().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"App secret key file {_SECRET_KEY_FILE} exists but cannot be read "
|
||||||
|
f"({exc}). Make it readable by the container user (uid 1000), or set "
|
||||||
|
f"STEWARD_SECRET_KEY."
|
||||||
|
) from exc
|
||||||
if key:
|
if key:
|
||||||
return key
|
return key
|
||||||
|
|
||||||
@@ -89,11 +103,16 @@ def _resolve_secret_key(raw: dict) -> str:
|
|||||||
try:
|
try:
|
||||||
_SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
_SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_SECRET_KEY_FILE.write_text(key)
|
_SECRET_KEY_FILE.write_text(key)
|
||||||
logger.info("Generated new secret key and saved to %s", _SECRET_KEY_FILE)
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning(
|
raise RuntimeError(
|
||||||
"Could not write secret key to %s (%s). "
|
f"Generated a new app secret key but could not persist it to "
|
||||||
"Key will not persist across restarts.",
|
f"{_SECRET_KEY_FILE} ({exc}). An ephemeral key changes on every restart, "
|
||||||
_SECRET_KEY_FILE, exc,
|
f"which makes all encrypted secrets (managed SSH key, SMTP/OIDC/LDAP "
|
||||||
)
|
f"credentials) unrecoverable. Fix one of:\n"
|
||||||
|
f" • set STEWARD_SECRET_KEY to a stable value "
|
||||||
|
f"(recommended for Swarm / multi-node), or\n"
|
||||||
|
f" • make {_SECRET_KEY_FILE.parent} writable by the container user "
|
||||||
|
f"(uid 1000)."
|
||||||
|
) from exc
|
||||||
|
logger.info("Generated new secret key and saved to %s", _SECRET_KEY_FILE)
|
||||||
return key
|
return key
|
||||||
|
|||||||
+25
-3
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
|
|||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|
||||||
from steward.models.monitors import MonitorResult
|
from steward.models.monitors import MonitorResult
|
||||||
from steward.models.metrics import PluginMetric
|
|
||||||
from steward.models.ansible import AnsibleRun
|
from steward.models.ansible import AnsibleRun
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -26,7 +25,6 @@ async def run_cleanup(app: "Quart") -> None:
|
|||||||
async with session.begin():
|
async with session.begin():
|
||||||
for model, ts_col in [
|
for model, ts_col in [
|
||||||
(MonitorResult, MonitorResult.checked_at),
|
(MonitorResult, MonitorResult.checked_at),
|
||||||
(PluginMetric, PluginMetric.recorded_at),
|
|
||||||
(AnsibleRun, AnsibleRun.started_at),
|
(AnsibleRun, AnsibleRun.started_at),
|
||||||
]:
|
]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -35,9 +33,29 @@ async def run_cleanup(app: "Quart") -> None:
|
|||||||
if result.rowcount:
|
if result.rowcount:
|
||||||
logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}")
|
logger.info(f"Pruned {result.rowcount} rows from {model.__tablename__}")
|
||||||
|
|
||||||
|
# plugin_metrics is NOT blanket-deleted here — it's rolled up to hourly
|
||||||
|
# then pruned, so multi-week host history stays cheap.
|
||||||
|
await _run_metrics_retention(session, now)
|
||||||
await _run_docker_retention(session, now)
|
await _run_docker_retention(session, now)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_metrics_retention(session, now: datetime) -> None:
|
||||||
|
"""Roll up + prune plugin_metrics (raw → hourly → gone). Windows read fresh
|
||||||
|
from settings each run (rule 25 — UI change takes effect next cleanup, no
|
||||||
|
restart). get_setting's SELECT autobegins, so read inside the begin block."""
|
||||||
|
from steward.core.metrics_retention import rollup_plugin_metrics
|
||||||
|
from steward.core.settings import get_setting
|
||||||
|
|
||||||
|
async with session.begin():
|
||||||
|
raw_days = int(await get_setting(session, "metrics.retention.raw_days") or 7)
|
||||||
|
rollup_days = int(await get_setting(session, "metrics.retention.rollup_days") or 90)
|
||||||
|
counts = await rollup_plugin_metrics(
|
||||||
|
session, raw_days=raw_days, rollup_days=rollup_days, now=now,
|
||||||
|
)
|
||||||
|
if counts and any(counts.values()):
|
||||||
|
logger.info("Metrics retention: %s", counts)
|
||||||
|
|
||||||
|
|
||||||
async def _run_docker_retention(session, now: datetime) -> None:
|
async def _run_docker_retention(session, now: datetime) -> None:
|
||||||
"""Drive the docker plugin's rollup + prune via its capability, if loaded.
|
"""Drive the docker plugin's rollup + prune via its capability, if loaded.
|
||||||
|
|
||||||
@@ -58,10 +76,14 @@ async def _run_docker_retention(session, now: datetime) -> None:
|
|||||||
raw_days = int(await get_setting(session, "docker.retention.metrics_raw_days") or 7)
|
raw_days = int(await get_setting(session, "docker.retention.metrics_raw_days") or 7)
|
||||||
rollup_days = int(await get_setting(session, "docker.retention.metrics_rollup_days") or 90)
|
rollup_days = int(await get_setting(session, "docker.retention.metrics_rollup_days") or 90)
|
||||||
events_days = int(await get_setting(session, "docker.retention.events_days") or 30)
|
events_days = int(await get_setting(session, "docker.retention.events_days") or 30)
|
||||||
|
logs_days = int(await get_setting(session, "docker.logs.retention_days") or 3)
|
||||||
|
logs_cap = int(
|
||||||
|
await get_setting(session, "docker.logs.max_bytes_per_container") or 5_000_000)
|
||||||
counts = await invoke_capability(
|
counts = await invoke_capability(
|
||||||
"docker.run_retention", UserRole.viewer, session,
|
"docker.run_retention", UserRole.viewer, session,
|
||||||
events_days=events_days, metrics_raw_days=raw_days,
|
events_days=events_days, metrics_raw_days=raw_days,
|
||||||
metrics_rollup_days=rollup_days, now=now,
|
metrics_rollup_days=rollup_days, logs_retention_days=logs_days,
|
||||||
|
logs_max_bytes_per_container=logs_cap, now=now,
|
||||||
)
|
)
|
||||||
if counts and any(counts.values()):
|
if counts and any(counts.values()):
|
||||||
logger.info("Docker retention: %s", counts)
|
logger.info("Docker retention: %s", counts)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Bound plugin_metrics growth: roll old raw samples up to hourly, prune the rest.
|
||||||
|
|
||||||
|
plugin_metrics grows by (sources × resources × sample cadence) — host agents push
|
||||||
|
host-level + per-core/mount/iface sub-resources every ~30s, so a fleet accrues
|
||||||
|
millions of rows. We keep raw samples for a short window, aggregate everything
|
||||||
|
older into hourly averages (plugin_metrics_hourly) and delete the raw rows, then
|
||||||
|
prune hourly beyond a longer window. Charts read raw for the recent part of a
|
||||||
|
range and hourly for the older part.
|
||||||
|
|
||||||
|
Driven by the core cleanup task (steward.core.cleanup). Runs inside the caller's
|
||||||
|
open transaction; never opens or commits its own.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def _hour_floor(dt: datetime) -> datetime:
|
||||||
|
"""Truncate a datetime down to the start of its hour (drops min/sec/µs)."""
|
||||||
|
return dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _rollup_cutoff(now: datetime, raw_days: int) -> datetime:
|
||||||
|
"""Hour-aligned boundary below which raw metrics get rolled up + deleted.
|
||||||
|
|
||||||
|
Aligning to the hour means we only roll up *whole* elapsed hours — a bucket
|
||||||
|
is never split across the keep/roll boundary, so a re-run can't produce a
|
||||||
|
partial-then-complete duplicate for the same hour.
|
||||||
|
"""
|
||||||
|
return _hour_floor(now - timedelta(days=raw_days))
|
||||||
|
|
||||||
|
|
||||||
|
async def rollup_plugin_metrics(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
raw_days: int,
|
||||||
|
rollup_days: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Roll up + prune plugin_metrics. Returns a counts dict for logging.
|
||||||
|
|
||||||
|
1. Aggregate plugin_metrics older than the (hour-aligned) raw window into
|
||||||
|
plugin_metrics_hourly (avg/max per source/resource/metric/hour), upserting
|
||||||
|
so a re-run is idempotent, then delete those raw rows.
|
||||||
|
2. Prune rolled-up rows older than the rollup window.
|
||||||
|
"""
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
|
from steward.models.metrics import PluginMetric, PluginMetricHourly
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
rolled = rolled_rows = rollup_pruned = 0
|
||||||
|
|
||||||
|
# ── 1. Roll up raw metrics older than the raw window into hourly buckets ──
|
||||||
|
raw_cutoff = _rollup_cutoff(now, raw_days)
|
||||||
|
hour = func.date_trunc("hour", PluginMetric.recorded_at)
|
||||||
|
agg = (
|
||||||
|
select(
|
||||||
|
PluginMetric.source_module,
|
||||||
|
PluginMetric.resource_name,
|
||||||
|
PluginMetric.metric_name,
|
||||||
|
hour.label("bucket"),
|
||||||
|
func.avg(PluginMetric.value).label("value_avg"),
|
||||||
|
func.max(PluginMetric.value).label("value_max"),
|
||||||
|
func.count().label("sample_count"),
|
||||||
|
)
|
||||||
|
.where(PluginMetric.recorded_at < raw_cutoff)
|
||||||
|
.group_by(
|
||||||
|
PluginMetric.source_module, PluginMetric.resource_name,
|
||||||
|
PluginMetric.metric_name, hour,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for r in (await session.execute(agg)).all():
|
||||||
|
avg_v = float(r.value_avg or 0.0)
|
||||||
|
max_v = float(r.value_max or 0.0)
|
||||||
|
cnt = int(r.sample_count or 0)
|
||||||
|
await session.execute(
|
||||||
|
pg_insert(PluginMetricHourly)
|
||||||
|
.values(
|
||||||
|
source_module=r.source_module, resource_name=r.resource_name,
|
||||||
|
metric_name=r.metric_name, bucket=r.bucket,
|
||||||
|
value_avg=avg_v, value_max=max_v, sample_count=cnt,
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
constraint="uq_plugin_metrics_hourly_bucket",
|
||||||
|
set_={"value_avg": avg_v, "value_max": max_v, "sample_count": cnt},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rolled += 1
|
||||||
|
rolled_rows += cnt
|
||||||
|
if rolled:
|
||||||
|
await session.execute(
|
||||||
|
delete(PluginMetric).where(PluginMetric.recorded_at < raw_cutoff)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2. Prune rolled-up rows beyond the rollup window ──
|
||||||
|
rollup_cutoff = now - timedelta(days=rollup_days)
|
||||||
|
res = await session.execute(
|
||||||
|
delete(PluginMetricHourly).where(PluginMetricHourly.bucket < rollup_cutoff)
|
||||||
|
)
|
||||||
|
rollup_pruned = res.rowcount or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"buckets_rolled": rolled,
|
||||||
|
"raw_rows_rolled": rolled_rows,
|
||||||
|
"rollup_pruned": rollup_pruned,
|
||||||
|
}
|
||||||
@@ -24,12 +24,52 @@ _LOADED_PLUGINS: set[str] = set()
|
|||||||
# Track plugins that failed to load: name → human-readable reason.
|
# Track plugins that failed to load: name → human-readable reason.
|
||||||
_FAILED_PLUGINS: dict[str, str] = {}
|
_FAILED_PLUGINS: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Nav entries contributed by plugins via the optional get_nav() export, so a
|
||||||
|
# plugin's UI gets a home in the sidebar. Each entry:
|
||||||
|
# {"plugin": <name>, "label": str, "href": str, "section": str}
|
||||||
|
_PLUGIN_NAV: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_failures() -> dict[str, str]:
|
def get_plugin_failures() -> dict[str, str]:
|
||||||
"""Return a copy of the failed-plugin registry (name → error message)."""
|
"""Return a copy of the failed-plugin registry (name → error message)."""
|
||||||
return dict(_FAILED_PLUGINS)
|
return dict(_FAILED_PLUGINS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_nav() -> list[dict]:
|
||||||
|
"""Plugin-contributed sidebar entries, sorted by section then label."""
|
||||||
|
return sorted(_PLUGIN_NAV, key=lambda e: (e.get("section", ""), e.get("label", "")))
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_plugin_nav(name: str, module) -> None:
|
||||||
|
"""Pull a plugin's optional get_nav() entries into the nav registry.
|
||||||
|
|
||||||
|
Tolerant by design: a missing hook is fine, and a raising or malformed hook
|
||||||
|
is logged and skipped — a bad nav contribution must never break loading.
|
||||||
|
Idempotent per plugin (drops prior entries first) so hot-reload re-runs cleanly.
|
||||||
|
"""
|
||||||
|
global _PLUGIN_NAV
|
||||||
|
_PLUGIN_NAV = [e for e in _PLUGIN_NAV if e.get("plugin") != name]
|
||||||
|
if not hasattr(module, "get_nav"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
items = module.get_nav() or []
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Plugin %r: get_nav() raised, skipping its nav entries", name)
|
||||||
|
return
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
label, href = str(item["label"]), str(item["href"])
|
||||||
|
except (TypeError, KeyError):
|
||||||
|
logger.warning("Plugin %r: malformed nav item %r, skipping", name, item)
|
||||||
|
continue
|
||||||
|
_PLUGIN_NAV.append({
|
||||||
|
"plugin": name,
|
||||||
|
"label": label,
|
||||||
|
"href": href,
|
||||||
|
"section": str(item.get("section", "Infrastructure")),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def resolve_plugin_path(plugin_dirs: list[Path], name: str) -> Path | None:
|
def resolve_plugin_path(plugin_dirs: list[Path], name: str) -> Path | None:
|
||||||
"""Return the first plugin root that contains `name`, else None.
|
"""Return the first plugin root that contains `name`, else None.
|
||||||
|
|
||||||
@@ -222,6 +262,7 @@ def load_plugins(app: "Quart") -> None:
|
|||||||
|
|
||||||
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
|
_FAILED_PLUGINS.pop(name, None) # clear any stale failure from a previous reload attempt
|
||||||
_LOADED_PLUGINS.add(name)
|
_LOADED_PLUGINS.add(name)
|
||||||
|
_collect_plugin_nav(name, module)
|
||||||
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
|
||||||
|
|
||||||
|
|
||||||
@@ -440,6 +481,7 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
|||||||
return False, f"get_scheduled_tasks() raised: {exc}"
|
return False, f"get_scheduled_tasks() raised: {exc}"
|
||||||
|
|
||||||
_LOADED_PLUGINS.add(name)
|
_LOADED_PLUGINS.add(name)
|
||||||
|
_collect_plugin_nav(name, module)
|
||||||
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
|
logger.info("Plugin %r hot-reloaded (v%s)", name, meta.get("version", "?"))
|
||||||
return True, f"Plugin activated (v{meta.get('version', '?')})"
|
return True, f"Plugin activated (v{meta.get('version', '?')})"
|
||||||
|
|
||||||
|
|||||||
+47
-10
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Callable, Coroutine
|
from typing import Callable, Coroutine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -15,28 +15,65 @@ class ScheduledTask:
|
|||||||
run_on_startup: bool = False
|
run_on_startup: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _DueTracker:
|
||||||
|
"""Decides which scheduled tasks are due, with a self-overlap guard.
|
||||||
|
|
||||||
|
Pure (no asyncio, no clock of its own — `now` is passed in) so the
|
||||||
|
scheduling policy is unit-testable without timing races. A task whose prior
|
||||||
|
run is still in flight is NOT re-fired: overlapping poll runs stack up open
|
||||||
|
connections/subprocesses and amplify any per-poll resource use — the same
|
||||||
|
failure mode behind the fd-leak lockups. The skipped task is retried on the
|
||||||
|
next tick once it completes (its last_run isn't advanced while skipped).
|
||||||
|
"""
|
||||||
|
last_run: dict[str, float] = field(default_factory=dict)
|
||||||
|
in_flight: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
def due(self, tasks: list[ScheduledTask], now: float) -> list[ScheduledTask]:
|
||||||
|
ready: list[ScheduledTask] = []
|
||||||
|
for task in tasks:
|
||||||
|
if now - self.last_run.get(task.name, 0) < task.interval_seconds:
|
||||||
|
continue
|
||||||
|
if task.name in self.in_flight:
|
||||||
|
logger.warning(
|
||||||
|
"Scheduled task %r still running — skipping this tick",
|
||||||
|
task.name)
|
||||||
|
continue
|
||||||
|
ready.append(task)
|
||||||
|
return ready
|
||||||
|
|
||||||
|
def mark_started(self, task: ScheduledTask, now: float) -> None:
|
||||||
|
self.in_flight.add(task.name)
|
||||||
|
self.last_run[task.name] = now
|
||||||
|
|
||||||
|
def mark_done(self, name: str) -> None:
|
||||||
|
self.in_flight.discard(name)
|
||||||
|
|
||||||
|
|
||||||
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
|
async def start_scheduler(tasks: list[ScheduledTask]) -> None:
|
||||||
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
|
"""Run scheduled tasks in a loop. Call with asyncio.create_task()."""
|
||||||
last_run: dict[str, float] = {}
|
tracker = _DueTracker()
|
||||||
|
|
||||||
|
def _spawn(task: ScheduledTask, now: float) -> None:
|
||||||
|
tracker.mark_started(task, now)
|
||||||
|
asyncio.create_task(_run_task(task, tracker))
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
if task.run_on_startup:
|
if task.run_on_startup:
|
||||||
logger.info(f"Startup task: {task.name}")
|
logger.info(f"Startup task: {task.name}")
|
||||||
asyncio.create_task(_run_task(task))
|
_spawn(task, asyncio.get_event_loop().time())
|
||||||
last_run[task.name] = asyncio.get_event_loop().time()
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
for task in tasks:
|
for task in tracker.due(tasks, now):
|
||||||
last = last_run.get(task.name, 0)
|
_spawn(task, now)
|
||||||
if now - last >= task.interval_seconds:
|
|
||||||
asyncio.create_task(_run_task(task))
|
|
||||||
last_run[task.name] = now
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
async def _run_task(task: ScheduledTask) -> None:
|
async def _run_task(task: ScheduledTask, tracker: _DueTracker) -> None:
|
||||||
try:
|
try:
|
||||||
await task.coro_factory()
|
await task.coro_factory()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Scheduled task {task.name!r} raised an exception")
|
logger.exception(f"Scheduled task {task.name!r} raised an exception")
|
||||||
|
finally:
|
||||||
|
tracker.mark_done(task.name)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Self-monitoring: record Steward's own open file-descriptor usage.
|
||||||
|
|
||||||
|
A leaked socket/file handle in a poll loop (see the SNMP and UniFi fd-leak
|
||||||
|
issues) used to fail silently until the process hit its fd ceiling and
|
||||||
|
`socket.accept()` started raising `OSError: [Errno 24] Too many open files` —
|
||||||
|
taking the whole app down with no early warning.
|
||||||
|
|
||||||
|
This turns that failure mode into an observable signal. Steward monitors other
|
||||||
|
things; it should monitor itself. We record two metrics each tick under
|
||||||
|
`source_module="steward"`:
|
||||||
|
|
||||||
|
• ``open_fds`` — raw count of open descriptors
|
||||||
|
• ``open_fds_pct`` — that count as a percentage of the soft RLIMIT_NOFILE
|
||||||
|
|
||||||
|
Both flow through the normal alert pipeline, so the operator can attach an alert
|
||||||
|
rule to either via the existing alert-rules UI. As a zero-config floor we also
|
||||||
|
log a WARNING once usage crosses ``FD_WARN_PCT`` — a leak becomes visible even
|
||||||
|
before any rule is set up.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Stdlib-only fd counting via /proc keeps this dependency-free. resource is
|
||||||
|
# POSIX-only but always present on the Linux runtime image; guard anyway so
|
||||||
|
# imports never explode on a dev machine.
|
||||||
|
try:
|
||||||
|
import resource
|
||||||
|
except ImportError: # pragma: no cover - non-POSIX
|
||||||
|
resource = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Warn (without needing a configured alert rule) once we're using this fraction
|
||||||
|
# of the soft fd limit. 80% leaves headroom to act before accepts start failing.
|
||||||
|
FD_WARN_PCT = 80.0
|
||||||
|
|
||||||
|
_warned = False # de-dupe the WARNING so a sustained leak doesn't spam the log
|
||||||
|
|
||||||
|
|
||||||
|
def count_open_fds() -> int | None:
|
||||||
|
"""Number of open file descriptors for this process, or None if unknown.
|
||||||
|
|
||||||
|
Reads ``/proc/self/fd`` (Linux). Returns None where /proc isn't available
|
||||||
|
(e.g. a macOS dev box) so callers degrade to a no-op rather than guessing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return len(os.listdir("/proc/self/fd"))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fd_soft_limit() -> int | None:
|
||||||
|
"""Soft RLIMIT_NOFILE for this process, or None if it can't be read.
|
||||||
|
|
||||||
|
None when the limit is unknown or 'unlimited' (RLIM_INFINITY) — a percentage
|
||||||
|
against an unbounded ceiling is meaningless, so we skip the pct metric then.
|
||||||
|
"""
|
||||||
|
if resource is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
soft, _hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
if soft <= 0 or soft == resource.RLIM_INFINITY:
|
||||||
|
return None
|
||||||
|
return soft
|
||||||
|
|
||||||
|
|
||||||
|
async def record_self_metrics(app) -> None:
|
||||||
|
"""Record open-fd usage as Steward's own metrics; warn past the floor.
|
||||||
|
|
||||||
|
No-op (logged at debug) when fd accounting isn't available on this platform,
|
||||||
|
so it's safe to schedule unconditionally.
|
||||||
|
"""
|
||||||
|
global _warned
|
||||||
|
|
||||||
|
fds = count_open_fds()
|
||||||
|
if fds is None:
|
||||||
|
logger.debug("self_monitor: /proc/self/fd unavailable — skipping fd metrics")
|
||||||
|
return
|
||||||
|
|
||||||
|
from .alerts import record_metric
|
||||||
|
|
||||||
|
soft = fd_soft_limit()
|
||||||
|
pct = (fds / soft * 100.0) if soft else None
|
||||||
|
|
||||||
|
async with app.db_sessionmaker() as session:
|
||||||
|
async with session.begin():
|
||||||
|
await record_metric(
|
||||||
|
session=session,
|
||||||
|
source_module="steward",
|
||||||
|
resource_name="process",
|
||||||
|
metric_name="open_fds",
|
||||||
|
value=float(fds),
|
||||||
|
)
|
||||||
|
if pct is not None:
|
||||||
|
await record_metric(
|
||||||
|
session=session,
|
||||||
|
source_module="steward",
|
||||||
|
resource_name="process",
|
||||||
|
metric_name="open_fds_pct",
|
||||||
|
value=pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
if pct is not None and pct >= FD_WARN_PCT:
|
||||||
|
if not _warned:
|
||||||
|
logger.warning(
|
||||||
|
"Open file descriptors at %.0f%% of the soft limit (%d/%d) — "
|
||||||
|
"possible descriptor leak; the app will stop accepting "
|
||||||
|
"connections if this reaches 100%%.", pct, fds, soft)
|
||||||
|
_warned = True
|
||||||
|
else:
|
||||||
|
_warned = False # recovered — re-arm the warning for the next breach
|
||||||
|
|
||||||
|
logger.debug("self_monitor: open_fds=%d soft_limit=%s pct=%s", fds, soft, pct)
|
||||||
@@ -84,6 +84,18 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"docker.retention.metrics_raw_days": 7,
|
"docker.retention.metrics_raw_days": 7,
|
||||||
"docker.retention.metrics_rollup_days": 90,
|
"docker.retention.metrics_rollup_days": 90,
|
||||||
"docker.retention.events_days": 30,
|
"docker.retention.events_days": 30,
|
||||||
|
# Container logs (m79): on by default for every container (operator
|
||||||
|
# preference). `exclude` names containers the server drops on ingest; the
|
||||||
|
# per-container ring bounds storage (rotate oldest past whichever of ~age or
|
||||||
|
# ~bytes hits first — a chatty container just keeps a shorter window).
|
||||||
|
"docker.logs.enabled": True,
|
||||||
|
"docker.logs.exclude": [],
|
||||||
|
"docker.logs.retention_days": 3,
|
||||||
|
"docker.logs.max_bytes_per_container": 5_000_000,
|
||||||
|
# Host/plugin metrics retention (plugin_metrics): keep a short raw window at
|
||||||
|
# the agent's ~30s cadence, then roll up to hourly averages kept much longer.
|
||||||
|
"metrics.retention.raw_days": 7,
|
||||||
|
"metrics.retention.rollup_days": 90,
|
||||||
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
|
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
|
||||||
# Default-enabled plugins. These are the generic, non-vendor-specific
|
# Default-enabled plugins. These are the generic, non-vendor-specific
|
||||||
# bundled plugins (protocols/standards, not a single product) — useful on
|
# bundled plugins (protocols/standards, not a single product) — useful on
|
||||||
@@ -150,6 +162,34 @@ def _decode(value: Any, key: str = "") -> Any:
|
|||||||
return decrypt_secret(value, context=key) if is_encrypted(value) else value
|
return decrypt_secret(value, context=key) if is_encrypted(value) else value
|
||||||
|
|
||||||
|
|
||||||
|
# Secret settings that are stored as ciphertext but won't decrypt with the
|
||||||
|
# current app key (key rotated or lost). Surfaced as an admin banner so the
|
||||||
|
# operator knows precisely which secrets to re-enter — instead of finding out
|
||||||
|
# only when something that uses one fails. Refreshed at startup
|
||||||
|
# (scan_undecryptable_secrets) and kept live: re-entering a secret clears it
|
||||||
|
# without a restart (set_setting discards it on a fresh write).
|
||||||
|
_undecryptable_secrets: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def get_undecryptable_secrets() -> list[str]:
|
||||||
|
"""Sorted keys whose stored ciphertext won't decrypt (for the UI banner)."""
|
||||||
|
return sorted(_undecryptable_secrets)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_undecryptable(stored: Any, key: str = "") -> bool:
|
||||||
|
"""True iff `stored` is an encrypted token that fails to decrypt.
|
||||||
|
|
||||||
|
A failed decrypt returns the ciphertext unchanged (still enc-prefixed), so a
|
||||||
|
value that is still encrypted after a decrypt attempt is undecryptable.
|
||||||
|
"""
|
||||||
|
from steward.core.crypto import decrypt_secret, is_encrypted
|
||||||
|
return (
|
||||||
|
isinstance(stored, str)
|
||||||
|
and is_encrypted(stored)
|
||||||
|
and is_encrypted(decrypt_secret(stored, context=key))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Async helpers (use inside request handlers / scheduled tasks)
|
# Async helpers (use inside request handlers / scheduled tasks)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -182,6 +222,12 @@ async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
|
|||||||
row.value_json = json.dumps(to_store)
|
row.value_json = json.dumps(to_store)
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
|
|
||||||
|
# A fresh write of a secret is encrypted with the current key (or cleared to
|
||||||
|
# plaintext), so it's decryptable now — drop any stale "undecryptable" flag
|
||||||
|
# so the banner clears without a restart.
|
||||||
|
if key in SECRET_KEYS:
|
||||||
|
_undecryptable_secrets.discard(key)
|
||||||
|
|
||||||
|
|
||||||
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
|
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
|
||||||
"""Return flat key→value dict with defaults filled in for missing keys."""
|
"""Return flat key→value dict with defaults filled in for missing keys."""
|
||||||
@@ -359,6 +405,37 @@ def migrate_plaintext_secrets(db_url: str) -> int:
|
|||||||
return asyncio.run(_run())
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def scan_undecryptable_secrets(db_url: str) -> list[str]:
|
||||||
|
"""Populate the undecryptable-secrets cache from the DB; return the keys found.
|
||||||
|
|
||||||
|
Run once at startup, AFTER migrate_plaintext_secrets and init_crypto: any row
|
||||||
|
that's encrypted but won't decrypt with the current key was sealed under a
|
||||||
|
different (lost/rotated) key and must be re-entered. Surfaced via the admin
|
||||||
|
banner (get_undecryptable_secrets).
|
||||||
|
"""
|
||||||
|
async def _run() -> list[str]:
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
engine = create_async_engine(db_url, echo=False)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
bad: list[str] = []
|
||||||
|
try:
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(AppSetting).where(AppSetting.key.in_(SECRET_KEYS))
|
||||||
|
)
|
||||||
|
for row in result.scalars():
|
||||||
|
if _is_undecryptable(json.loads(row.value_json), row.key):
|
||||||
|
bad.append(row.key)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
return bad
|
||||||
|
|
||||||
|
global _undecryptable_secrets
|
||||||
|
found = asyncio.run(_run())
|
||||||
|
_undecryptable_secrets = set(found)
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# External URL helper
|
# External URL helper
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Index plugin_metrics for host/dashboard reads
|
||||||
|
|
||||||
|
plugin_metrics had only a PK on id, so every host-detail / full-metrics /
|
||||||
|
dashboard-widget query (all filter by source_module + resource_name over a
|
||||||
|
recorded_at range) sequentially scanned the whole time-series table — slower as
|
||||||
|
samples accumulate. Add the two composite indexes that match those query shapes.
|
||||||
|
|
||||||
|
The non-concurrent CREATE INDEX takes a brief exclusive lock; it runs once at
|
||||||
|
startup migration time, acceptable for this table.
|
||||||
|
|
||||||
|
Revision ID: 0023_plugin_metrics_indexes
|
||||||
|
Revises: 0022_unify_monitors
|
||||||
|
Create Date: 2026-06-20
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0023_plugin_metrics_indexes"
|
||||||
|
down_revision: Union[str, None] = "0022_unify_monitors"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"ix_plugin_metrics_module_resource_recorded",
|
||||||
|
"plugin_metrics",
|
||||||
|
["source_module", "resource_name", "recorded_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_plugin_metrics_module_resource_metric_recorded",
|
||||||
|
"plugin_metrics",
|
||||||
|
["source_module", "resource_name", "metric_name", "recorded_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plugin_metrics_module_resource_metric_recorded", "plugin_metrics")
|
||||||
|
op.drop_index("ix_plugin_metrics_module_resource_recorded", "plugin_metrics")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Hourly rollup table for plugin_metrics
|
||||||
|
|
||||||
|
Adds plugin_metrics_hourly — the coarse series that retention rolls raw
|
||||||
|
plugin_metrics into before pruning them, so multi-day/week host history stays
|
||||||
|
cheap to store. One row per (source_module, resource_name, metric_name, hour);
|
||||||
|
the unique constraint is the conflict target for the idempotent rollup upsert.
|
||||||
|
|
||||||
|
Revision ID: 0024_plugin_metrics_hourly
|
||||||
|
Revises: 0023_plugin_metrics_indexes
|
||||||
|
Create Date: 2026-06-20
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0024_plugin_metrics_hourly"
|
||||||
|
down_revision: Union[str, None] = "0023_plugin_metrics_indexes"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"plugin_metrics_hourly",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_module", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("resource_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("metric_name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("bucket", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("value_avg", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("value_max", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("sample_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("source_module", "resource_name", "metric_name", "bucket",
|
||||||
|
name="uq_plugin_metrics_hourly_bucket"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plugin_metrics_hourly_lookup", "plugin_metrics_hourly",
|
||||||
|
["source_module", "resource_name", "metric_name", "bucket"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plugin_metrics_hourly_lookup", table_name="plugin_metrics_hourly")
|
||||||
|
op.drop_table("plugin_metrics_hourly")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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, String
|
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from .base import Base
|
from .base import Base
|
||||||
|
|
||||||
@@ -17,3 +17,41 @@ class PluginMetric(Base):
|
|||||||
recorded_at: Mapped[datetime] = mapped_column(
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# This time-series table grows by (sources × resources × sample cadence); every
|
||||||
|
# host-detail / full-metrics / dashboard-widget read filters by
|
||||||
|
# (source_module, resource_name) over a recorded_at range. Without these it's a
|
||||||
|
# full sequential scan on every load.
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_plugin_metrics_module_resource_recorded",
|
||||||
|
"source_module", "resource_name", "recorded_at"),
|
||||||
|
Index("ix_plugin_metrics_module_resource_metric_recorded",
|
||||||
|
"source_module", "resource_name", "metric_name", "recorded_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginMetricHourly(Base):
|
||||||
|
"""Hourly rollup of plugin_metrics — the coarse series that retention rolls
|
||||||
|
raw samples into before pruning them, so multi-day/week history stays cheap.
|
||||||
|
|
||||||
|
One row per (source_module, resource_name, metric_name, hour bucket); the
|
||||||
|
unique constraint is the conflict target for the idempotent rollup upsert.
|
||||||
|
Charts read this for the part of a range older than the raw-retention window.
|
||||||
|
"""
|
||||||
|
__tablename__ = "plugin_metrics_hourly"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
bucket: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
value_avg: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
value_max: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||||
|
sample_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source_module", "resource_name", "metric_name", "bucket",
|
||||||
|
name="uq_plugin_metrics_hourly_bucket"),
|
||||||
|
Index("ix_plugin_metrics_hourly_lookup",
|
||||||
|
"source_module", "resource_name", "metric_name", "bucket"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ _RETENTION_FIELDS = [
|
|||||||
("docker_metrics_raw_days", "docker.retention.metrics_raw_days"),
|
("docker_metrics_raw_days", "docker.retention.metrics_raw_days"),
|
||||||
("docker_metrics_rollup_days", "docker.retention.metrics_rollup_days"),
|
("docker_metrics_rollup_days", "docker.retention.metrics_rollup_days"),
|
||||||
("docker_events_days", "docker.retention.events_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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +171,20 @@ async def save_thresholds():
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
await set_setting(db, key, val)
|
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 _reload_app_config()
|
||||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||||
"settings.saved", detail={"section": "thresholds"})
|
"settings.saved", detail={"section": "thresholds"})
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||||
<h1 class="page-title" style="margin-bottom:0;">Maintenance Windows</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Maintenance Windows</h1>
|
||||||
<div style="display:flex;gap:0.5rem;">
|
<div style="display:flex;gap:0.5rem;">
|
||||||
<a class="btn btn-ghost" href="/alerts/">← Alerts</a>
|
|
||||||
<a class="btn" href="/alerts/maintenance/new">New Window</a>
|
<a class="btn" href="/alerts/maintenance/new">New Window</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{# <option> list for a playbook <select>, populated when a source is chosen.
|
{# <option> list for a playbook <select>, populated when a source is chosen.
|
||||||
Returned by /ansible/playbook-options. `selected` pre-selects one (edit). #}
|
Returned by /ansible/playbook-options. `selected` pre-selects one; with none
|
||||||
<option value="">— choose playbook —</option>
|
given the browser selects the first, so the dropdown never sits on a
|
||||||
|
placeholder. #}
|
||||||
{% for pb in playbooks %}
|
{% for pb in playbooks %}
|
||||||
<option value="{{ pb }}" {% if pb == selected %}selected{% endif %}>{{ pb }}</option>
|
<option value="{{ pb }}" {% if pb == selected %}selected{% endif %}>{{ pb }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
{# Description + discovered variable fields for a selected playbook. Shared by
|
{# Description + discovered variable fields for a selected playbook. Shared by
|
||||||
the browse run form, host run form, and schedule form. Expects `variables`
|
the browse run form, host run form, and schedule form. Expects `variables`
|
||||||
and `description`. Rendered standalone by /ansible/playbook-vars. #}
|
and `description`. Rendered standalone by /ansible/playbook-vars.
|
||||||
|
Optional: `values` (dict var-name→saved value, prefills fields for edit
|
||||||
|
forms) and `schedule` (True in the schedule form — schedules can't prompt,
|
||||||
|
so secret vars are shown disabled and the confirm gate isn't `required`). #}
|
||||||
|
{% set _values = values|default({}) %}
|
||||||
|
{% set _schedule = schedule|default(false) %}
|
||||||
{% if description or category %}
|
{% if description or category %}
|
||||||
<div style="background:var(--bg);border-left:3px solid var(--accent);border-radius:3px;
|
<div style="background:var(--bg);border-left:3px solid var(--accent);border-radius:3px;
|
||||||
padding:0.5rem 0.7rem;margin:0.25rem 0 0.6rem;font-size:0.82rem;color:var(--text-muted);">
|
padding:0.5rem 0.7rem;margin:0.25rem 0 0.6rem;font-size:0.82rem;color:var(--text-muted);">
|
||||||
@@ -13,7 +18,7 @@
|
|||||||
<label style="display:flex;align-items:flex-start;gap:0.5rem;background:color-mix(in srgb,var(--red) 10%,var(--bg-elevated));
|
<label style="display:flex;align-items:flex-start;gap:0.5rem;background:color-mix(in srgb,var(--red) 10%,var(--bg-elevated));
|
||||||
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));border-radius:6px;
|
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));border-radius:6px;
|
||||||
padding:0.6rem 0.8rem;margin:0 0 0.6rem;font-size:0.82rem;font-weight:normal;cursor:pointer;">
|
padding:0.6rem 0.8rem;margin:0 0 0.6rem;font-size:0.82rem;font-weight:normal;cursor:pointer;">
|
||||||
<input type="checkbox" name="confirmed" required style="margin-top:0.15rem;">
|
<input type="checkbox" name="confirmed" {% if not _schedule %}required{% endif %} style="margin-top:0.15rem;">
|
||||||
<span><strong style="color:var(--red);">Confirm:</strong> this playbook is marked as making
|
<span><strong style="color:var(--red);">Confirm:</strong> this playbook is marked as making
|
||||||
significant or destructive changes. Tick to enable running it.</span>
|
significant or destructive changes. Tick to enable running it.</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -33,13 +38,22 @@
|
|||||||
({{ v.name }}{% if v.required %}, required{% endif %}{% if v.secret %}, secret{% endif %})
|
({{ v.name }}{% if v.required %}, required{% endif %}{% if v.secret %}, secret{% endif %})
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{% if v.secret and _schedule %}
|
||||||
|
{# Scheduled runs can't prompt, so secret vars are dropped downstream — show
|
||||||
|
the field disabled rather than inviting silent data loss. #}
|
||||||
|
<input type="text" disabled
|
||||||
|
placeholder="secrets can't be scheduled — set via inventory / global creds"
|
||||||
|
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;opacity:0.55;">
|
||||||
|
{% else %}
|
||||||
{% if v.secret %}<input type="hidden" name="secret__{{ v.name }}" value="1">{% endif %}
|
{% if v.secret %}<input type="hidden" name="secret__{{ v.name }}" value="1">{% endif %}
|
||||||
<input type="{{ 'password' if v.secret else 'text' }}"
|
<input type="{{ 'password' if v.secret else 'text' }}"
|
||||||
name="var__{{ v.name }}"
|
name="var__{{ v.name }}"
|
||||||
|
{% if not v.secret %}value="{{ _values.get(v.name, '') }}"{% endif %}
|
||||||
{% if v.required %}required{% endif %}
|
{% if v.required %}required{% endif %}
|
||||||
{% if v.secret %}autocomplete="new-password"{% endif %}
|
{% if v.secret %}autocomplete="new-password"{% endif %}
|
||||||
placeholder="{% if v.secret %}(hidden){% elif v.default not in (None, '') %}default: {{ v.default }}{% else %}no default{% endif %}"
|
placeholder="{% if v.secret %}(hidden){% elif v.default not in (None, '') %}default: {{ v.default }}{% else %}no default{% endif %}"
|
||||||
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;">
|
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;">
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Inventory</a>
|
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Inventory</a>
|
||||||
<a href="/ansible/schedules" class="btn btn-ghost btn-sm">Schedules</a>
|
<a href="/ansible/schedules" class="btn btn-ghost btn-sm">Schedules</a>
|
||||||
<a href="/ansible/" class="btn btn-ghost btn-sm">← Run History</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,7 +34,6 @@
|
|||||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/ansible/browse" class="btn btn-ghost btn-sm">← Back to browse</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
|
<pre style="background:var(--bg);padding:1rem;border-radius:4px;overflow-x:auto;font-size:0.8rem;color:var(--text-muted);max-height:600px;overflow-y:auto;">{{ view_contents }}</pre>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||||
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
|
||||||
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">← Groups</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">
|
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<h1 class="page-title" style="margin-bottom:0;">Inventory Groups</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Inventory Groups</h1>
|
||||||
<div style="display:flex;gap:0.5rem;">
|
<div style="display:flex;gap:0.5rem;">
|
||||||
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Targets</a>
|
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Targets</a>
|
||||||
<a href="/ansible/" class="btn btn-ghost btn-sm">← Ansible</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||||
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
|
||||||
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">← Targets</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">
|
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<h1 class="page-title" style="margin-bottom:0;">Inventory Targets</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Inventory Targets</h1>
|
||||||
<div style="display:flex;gap:0.5rem;">
|
<div style="display:flex;gap:0.5rem;">
|
||||||
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">Groups</a>
|
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">Groups</a>
|
||||||
<a href="/ansible/" class="btn btn-ghost btn-sm">← Ansible</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
|
||||||
<h1 class="page-title" style="margin-bottom:0;">{{ "Edit playbook" if editing else "New playbook" }}</h1>
|
<h1 class="page-title" style="margin-bottom:0;">{{ "Edit playbook" if editing else "New playbook" }}</h1>
|
||||||
<a href="/ansible/browse" class="btn btn-ghost btn-sm">← Browse</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
|
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Run " ~ run.id[:8], "")]) }}{% endblock %}
|
{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Run " ~ run.id[:8], "")]) }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||||
<a href="/ansible/" class="btn btn-ghost btn-sm">← Runs</a>
|
|
||||||
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
|
||||||
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
||||||
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
|
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<div style="display:flex;gap:0.5rem;">
|
<div style="display:flex;gap:0.5rem;">
|
||||||
<a href="/ansible/inventory/targets" class="btn btn-ghost">Inventory</a>
|
<a href="/ansible/inventory/targets" class="btn btn-ghost">Inventory</a>
|
||||||
<a href="/ansible/browse" class="btn btn-ghost">Browse</a>
|
<a href="/ansible/browse" class="btn btn-ghost">Browse</a>
|
||||||
<a href="/ansible/" class="btn btn-ghost">← Runs</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,6 +75,8 @@
|
|||||||
|
|
||||||
{# ── Create / edit form ───────────────────────────────────────────────────── #}
|
{# ── Create / edit form ───────────────────────────────────────────────────── #}
|
||||||
{% set p = (editing.params or {}) if editing else {} %}
|
{% set p = (editing.params or {}) if editing else {} %}
|
||||||
|
{% set saved_vars = p.get('extra_vars_map') or {} %}
|
||||||
|
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
|
||||||
<div class="card" id="form">
|
<div class="card" id="form">
|
||||||
<h2 class="section-title" style="margin-bottom:1rem;">{{ "Edit schedule" if editing else "New schedule" }}</h2>
|
<h2 class="section-title" style="margin-bottom:1rem;">{{ "Edit schedule" if editing else "New schedule" }}</h2>
|
||||||
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">
|
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">
|
||||||
@@ -88,18 +89,21 @@
|
|||||||
<label>Source</label>
|
<label>Source</label>
|
||||||
<select name="source_name" required
|
<select name="source_name" required
|
||||||
hx-get="/ansible/playbook-options" hx-trigger="change"
|
hx-get="/ansible/playbook-options" hx-trigger="change"
|
||||||
hx-target="#sch-playbook" hx-swap="innerHTML" hx-include="this">
|
hx-target="#sch-playbook" hx-swap="innerHTML" hx-include="this"
|
||||||
<option value="">— choose source —</option>
|
hx-on::after-settle="htmx.trigger('#sch-playbook','change')">
|
||||||
{% for sd in source_data %}
|
{% for sd in source_data %}
|
||||||
<option value="{{ sd.name }}" {% if editing and editing.source_name == sd.name %}selected{% endif %}>{{ sd.name }}</option>
|
<option value="{{ sd.name }}" {% if sd.name == sel_source %}selected{% endif %}>{{ sd.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if not source_data %}<option value="" disabled>No Ansible sources — add one in Settings</option>{% endif %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0;">
|
<div class="form-group" style="margin-bottom:0;">
|
||||||
<label>Playbook</label>
|
<label>Playbook</label>
|
||||||
<select name="playbook_path" id="sch-playbook" required>
|
<select name="playbook_path" id="sch-playbook" required
|
||||||
<option value="">— choose playbook —</option>
|
hx-get="/ansible/playbook-vars?schedule=1"
|
||||||
{% for pb in all_playbooks %}
|
hx-trigger="{% if not editing %}load, {% endif %}change"
|
||||||
|
hx-target="#sch-playbook-vars" hx-swap="innerHTML" hx-include="closest form">
|
||||||
|
{% for pb in sel_playbooks %}
|
||||||
<option value="{{ pb }}" {% if editing and editing.playbook_path == pb %}selected{% endif %}>{{ pb }}</option>
|
<option value="{{ pb }}" {% if editing and editing.playbook_path == pb %}selected{% endif %}>{{ pb }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
@@ -133,11 +137,29 @@
|
|||||||
<input type="text" name="tags" value="{{ p.get('tags', '') }}" placeholder="tag1,tag2">
|
<input type="text" name="tags" value="{{ p.get('tags', '') }}" placeholder="tag1,tag2">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top:1rem;">
|
|
||||||
<label>Extra vars (one key=value per line)</label>
|
{# Declared playbook variables (vars:/vars_prompt:) — loaded via HTMX on
|
||||||
<textarea name="extra_vars" rows="3" placeholder="prune_volumes=false">{% for k, v in (p.get('extra_vars_map') or {}).items() %}{{ k }}={{ v }}
|
playbook change; pre-rendered here in edit mode so saved values show. #}
|
||||||
|
<div id="sch-playbook-vars" style="margin-top:1rem;">
|
||||||
|
{% if editing %}
|
||||||
|
{% set variables = edit_variables %}
|
||||||
|
{% set description = edit_description %}
|
||||||
|
{% set category = edit_category %}
|
||||||
|
{% set confirm = edit_confirm %}
|
||||||
|
{% set values = saved_vars %}
|
||||||
|
{% set schedule = true %}
|
||||||
|
{% include "ansible/_playbook_vars.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style="margin-top:0.75rem;">
|
||||||
|
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Extra vars</summary>
|
||||||
|
<div class="form-group" style="margin-top:0.6rem;">
|
||||||
|
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line — for vars not listed above)</span></label>
|
||||||
|
<textarea name="extra_vars" rows="3" placeholder="prune_volumes=false">{% for k, v in saved_vars.items() if k not in declared_names %}{{ k }}={{ v }}
|
||||||
{% endfor %}</textarea>
|
{% endfor %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;">
|
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
<input type="checkbox" name="check" id="check" style="width:auto;" {% if p.get('check') %}checked{% endif %}>
|
<input type="checkbox" name="check" id="check" style="width:auto;" {% if p.get('check') %}checked{% endif %}>
|
||||||
<label for="check" style="margin-bottom:0;">Dry run (--check --diff)</label>
|
<label for="check" style="margin-bottom:0;">Dry run (--check --diff)</label>
|
||||||
|
|||||||
+165
-37
@@ -51,37 +51,90 @@ code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var
|
|||||||
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%);
|
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav */
|
/* ── App shell: persistent left sidebar + content column ───────────────────── */
|
||||||
nav {
|
.app-shell { display: flex; min-height: 100vh; position: relative; z-index: 1; }
|
||||||
background: var(--bg-card);
|
.sidebar {
|
||||||
padding: 0 1.5rem;
|
width: 220px; flex-shrink: 0; background: var(--bg-card);
|
||||||
display: flex;
|
border-right: 1px solid var(--border-mid);
|
||||||
align-items: center;
|
display: flex; flex-direction: column;
|
||||||
gap: 0;
|
position: sticky; top: 0; height: 100vh; z-index: 20;
|
||||||
border-bottom: 1px solid var(--border-mid);
|
|
||||||
height: 48px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
nav .brand {
|
.sidebar .brand {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif); font-weight: 700; font-size: 1.15rem;
|
||||||
font-weight: 700;
|
letter-spacing: 0.01em; color: var(--gold); text-decoration: none;
|
||||||
font-size: 1.1rem;
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
margin-right: 2rem;
|
padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||||
letter-spacing: 0.01em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
color: var(--gold);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
nav .brand svg { flex-shrink: 0; }
|
.sidebar .brand svg { flex-shrink: 0; }
|
||||||
nav a { color: var(--text-muted); font-size: 0.875rem; padding: 0 0.875rem; height: 48px; display: flex; align-items: center; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
|
/* Only the nav list scrolls when long — the brand and user/logout stay pinned. */
|
||||||
nav a:hover { color: var(--text); border-bottom-color: var(--border-mid); }
|
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 0.75rem 0; }
|
||||||
nav a.nav-end { margin-left: auto; }
|
.nav-group { margin-bottom: 0.9rem; }
|
||||||
|
.nav-group-label {
|
||||||
|
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.09em;
|
||||||
|
color: var(--text-dim); font-weight: 600; padding: 0 1.25rem; margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.side-nav a {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.4rem 1.25rem; font-size: 0.9rem; color: var(--text-muted);
|
||||||
|
border-left: 2px solid transparent; text-decoration: none;
|
||||||
|
transition: color .12s, background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.side-nav a:hover { color: var(--text); background: var(--bg-elevated); }
|
||||||
|
.side-nav a.active {
|
||||||
|
color: var(--gold); border-left-color: var(--gold);
|
||||||
|
background: color-mix(in srgb, var(--gold) 8%, transparent);
|
||||||
|
}
|
||||||
|
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
|
||||||
|
.side-user a {
|
||||||
|
color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.side-user a:hover { color: var(--text); }
|
||||||
|
/* Keyboard focus: a clear inset ring on every interactive sidebar element. */
|
||||||
|
.sidebar .brand:focus-visible,
|
||||||
|
.side-nav a:focus-visible,
|
||||||
|
.side-user a:focus-visible {
|
||||||
|
outline: 2px solid var(--accent); outline-offset: -2px; color: var(--text);
|
||||||
|
}
|
||||||
|
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.topbar { display: none; } /* slim mobile bar; shown only under 900px */
|
||||||
|
.nav-scrim { display: none; }
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
main { padding: 1.5rem 2rem; max-width: 1600px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
|
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* Responsive: sidebar slides off-canvas under 900px, toggled by ☰ */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed; left: 0; top: 0; bottom: 0; height: 100%;
|
||||||
|
transform: translateX(-100%); transition: transform .2s ease;
|
||||||
|
box-shadow: 0 0 30px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
body.nav-open .sidebar { transform: translateX(0); }
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem; height: 48px; padding: 0 1rem;
|
||||||
|
background: var(--bg-card); border-bottom: 1px solid var(--border-mid);
|
||||||
|
position: sticky; top: 0; z-index: 15;
|
||||||
|
}
|
||||||
|
.sidebar-toggle {
|
||||||
|
background: none; border: none; color: var(--text-muted); font-size: 1.3rem;
|
||||||
|
cursor: pointer; line-height: 1; padding: 0.25rem; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.sidebar-toggle:hover { color: var(--text); }
|
||||||
|
.sidebar-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.topbar .brand-sm {
|
||||||
|
font-family: var(--font-serif); color: var(--gold); font-weight: 700;
|
||||||
|
font-size: 1.05rem; text-decoration: none;
|
||||||
|
}
|
||||||
|
body.nav-open .nav-scrim {
|
||||||
|
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 18;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sidebar { transition: none; }
|
||||||
|
.side-nav a { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Alerts */
|
/* Alerts */
|
||||||
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||||
@@ -224,8 +277,10 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
|
|||||||
|
|
||||||
<div id="candle-glow"></div>
|
<div id="candle-glow"></div>
|
||||||
|
|
||||||
|
{% set _p = request.path %}
|
||||||
|
<div class="app-shell">
|
||||||
{% if session.user_id is defined %}
|
{% if session.user_id is defined %}
|
||||||
<nav>
|
<aside class="sidebar" id="sidebar">
|
||||||
<a href="/" class="brand">
|
<a href="/" class="brand">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
|
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
|
||||||
@@ -237,18 +292,48 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
|
|||||||
</svg>
|
</svg>
|
||||||
Steward
|
Steward
|
||||||
</a>
|
</a>
|
||||||
<a href="/">Dashboard</a>
|
<nav class="side-nav" aria-label="Primary">
|
||||||
<a href="/status">Status</a>
|
<div class="nav-group">
|
||||||
<a href="/hosts/">Hosts</a>
|
<div class="nav-group-label">Overview</div>
|
||||||
<a href="/monitors/">Monitors</a>
|
<a href="/" {% if _p == '/' %}class="active" aria-current="page"{% endif %}>Dashboard</a>
|
||||||
<a href="/alerts/">Alerts</a>
|
<a href="/status" {% if _p.startswith('/status') %}class="active" aria-current="page"{% endif %}>Status</a>
|
||||||
<a href="/ansible/">Ansible</a>
|
</div>
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-label">Infrastructure</div>
|
||||||
|
<a href="/hosts/" {% if _p.startswith('/hosts') %}class="active" aria-current="page"{% endif %}>Hosts</a>
|
||||||
|
{% for item in plugin_nav %}
|
||||||
|
<a href="{{ item.href }}" {% if _p.startswith(item.href.rstrip('/')) %}class="active" aria-current="page"{% endif %}>{{ item.label }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-label">Monitoring</div>
|
||||||
|
<a href="/monitors/" {% if _p.startswith('/monitors') %}class="active" aria-current="page"{% endif %}>Monitors</a>
|
||||||
|
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-label">Automation</div>
|
||||||
|
<a href="/ansible/" {% if _p.startswith('/ansible') %}class="active" aria-current="page"{% endif %}>Ansible</a>
|
||||||
|
</div>
|
||||||
{% if session.user_role == 'admin' %}
|
{% if session.user_role == 'admin' %}
|
||||||
<a href="/settings/">Settings</a>
|
<div class="nav-group">
|
||||||
<a href="/audit/">Audit</a>
|
<div class="nav-group-label">Admin</div>
|
||||||
|
<a href="/settings/" {% if _p.startswith('/settings') %}class="active" aria-current="page"{% endif %}>Settings</a>
|
||||||
|
<a href="/audit/" {% if _p.startswith('/audit') %}class="active" aria-current="page"{% endif %}>Audit</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/logout" class="nav-end">{{ session.username }}</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="side-user">
|
||||||
|
<a href="/logout" title="Log out">⏻ {{ session.username }}</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
<div class="app-main">
|
||||||
|
{% if session.user_id is defined %}
|
||||||
|
<div class="topbar">
|
||||||
|
<button class="sidebar-toggle" type="button" aria-label="Toggle navigation"
|
||||||
|
aria-controls="sidebar" aria-expanded="false" onclick="toggleNav(this)">☰</button>
|
||||||
|
<a href="/" class="brand-sm">Steward</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -263,6 +348,16 @@ function setTimeRange(val) {
|
|||||||
history.replaceState({}, '', url);
|
history.replaceState({}, '', url);
|
||||||
document.body.dispatchEvent(new CustomEvent('rangeChange'));
|
document.body.dispatchEvent(new CustomEvent('rangeChange'));
|
||||||
}
|
}
|
||||||
|
// Mobile sidebar (off-canvas). Keep aria-expanded in sync; Escape closes it.
|
||||||
|
function setNavOpen(open) {
|
||||||
|
document.body.classList.toggle('nav-open', open);
|
||||||
|
var btn = document.querySelector('.sidebar-toggle');
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
function toggleNav(btn) { setNavOpen(!document.body.classList.contains('nav-open')); }
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNavOpen(false);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -280,12 +375,45 @@ function setTimeRange(val) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if undecryptable_secrets and session.user_role == 'admin' %}
|
||||||
|
{% set _sec_tab = {
|
||||||
|
'smtp.password': '/settings/notifications/',
|
||||||
|
'oidc.client_secret': '/settings/auth/',
|
||||||
|
'ldap.bind_password': '/settings/auth/',
|
||||||
|
'ansible.ssh_private_key': '/settings/ansible/',
|
||||||
|
'ansible.become_password': '/settings/ansible/',
|
||||||
|
'ansible.vault_password': '/settings/ansible/',
|
||||||
|
} %}
|
||||||
|
<div style="background:color-mix(in srgb,var(--red) 12%,var(--bg-elevated));
|
||||||
|
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));
|
||||||
|
border-radius:6px;padding:0.6rem 1rem;margin-bottom:1rem;
|
||||||
|
font-size:0.84rem;display:flex;align-items:flex-start;gap:0.6rem;">
|
||||||
|
<span style="color:var(--red);flex-shrink:0;">⚠</span>
|
||||||
|
<span>
|
||||||
|
<strong style="color:var(--text);">
|
||||||
|
{{ undecryptable_secrets | length }} stored secret{{ 's' if undecryptable_secrets | length != 1 }}
|
||||||
|
can’t be decrypted
|
||||||
|
</strong>
|
||||||
|
— the app secret key changed, so {{ 'they' if undecryptable_secrets | length != 1 else 'it' }}
|
||||||
|
must be re-entered:
|
||||||
|
{% for k in undecryptable_secrets -%}
|
||||||
|
{%- if _sec_tab.get(k) %}<a href="{{ _sec_tab[k] }}" style="color:var(--accent);">{{ k }}</a>
|
||||||
|
{%- else %}<code>{{ k }}</code>{% endif %}{% if not loop.last %}, {% endif %}
|
||||||
|
{%- endfor %}.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% block breadcrumb %}{% endblock %}
|
{% block breadcrumb %}{% endblock %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
</div>{# .app-main #}
|
||||||
|
</div>{# .app-shell #}
|
||||||
|
{% if session.user_id is defined %}
|
||||||
|
<div class="nav-scrim" onclick="setNavOpen(false)"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,17 @@
|
|||||||
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
||||||
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
|
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
|
||||||
|
|
||||||
|
{# ── Live vitals strip (host_agent fragment) — full width on top, polled ────── #}
|
||||||
|
<div id="hv-strip"
|
||||||
|
hx-get="/plugins/host_agent/vitals/{{ host.id }}"
|
||||||
|
hx-trigger="load, every 15s"
|
||||||
|
hx-swap="innerHTML"></div>
|
||||||
|
|
||||||
|
{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #}
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:1rem;align-items:start;margin-bottom:1rem;">
|
||||||
|
|
||||||
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
|
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;">
|
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;">
|
||||||
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
|
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
|
||||||
{% if uptime %}
|
{% if uptime %}
|
||||||
@@ -114,20 +123,15 @@
|
|||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>{# Monitors card #}
|
||||||
|
|
||||||
|
{# ── Agent (host_agent management panel) ──────────────────────────────────── #}
|
||||||
|
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
|
||||||
|
<div class="card" style="margin-bottom:0;"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>{# Monitors + Agent grid #}
|
||||||
|
|
||||||
{# ── Agent (host_agent plugin fragment, embedded across the plugin boundary) ── #}
|
{# ── Ansible (full width) ─────────────────────────────────────────────────── #}
|
||||||
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></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 ──────────────────────────────────────────────────────────────── #}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="section-title">Ansible</h3>
|
<h3 class="section-title">Ansible</h3>
|
||||||
{% if linked_target %}
|
{% if linked_target %}
|
||||||
@@ -157,9 +161,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Source</label>
|
<label>Source</label>
|
||||||
<select name="source_name" required
|
<select name="source_name" required
|
||||||
hx-get="/ansible/playbook-options" hx-trigger="change"
|
hx-get="/ansible/playbook-options" hx-trigger="load, change"
|
||||||
hx-target="#hp-playbook" hx-swap="innerHTML" hx-include="this">
|
hx-target="#hp-playbook" hx-swap="innerHTML" hx-include="this"
|
||||||
<option value="">— choose source —</option>
|
hx-on::after-settle="htmx.trigger('#hp-playbook','change')">
|
||||||
{% for s in ansible_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
|
{% for s in ansible_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +172,6 @@
|
|||||||
<select name="playbook_path" id="hp-playbook" required
|
<select name="playbook_path" id="hp-playbook" required
|
||||||
hx-get="/ansible/playbook-vars" hx-trigger="change"
|
hx-get="/ansible/playbook-vars" hx-trigger="change"
|
||||||
hx-target="#hp-vars" hx-swap="innerHTML" hx-include="closest form">
|
hx-target="#hp-vars" hx-swap="innerHTML" hx-include="closest form">
|
||||||
<option value="">— choose a source first —</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="hp-vars"></div>
|
<div id="hp-vars"></div>
|
||||||
@@ -213,7 +216,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>{# Ansible card #}
|
||||||
|
|
||||||
|
{# ── 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 %}
|
||||||
|
|
||||||
|
{# ── SNMP (renders nothing unless a configured SNMP device maps to this host) ── #}
|
||||||
|
{% if "snmp" in enabled_plugins %}
|
||||||
|
<div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ toggle_script() }}
|
{{ toggle_script() }}
|
||||||
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
|
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs %}
|
||||||
{% block title %}Uptime / SLA — Steward{% endblock %}
|
{% block title %}Uptime / SLA — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Uptime / SLA", "")]) }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||||
<h1 class="page-title" style="margin-bottom:0;">Uptime / SLA</h1>
|
<h1 class="page-title" style="margin-bottom:0;">Uptime / SLA</h1>
|
||||||
<a class="btn btn-ghost" href="/hosts/">← Hosts</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Summary pills ──────────────────────────────────────────────────────────── #}
|
{# ── Summary pills ──────────────────────────────────────────────────────────── #}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "_macros.html" import crumbs %}
|
||||||
{% from "monitors/_fields.html" import type_fields, toggle_script %}
|
{% from "monitors/_fields.html" import type_fields, toggle_script %}
|
||||||
{% block title %}Edit Monitor — Steward{% endblock %}
|
{% block title %}Edit Monitor — Steward{% endblock %}
|
||||||
|
{% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), ("Edit monitor", "")]) }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="margin-bottom:1.25rem;">
|
<div style="margin-bottom:1.25rem;">
|
||||||
<a href="/monitors/" style="color:var(--text-muted);font-size:0.85rem;">← Monitors</a>
|
<h1 class="page-title" style="margin-bottom:0;">Edit Monitor</h1>
|
||||||
<h1 class="page-title" style="margin:0.4rem 0 0;">Edit Monitor</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
{% set inp = "width:100%;padding:0.4rem 0.65rem;background:var(--bg);border:1px solid var(--border-mid);border-radius:4px;color:var(--text);font-size:0.88rem;" %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{# settings/_tabs.html — include at top of each settings section #}
|
{# settings/_tabs.html — include at top of each settings section.
|
||||||
<h1 class="page-title">Settings</h1>
|
No section <h1>: the breadcrumb kicker ("Settings › …") names the section and
|
||||||
|
the tab strip is the visual header, so we don't stack a redundant title. #}
|
||||||
<div style="display:flex;gap:0;border-bottom:1px solid var(--border-mid);margin-bottom:1.5rem;">
|
<div style="display:flex;gap:0;border-bottom:1px solid var(--border-mid);margin-bottom:1.5rem;">
|
||||||
{% set tabs = [
|
{% set tabs = [
|
||||||
("general", "General", "/settings/general/"),
|
("general", "General", "/settings/general/"),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
{% include "settings/_tabs.html" %}
|
{% include "settings/_tabs.html" %}
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||||
<a href="/settings/plugins/" class="btn btn-ghost btn-sm">← Plugins</a>
|
|
||||||
<h1 class="page-title" style="margin-bottom:0;">{{ plugin.get('name', plugin._dir) }}</h1>
|
<h1 class="page-title" style="margin-bottom:0;">{{ plugin.get('name', plugin._dir) }}</h1>
|
||||||
<span style="color:var(--text-muted);font-size:0.82rem;">v{{ plugin.get('version', '?') }}</span>
|
<span style="color:var(--text-muted);font-size:0.82rem;">v{{ plugin.get('version', '?') }}</span>
|
||||||
{% if fail_reason %}
|
{% if fail_reason %}
|
||||||
|
|||||||
@@ -70,6 +70,69 @@
|
|||||||
"Keep container start/stop/die/health history this long.") }}
|
"Keep container start/stop/die/health history this long.") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:640px;margin-top:1rem;">
|
||||||
|
<h2 class="section-title" style="margin-bottom:0.5rem;">Container logs</h2>
|
||||||
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:1.1rem;">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" name="docker_logs_enabled" style="width:auto;"
|
||||||
|
{% if settings["docker.logs.enabled"] %}checked{% endif %}>
|
||||||
|
Collect container logs
|
||||||
|
</label>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
|
||||||
|
Global kill-switch. When off, pushed log lines are dropped and no new logs are stored.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:1.1rem;">
|
||||||
|
<label>Exclude containers</label>
|
||||||
|
<div style="margin-top:0.25rem;">
|
||||||
|
<input type="text" name="docker_logs_exclude"
|
||||||
|
value="{{ settings['docker.logs.exclude'] | join(', ') }}"
|
||||||
|
placeholder="watchtower, some-chatty-service" style="width:100%;max-width:420px;">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
|
||||||
|
Comma-separated container names whose logs are dropped on ingest (e.g. known-noisy ones).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ 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.") }}
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:0.25rem;">
|
||||||
|
<label>Max size per container <span style="color:var(--text-muted);font-size:0.8rem;">(MB)</span></label>
|
||||||
|
<div style="margin-top:0.25rem;">
|
||||||
|
<input type="number" name="docker_logs_max_mb" min="1" step="1"
|
||||||
|
value="{{ (settings['docker.logs.max_bytes_per_container'] // 1000000) or 1 }}"
|
||||||
|
style="max-width:110px;">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">
|
||||||
|
Newest lines are kept up to this size per container; older lines rotate out first.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:640px;margin-top:1rem;">
|
||||||
|
<h2 class="section-title" style="margin-bottom:0.5rem;">Host metrics retention</h2>
|
||||||
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
|
||||||
|
Bounds how much host-agent metric history is stored (CPU, memory, disk, network,
|
||||||
|
temps, …). Raw per-sample points are kept for the raw window, then rolled up
|
||||||
|
into hourly averages kept for the rollup window. Host charts read raw for the
|
||||||
|
recent part of a range and hourly for older data. Applied by the hourly cleanup.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{ days("Raw metrics", "metrics_raw_days", "metrics.retention.raw_days",
|
||||||
|
"Keep per-sample host metrics this long, then roll up to hourly averages.") }}
|
||||||
|
{{ days("Rolled-up metrics", "metrics_rollup_days", "metrics.retention.rollup_days",
|
||||||
|
"Keep the hourly-averaged series this long for multi-week history.") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
|
<div style="margin-top:1rem;display:flex;align-items:center;gap:1rem;">
|
||||||
<button type="submit" class="btn">Save</button>
|
<button type="submit" class="btn">Save</button>
|
||||||
<span style="font-size:0.82rem;color:var(--text-muted);">Takes effect immediately — no restart.</span>
|
<span style="font-size:0.82rem;color:var(--text-muted);">Takes effect immediately — no restart.</span>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""The bundled first-party playbook source is always present and discoverable."""
|
"""The bundled first-party playbook source is always present and discoverable."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from steward.ansible.sources import (
|
from steward.ansible.sources import (
|
||||||
BUILTIN_SOURCE_NAME,
|
BUILTIN_SOURCE_NAME,
|
||||||
|
discover_playbook_meta,
|
||||||
discover_playbooks,
|
discover_playbooks,
|
||||||
get_sources,
|
get_sources,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_content(rel_path: str) -> str:
|
||||||
|
builtin = get_sources({"sources": []})[0]
|
||||||
|
return (Path(builtin["path"]) / rel_path).read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_builtin_source_is_first_and_local():
|
def test_builtin_source_is_first_and_local():
|
||||||
sources = get_sources({"sources": []})
|
sources = get_sources({"sources": []})
|
||||||
assert sources[0]["name"] == BUILTIN_SOURCE_NAME
|
assert sources[0]["name"] == BUILTIN_SOURCE_NAME
|
||||||
@@ -17,3 +27,33 @@ def test_bundled_playbooks_are_discoverable():
|
|||||||
playbooks = discover_playbooks(builtin["path"])
|
playbooks = discover_playbooks(builtin["path"])
|
||||||
assert "maintenance/docker_prune.yml" in playbooks
|
assert "maintenance/docker_prune.yml" in playbooks
|
||||||
assert "host_agent/install.yml" in playbooks
|
assert "host_agent/install.yml" in playbooks
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_prune_playbook_parses_and_keeps_meta():
|
||||||
|
"""The prune playbook stays valid YAML and keeps its self-describing meta
|
||||||
|
(a destructive maintenance run that must prompt for confirmation)."""
|
||||||
|
content = _bundled_content("maintenance/docker_prune.yml")
|
||||||
|
plays = yaml.safe_load(content)
|
||||||
|
assert isinstance(plays, list) and plays # at least one play
|
||||||
|
|
||||||
|
meta = discover_playbook_meta(content)
|
||||||
|
assert meta["confirm"] is True
|
||||||
|
assert meta["category"] == "maintenance"
|
||||||
|
assert meta["description"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_prune_supports_all_three_targets():
|
||||||
|
"""M78 drives the three prune buttons via a single `prune_target` var;
|
||||||
|
`system` is the default so pre-M78 callers (no var set) are unchanged."""
|
||||||
|
content = _bundled_content("maintenance/docker_prune.yml")
|
||||||
|
play = yaml.safe_load(content)[0]
|
||||||
|
assert play["vars"]["prune_target"] == "system"
|
||||||
|
|
||||||
|
# Every target the routes/UI can send has a matching guarded task.
|
||||||
|
guards = {
|
||||||
|
task.get("when")
|
||||||
|
for task in play["tasks"]
|
||||||
|
if isinstance(task.get("when"), str) and "prune_target" in task["when"]
|
||||||
|
}
|
||||||
|
for target in ("containers", "images", "system"):
|
||||||
|
assert f"prune_target == '{target}'" in guards
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Real-fd regression canary.
|
||||||
|
|
||||||
|
Drives a real socket code path (the TCP reachability probe) in a tight loop and
|
||||||
|
asserts the process's open-fd count doesn't grow. If someone reintroduces a
|
||||||
|
socket leak in the probe path — the class of bug behind the Errno 24 lockups —
|
||||||
|
this fails in CI instead of in production. Uses real descriptors, not fakes, so
|
||||||
|
the assertion has teeth.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from steward.monitors.ping import tcp_check
|
||||||
|
|
||||||
|
_ITERATIONS = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _fd_count() -> int | None:
|
||||||
|
try:
|
||||||
|
return len(os.listdir("/proc/self/fd"))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _hammer_tcp_check() -> None:
|
||||||
|
# 127.0.0.1:1 has no listener → connection refused immediately. Each call must
|
||||||
|
# fully release its socket; a leak would add ~one fd per iteration.
|
||||||
|
for _ in range(_ITERATIONS):
|
||||||
|
await tcp_check("127.0.0.1", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tcp_check_does_not_leak_fds():
|
||||||
|
if _fd_count() is None:
|
||||||
|
pytest.skip("/proc/self/fd unavailable on this platform")
|
||||||
|
|
||||||
|
asyncio.run(_hammer_tcp_check()) # warm up (lazy imports, caches)
|
||||||
|
before = _fd_count()
|
||||||
|
asyncio.run(_hammer_tcp_check())
|
||||||
|
after = _fd_count()
|
||||||
|
|
||||||
|
# Small slack for interpreter-internal fds; a genuine leak over 100 iterations
|
||||||
|
# would be far larger than this.
|
||||||
|
assert after - before <= 5, (
|
||||||
|
f"open fds grew {before}->{after} over {_ITERATIONS} tcp_check calls "
|
||||||
|
"— possible socket leak")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Unit tests for the plugin_metrics rollup cutoff helpers (no DB)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from steward.core.metrics_retention import _hour_floor, _rollup_cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def test_hour_floor_drops_sub_hour():
|
||||||
|
dt = datetime(2026, 6, 20, 15, 42, 9, 123456, tzinfo=timezone.utc)
|
||||||
|
assert _hour_floor(dt) == datetime(2026, 6, 20, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollup_cutoff_is_hour_aligned_and_offset():
|
||||||
|
now = datetime(2026, 6, 20, 15, 42, 9, tzinfo=timezone.utc)
|
||||||
|
cutoff = _rollup_cutoff(now, 7)
|
||||||
|
assert (cutoff.minute, cutoff.second, cutoff.microsecond) == (0, 0, 0)
|
||||||
|
# 7 whole days back, then floored to the hour.
|
||||||
|
assert cutoff == datetime(2026, 6, 13, 15, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Unit tests for plugin-contributed sidebar nav collection."""
|
||||||
|
import types
|
||||||
|
|
||||||
|
from steward.core import plugin_manager as pm
|
||||||
|
|
||||||
|
|
||||||
|
def _mod(nav):
|
||||||
|
"""A stand-in plugin module; omit get_nav by passing nav=None."""
|
||||||
|
m = types.SimpleNamespace()
|
||||||
|
if nav is not None:
|
||||||
|
m.get_nav = lambda: nav
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_and_get(monkeypatch):
|
||||||
|
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
|
||||||
|
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/plugins/docker/"}]))
|
||||||
|
assert pm.get_plugin_nav() == [
|
||||||
|
{"plugin": "docker", "label": "Docker", "href": "/plugins/docker/", "section": "Infrastructure"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_hook_is_noop(monkeypatch):
|
||||||
|
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
|
||||||
|
pm._collect_plugin_nav("x", _mod(None))
|
||||||
|
assert pm.get_plugin_nav() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_replaces_same_plugin_entries(monkeypatch):
|
||||||
|
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
|
||||||
|
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/old"}]))
|
||||||
|
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/new"}]))
|
||||||
|
assert [e["href"] for e in pm.get_plugin_nav() if e["plugin"] == "docker"] == ["/new"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_item_skipped(monkeypatch):
|
||||||
|
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
|
||||||
|
pm._collect_plugin_nav("x", _mod([{"label": "NoHref"}, {"label": "Ok", "href": "/ok"}]))
|
||||||
|
assert [e["href"] for e in pm.get_plugin_nav()] == ["/ok"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_raising_hook_skipped(monkeypatch):
|
||||||
|
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
|
||||||
|
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("nope")
|
||||||
|
|
||||||
|
pm._collect_plugin_nav("x", types.SimpleNamespace(get_nav=boom))
|
||||||
|
assert pm.get_plugin_nav() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_plugin_nav_sorted_by_section_then_label(monkeypatch):
|
||||||
|
monkeypatch.setattr(pm, "_PLUGIN_NAV", [])
|
||||||
|
pm._collect_plugin_nav("traefik", _mod([{"label": "Traefik", "href": "/t"}]))
|
||||||
|
pm._collect_plugin_nav("docker", _mod([{"label": "Docker", "href": "/d"}]))
|
||||||
|
# Same default section → sorted by label.
|
||||||
|
assert [e["label"] for e in pm.get_plugin_nav()] == ["Docker", "Traefik"]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Unit tests for the scheduler's poll-overlap guard (_DueTracker).
|
||||||
|
|
||||||
|
The tracker is pure (clock passed in) so we can assert the overlap policy
|
||||||
|
deterministically: a task whose prior run is still in flight is never re-fired,
|
||||||
|
and a skipped task isn't penalised — it runs on the next tick once it finishes.
|
||||||
|
This is the rail that stops a hung poll from stacking overlapping runs (which
|
||||||
|
would amplify any per-poll resource/fd use).
|
||||||
|
"""
|
||||||
|
from steward.core.scheduler import ScheduledTask, _DueTracker
|
||||||
|
|
||||||
|
|
||||||
|
def _task(name: str, interval: int) -> ScheduledTask:
|
||||||
|
return ScheduledTask(name=name, coro_factory=lambda: None, interval_seconds=interval)
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_only_after_interval_elapses():
|
||||||
|
t = _task("a", 60)
|
||||||
|
tr = _DueTracker()
|
||||||
|
assert tr.due([t], now=59) == [] # 59 < 60
|
||||||
|
assert tr.due([t], now=60) == [t] # interval elapsed
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_flight_task_is_skipped_even_when_due():
|
||||||
|
t = _task("a", 0) # due every tick
|
||||||
|
tr = _DueTracker()
|
||||||
|
tr.mark_started(t, now=0)
|
||||||
|
assert tr.due([t], now=100) == [] # still running → skipped
|
||||||
|
tr.mark_done("a")
|
||||||
|
assert tr.due([t], now=100) == [t] # finished → eligible again
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_does_not_advance_last_run_so_it_retries():
|
||||||
|
t = _task("a", 10)
|
||||||
|
tr = _DueTracker()
|
||||||
|
tr.mark_started(t, now=0)
|
||||||
|
assert tr.due([t], now=100) == [] # due but in flight
|
||||||
|
assert tr.last_run["a"] == 0 # not advanced by the skip
|
||||||
|
tr.mark_done("a")
|
||||||
|
assert tr.due([t], now=100) == [t] # retried promptly after completion
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_started_advances_last_run_and_marks_in_flight():
|
||||||
|
t = _task("a", 10)
|
||||||
|
tr = _DueTracker()
|
||||||
|
tr.mark_started(t, now=50)
|
||||||
|
assert tr.last_run["a"] == 50
|
||||||
|
assert tr.in_flight == {"a"}
|
||||||
|
assert tr.due([t], now=55) == [] # 5 < 10 (not yet due)
|
||||||
|
assert tr.due([t], now=61) == [] # due by interval, but still in flight
|
||||||
|
tr.mark_done("a")
|
||||||
|
assert tr.due([t], now=61) == [t]
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_tasks_do_not_block_each_other():
|
||||||
|
a, b = _task("a", 0), _task("b", 0)
|
||||||
|
tr = _DueTracker()
|
||||||
|
tr.mark_started(a, now=0) # a hangs
|
||||||
|
assert tr.due([a, b], now=10) == [b] # b still fires
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Unit tests for the self-fd watchdog (no DB, no network).
|
||||||
|
|
||||||
|
Exercises the fd accounting, the percentage/warning floor, and that the
|
||||||
|
recorder degrades to a no-op where fd accounting isn't available. The DB session
|
||||||
|
and the alert pipeline are faked — we only assert that the right metrics get
|
||||||
|
handed to record_metric.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import steward.core.alerts as alerts
|
||||||
|
import steward.core.self_monitor as sm
|
||||||
|
|
||||||
|
|
||||||
|
# ── fd accounting ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_count_open_fds_sane_or_none():
|
||||||
|
n = sm.count_open_fds()
|
||||||
|
# On the Linux CI image /proc exists; stdin/stdout/stderr are always open.
|
||||||
|
assert n is None or n >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_fd_soft_limit_positive_or_none():
|
||||||
|
soft = sm.fd_soft_limit()
|
||||||
|
assert soft is None or soft > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── fake session / app plumbing ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _Ctx:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession(_Ctx):
|
||||||
|
def begin(self):
|
||||||
|
return _Ctx()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeApp:
|
||||||
|
def db_sessionmaker(self):
|
||||||
|
return _FakeSession()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_recorder(monkeypatch):
|
||||||
|
recorded: list[tuple] = []
|
||||||
|
|
||||||
|
async def fake_record_metric(session, source_module, resource_name, metric_name, value):
|
||||||
|
recorded.append((source_module, resource_name, metric_name, value))
|
||||||
|
|
||||||
|
monkeypatch.setattr(alerts, "record_metric", fake_record_metric)
|
||||||
|
return recorded
|
||||||
|
|
||||||
|
|
||||||
|
# ── record_self_metrics ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_records_both_fd_metrics(monkeypatch):
|
||||||
|
recorded = _patch_recorder(monkeypatch)
|
||||||
|
monkeypatch.setattr(sm, "count_open_fds", lambda: 50)
|
||||||
|
monkeypatch.setattr(sm, "fd_soft_limit", lambda: 1000)
|
||||||
|
|
||||||
|
asyncio.run(sm.record_self_metrics(_FakeApp()))
|
||||||
|
|
||||||
|
assert ("steward", "process", "open_fds", 50.0) in recorded
|
||||||
|
assert ("steward", "process", "open_fds_pct", 5.0) in recorded
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_pct_metric_when_limit_unknown(monkeypatch):
|
||||||
|
recorded = _patch_recorder(monkeypatch)
|
||||||
|
monkeypatch.setattr(sm, "count_open_fds", lambda: 50)
|
||||||
|
monkeypatch.setattr(sm, "fd_soft_limit", lambda: None)
|
||||||
|
|
||||||
|
asyncio.run(sm.record_self_metrics(_FakeApp()))
|
||||||
|
|
||||||
|
metric_names = [m for (_s, _r, m, _v) in recorded]
|
||||||
|
assert "open_fds" in metric_names
|
||||||
|
assert "open_fds_pct" not in metric_names # meaningless without a ceiling
|
||||||
|
|
||||||
|
|
||||||
|
def test_warns_past_floor(monkeypatch, caplog):
|
||||||
|
_patch_recorder(monkeypatch)
|
||||||
|
monkeypatch.setattr(sm, "count_open_fds", lambda: 900)
|
||||||
|
monkeypatch.setattr(sm, "fd_soft_limit", lambda: 1000) # 90% ≥ FD_WARN_PCT
|
||||||
|
monkeypatch.setattr(sm, "_warned", False)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sm.logger.name):
|
||||||
|
asyncio.run(sm.record_self_metrics(_FakeApp()))
|
||||||
|
|
||||||
|
assert any("soft limit" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_warning_below_floor(monkeypatch, caplog):
|
||||||
|
_patch_recorder(monkeypatch)
|
||||||
|
monkeypatch.setattr(sm, "count_open_fds", lambda: 100)
|
||||||
|
monkeypatch.setattr(sm, "fd_soft_limit", lambda: 1000) # 10%
|
||||||
|
monkeypatch.setattr(sm, "_warned", False)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sm.logger.name):
|
||||||
|
asyncio.run(sm.record_self_metrics(_FakeApp()))
|
||||||
|
|
||||||
|
assert not any("soft limit" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop_when_fd_count_unavailable(monkeypatch):
|
||||||
|
# /proc absent → must return before touching the DB.
|
||||||
|
monkeypatch.setattr(sm, "count_open_fds", lambda: None)
|
||||||
|
|
||||||
|
class _Boom:
|
||||||
|
def db_sessionmaker(self):
|
||||||
|
raise AssertionError("must not open a session when fds are unknown")
|
||||||
|
|
||||||
|
asyncio.run(sm.record_self_metrics(_Boom())) # no raise
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Unit tests for undecryptable-secret detection (the admin-banner source)."""
|
||||||
|
from steward.core.crypto import encrypt_secret, init_crypto
|
||||||
|
from steward.core.settings import _is_undecryptable, get_undecryptable_secrets
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_undecryptable_false_for_current_key():
|
||||||
|
init_crypto("key-A")
|
||||||
|
tok = encrypt_secret("hunter2")
|
||||||
|
assert _is_undecryptable(tok, "smtp.password") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_undecryptable_true_after_key_rotation():
|
||||||
|
init_crypto("key-A")
|
||||||
|
tok = encrypt_secret("hunter2") # sealed under key-A
|
||||||
|
init_crypto("key-B") # key changed/lost
|
||||||
|
assert _is_undecryptable(tok, "smtp.password") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_undecryptable_ignores_plaintext_and_empty():
|
||||||
|
init_crypto("key-A")
|
||||||
|
assert _is_undecryptable("", "smtp.password") is False
|
||||||
|
assert _is_undecryptable("plain-value", "smtp.password") is False
|
||||||
|
assert _is_undecryptable(None, "smtp.password") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_undecryptable_secrets_returns_sorted_list():
|
||||||
|
# Default (nothing scanned) is an empty list; the accessor is always safe to
|
||||||
|
# call from the template context processor.
|
||||||
|
assert isinstance(get_undecryptable_secrets(), list)
|
||||||
@@ -75,6 +75,28 @@ def test_events_and_swarm_tables_exist(app):
|
|||||||
asyncio.run(_go())
|
asyncio.run(_go())
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_docker_logs_table_shape(app):
|
||||||
|
"""docker_009 created docker_logs: host-scoped, with the twin indexes the
|
||||||
|
viewer (host, container, ts) and the age-cutoff prune (ts) rely on."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
cols = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'docker_logs'"))).all()}
|
||||||
|
assert cols, "docker_logs missing entirely"
|
||||||
|
assert {"id", "host_id", "container_name", "ts", "stream", "line"} <= cols
|
||||||
|
idx = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT indexname FROM pg_indexes "
|
||||||
|
"WHERE tablename = 'docker_logs'"))).all()}
|
||||||
|
assert "ix_docker_logs_host_container_time" in idx
|
||||||
|
assert "ix_docker_logs_ts" in idx
|
||||||
|
|
||||||
|
asyncio.run(_go())
|
||||||
|
|
||||||
|
|
||||||
def _persist_fn(app):
|
def _persist_fn(app):
|
||||||
"""Resolve persist_host_docker via the registered capability if the docker
|
"""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 loaded, else import it directly (the import is safe only when the
|
||||||
@@ -151,6 +173,137 @@ def test_persist_scopes_containers_by_host(app):
|
|||||||
|
|
||||||
|
|
||||||
@_NEEDS_DB
|
@_NEEDS_DB
|
||||||
|
def test_persist_logs_stores_lines(app):
|
||||||
|
"""Pushed container logs land in docker_logs, host-scoped, one row per line,
|
||||||
|
ordered by their own ts; the agent's _steward truncation marker is dropped."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
persist = _persist_fn(app)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
log_batches = [(now, [
|
||||||
|
{"container": "web", "stream": "stdout",
|
||||||
|
"ts": "2026-01-01T00:00:01+00:00", "line": "started"},
|
||||||
|
{"container": "web", "stream": "stderr",
|
||||||
|
"ts": "2026-01-01T00:00:02+00:00", "line": "warn: x"},
|
||||||
|
{"container": "_steward", "stream": "stderr", "ts": None, "line": "truncated"},
|
||||||
|
])]
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(text("DELETE FROM docker_logs"))
|
||||||
|
h = Host(id=str(uuid.uuid4()), name="loghost", address="10.0.0.9")
|
||||||
|
s.add(h)
|
||||||
|
await s.flush()
|
||||||
|
hid = h.id
|
||||||
|
# snapshots empty; logs passed as the 6th positional arg.
|
||||||
|
await persist(s, h, [], None, None, log_batches)
|
||||||
|
rows = (await s.execute(text(
|
||||||
|
"SELECT container_name, stream, line FROM docker_logs "
|
||||||
|
"WHERE host_id = :h ORDER BY ts"), {"h": hid})).all()
|
||||||
|
return [tuple(r) for r in rows]
|
||||||
|
|
||||||
|
assert asyncio.run(_go()) == [
|
||||||
|
("web", "stdout", "started"),
|
||||||
|
("web", "stderr", "warn: x"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@_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 types import SimpleNamespace
|
||||||
|
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"},
|
||||||
|
])]
|
||||||
|
|
||||||
|
hid = str(uuid.uuid4())
|
||||||
|
# Stand-in host: the logs-only persist path reads only host.id, so a plain
|
||||||
|
# object sidesteps ORM attribute-expiry across the commit boundaries below.
|
||||||
|
host = SimpleNamespace(id=hid, name="loghost3")
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
# Back-to-back begin blocks with NO read in between — a SELECT between
|
||||||
|
# them would autobegin a txn and make the next begin() collide.
|
||||||
|
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"])
|
||||||
|
s.add(Host(id=hid, name="loghost3", address="10.7.7.10"))
|
||||||
|
await s.flush() # host row before its log rows (FK order)
|
||||||
|
await persist(s, host, [], None, None, batch) # web kept, noisy excluded
|
||||||
|
async with s.begin(): # global kill-switch off → nothing stored
|
||||||
|
await set_setting(s, "docker.logs.enabled", False)
|
||||||
|
await persist(s, host, [], None, None, batch)
|
||||||
|
async with s.begin(): # restore defaults for other tests
|
||||||
|
await set_setting(s, "docker.logs.enabled", True)
|
||||||
|
await set_setting(s, "docker.logs.exclude", [])
|
||||||
|
# Reads only after the last begin block (they autobegin, but nothing
|
||||||
|
# opens a begin() after them). Toggle-off added nothing, so the state
|
||||||
|
# here still reflects the exclude phase.
|
||||||
|
kept = {r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT DISTINCT container_name FROM docker_logs WHERE host_id=:h"),
|
||||||
|
{"h": hid})).all()}
|
||||||
|
after_off = (await s.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM docker_logs WHERE host_id=:h"), {"h": hid})).scalar()
|
||||||
|
return kept, after_off
|
||||||
|
|
||||||
|
kept, after_off = asyncio.run(_go())
|
||||||
|
assert kept == {"web"} # excluded 'noisy' dropped on ingest
|
||||||
|
assert after_off == 1 # toggle off added nothing → still just the one 'web' line
|
||||||
|
|
||||||
|
|
||||||
|
@_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
|
||||||
|
/ mem_limit_bytes were int4 and overflowed (asyncpg 'value out of int32 range'),
|
||||||
|
failing the whole ingest batch for any host with a >2.1 GB container."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
persist = _persist_fn(app)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
big_usage = 8_185_077_760 # ~8.18 GB — well past int4 max (2_147_483_647)
|
||||||
|
big_limit = 17_179_869_184 # 16 GB
|
||||||
|
snapshot = [(now, [{
|
||||||
|
"name": "bigmem", "container_id": "deadbeef", "image": "postgres",
|
||||||
|
"status": "running", "cpu_pct": 1.0, "mem_pct": 50.0,
|
||||||
|
"mem_usage_bytes": big_usage, "mem_limit_bytes": big_limit,
|
||||||
|
"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"))
|
||||||
|
h = Host(id=str(uuid.uuid4()), name="bigmemhost", address="10.0.0.9")
|
||||||
|
s.add(h)
|
||||||
|
await s.flush()
|
||||||
|
await persist(s, h, snapshot)
|
||||||
|
row = (await s.execute(text(
|
||||||
|
"SELECT mem_usage_bytes, mem_limit_bytes FROM docker_containers "
|
||||||
|
"WHERE name = 'bigmem'"))).first()
|
||||||
|
metric = (await s.execute(text(
|
||||||
|
"SELECT mem_usage_bytes FROM docker_metrics "
|
||||||
|
"WHERE container_name = 'bigmem'"))).scalar()
|
||||||
|
return row, metric
|
||||||
|
|
||||||
|
row, metric = asyncio.run(_go())
|
||||||
|
assert row == (big_usage, big_limit) # current-state row round-trips
|
||||||
|
assert metric == big_usage # time-series row round-trips
|
||||||
|
|
||||||
|
|
||||||
def test_lifecycle_events_derived_across_snapshots(app):
|
def test_lifecycle_events_derived_across_snapshots(app):
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from steward.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
@@ -191,6 +344,44 @@ def test_lifecycle_events_derived_across_snapshots(app):
|
|||||||
assert not any(e[0] == "start" for e in events)
|
assert not any(e[0] == "start" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_vanished_container_is_reaped(app):
|
||||||
|
"""A container absent from the latest snapshot is deleted, not left behind as
|
||||||
|
a permanent stopped row. Regression: removed one-shot containers (CI jobs,
|
||||||
|
buildkit builders) accumulated forever and inflated the dashboard counts."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
persist = _persist_fn(app)
|
||||||
|
t1 = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 6, 19, 12, 0, 30, tzinfo=timezone.utc)
|
||||||
|
base = {"status": "running", "cpu_pct": 1.0, "mem_pct": 1.0,
|
||||||
|
"restart_count": 0, "exit_code": None, "oom_killed": False, "health": None}
|
||||||
|
keep = {**base, "name": "keep"}
|
||||||
|
gone = {**base, "name": "ci-job-123"}
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(text("DELETE FROM docker_containers"))
|
||||||
|
h = Host(id=str(uuid.uuid4()), name="reap", address="10.9.9.8")
|
||||||
|
s.add(h)
|
||||||
|
await s.flush()
|
||||||
|
await persist(s, h, [(t1, [keep, gone])]) # both present
|
||||||
|
before = [r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT name FROM docker_containers WHERE host_id = :h ORDER BY name"),
|
||||||
|
{"h": h.id})).all()]
|
||||||
|
await persist(s, h, [(t2, [keep])]) # ci-job-123 vanished
|
||||||
|
after = [r[0] for r in (await s.execute(text(
|
||||||
|
"SELECT name FROM docker_containers WHERE host_id = :h ORDER BY name"),
|
||||||
|
{"h": h.id})).all()]
|
||||||
|
return before, after
|
||||||
|
|
||||||
|
before, after = asyncio.run(_go())
|
||||||
|
assert before == ["ci-job-123", "keep"]
|
||||||
|
assert after == ["keep"] # the vanished container was reaped
|
||||||
|
|
||||||
|
|
||||||
@_NEEDS_DB
|
@_NEEDS_DB
|
||||||
def test_swarm_topology_persisted(app):
|
def test_swarm_topology_persisted(app):
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -267,6 +458,8 @@ def test_disk_usage_persisted(app):
|
|||||||
"FROM docker_disk_usage WHERE host_id=:h"), {"h": hid})).first()
|
"FROM docker_disk_usage WHERE host_id=:h"), {"h": hid})).first()
|
||||||
img_count = (await s.execute(text(
|
img_count = (await s.execute(text(
|
||||||
"SELECT COUNT(*) FROM docker_images WHERE host_id=:h"), {"h": hid})).scalar()
|
"SELECT COUNT(*) FROM docker_images WHERE host_id=:h"), {"h": hid})).scalar()
|
||||||
|
# End the autobegun read transaction before opening the next write one.
|
||||||
|
await s.rollback()
|
||||||
# Re-report with one image dropped — the stale row must be pruned.
|
# Re-report with one image dropped — the stale row must be pruned.
|
||||||
async with s.begin():
|
async with s.begin():
|
||||||
await persist(s, await s.get(Host, hid), [], None, disk2)
|
await persist(s, await s.get(Host, hid), [], None, disk2)
|
||||||
@@ -369,3 +562,84 @@ def test_retention_rollup_and_prune(app):
|
|||||||
assert events_left == 1 # only the in-window event survives
|
assert events_left == 1 # only the in-window event survives
|
||||||
assert counts["buckets_rolled"] == 1 and counts["raw_rows_rolled"] == 3
|
assert counts["buckets_rolled"] == 1 and counts["raw_rows_rolled"] == 3
|
||||||
assert counts["events_pruned"] == 1 and counts["rollup_pruned"] == 1
|
assert counts["events_pruned"] == 1 and counts["rollup_pruned"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_retention_logs_ring(app):
|
||||||
|
"""docker_logs ring (m79): lines past the age window are pruned; within it,
|
||||||
|
each container keeps only the newest ~cap bytes; containers are independent."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.hosts import Host
|
||||||
|
|
||||||
|
run_retention = _retention_fn(app)
|
||||||
|
now = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
line40 = "x" * 40 # 40 chars/line; cap=100 keeps 3 lines (excl-prefix 0/40/80)
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(text("DELETE FROM docker_logs"))
|
||||||
|
h = Host(id=str(uuid.uuid4()), name="loghost2", address="10.7.7.8")
|
||||||
|
s.add(h)
|
||||||
|
await s.flush()
|
||||||
|
hid = h.id
|
||||||
|
|
||||||
|
def ins(cn, ts, line):
|
||||||
|
return s.execute(text(
|
||||||
|
"INSERT INTO docker_logs "
|
||||||
|
"(id, host_id, container_name, ts, stream, line) "
|
||||||
|
"VALUES (:id,:h,:cn,:ts,'stdout',:line)"),
|
||||||
|
{"id": str(uuid.uuid4()), "h": hid, "cn": cn,
|
||||||
|
"ts": ts, "line": line})
|
||||||
|
|
||||||
|
# Past the 3-day age window → age-pruned.
|
||||||
|
await ins("old", now - timedelta(days=10), "z")
|
||||||
|
# 5 recent 'web' lines (40 bytes each = 200 > cap 100) → keep 3.
|
||||||
|
for i in range(1, 6):
|
||||||
|
await ins("web", now - timedelta(minutes=6 - i), line40)
|
||||||
|
# 2 recent 'db' lines (80 bytes ≤ cap) → both survive (isolation).
|
||||||
|
await ins("db", now - timedelta(minutes=2), line40)
|
||||||
|
await ins("db", now - timedelta(minutes=1), line40)
|
||||||
|
|
||||||
|
async with s.begin():
|
||||||
|
counts = await run_retention(
|
||||||
|
s, events_days=30, metrics_raw_days=7, metrics_rollup_days=90,
|
||||||
|
logs_retention_days=3, logs_max_bytes_per_container=100, now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def n(cn):
|
||||||
|
return s.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM docker_logs WHERE host_id=:h AND container_name=:cn"),
|
||||||
|
{"h": hid, "cn": cn})
|
||||||
|
web = (await n("web")).scalar()
|
||||||
|
db = (await n("db")).scalar()
|
||||||
|
old = (await n("old")).scalar()
|
||||||
|
return counts, web, db, old
|
||||||
|
|
||||||
|
counts, web, db, old = asyncio.run(_go())
|
||||||
|
assert old == 0 # age window
|
||||||
|
assert web == 3 # newest ~100 bytes kept, oldest 2 dropped
|
||||||
|
assert db == 2 # under cap → untouched (per-container)
|
||||||
|
assert counts["logs_age_pruned"] == 1
|
||||||
|
assert counts["logs_size_pruned"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_dedup_collapses_cross_manager_duplicates():
|
||||||
|
"""The same swarm task is reported by every manager (identical container_id);
|
||||||
|
the dashboard widget must count it once. Older agents send no container_id,
|
||||||
|
so those rows can't be matched and are all kept."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from plugins.docker.dedup import dedup_by_container_id
|
||||||
|
|
||||||
|
def c(cid, name, status="running"):
|
||||||
|
return SimpleNamespace(container_id=cid, name=name, status=status)
|
||||||
|
|
||||||
|
rows = [c("abc", "web@m1"), c("abc", "web@m2"), # same task, two managers
|
||||||
|
c("def", "db"), c("", "noid-1"), c("", "noid-2")]
|
||||||
|
out = dedup_by_container_id(rows)
|
||||||
|
|
||||||
|
assert len(out) == 4 # one "abc" dropped
|
||||||
|
abc = [r for r in out if r.container_id == "abc"]
|
||||||
|
assert len(abc) == 1 and abc[0].name == "web@m1" # first occurrence wins
|
||||||
|
assert sum(1 for r in out if r.container_id == "") == 2 # id-less rows all kept
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Integration: host-metrics read paths (DISTINCT ON latest + SQL date_bin history).
|
||||||
|
|
||||||
|
Validates the two host_agent query helpers that back the slow host views, against
|
||||||
|
a live Postgres: the latest-per-(resource,metric) lookup and the bucket-averaged
|
||||||
|
history (which now aggregates in SQL instead of shipping raw rows to Python).
|
||||||
|
Requires STEWARD_DATABASE_URL.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, 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_latest_distinct_on_and_sql_bucketed_history(app):
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.metrics import PluginMetric
|
||||||
|
from plugins.host_agent.metrics_query import (
|
||||||
|
SOURCE_MODULE, _history_for_host, _latest_metrics_for_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hostname = "metrics-host-" + uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
await s.execute(
|
||||||
|
text("DELETE FROM plugin_metrics WHERE resource_name LIKE :p"),
|
||||||
|
{"p": hostname + "%"},
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
# Host-level CPU samples, oldest → newest (10, 20, 30).
|
||||||
|
for i, val in enumerate([10.0, 20.0, 30.0]):
|
||||||
|
rows.append(PluginMetric(
|
||||||
|
source_module=SOURCE_MODULE, resource_name=hostname,
|
||||||
|
metric_name="cpu_pct", value=val,
|
||||||
|
recorded_at=now - timedelta(minutes=30 - i * 10),
|
||||||
|
))
|
||||||
|
# A sub-resource (root mount) to exercise the host:% match.
|
||||||
|
rows.append(PluginMetric(
|
||||||
|
source_module=SOURCE_MODULE, resource_name=hostname + ":/",
|
||||||
|
metric_name="disk_used_pct", value=80.0,
|
||||||
|
recorded_at=now - timedelta(minutes=5),
|
||||||
|
))
|
||||||
|
s.add_all(rows)
|
||||||
|
|
||||||
|
latest = await _latest_metrics_for_host(s, hostname)
|
||||||
|
hist = await _history_for_host(s, hostname, now - timedelta(hours=1))
|
||||||
|
return latest, hist
|
||||||
|
|
||||||
|
latest, hist = asyncio.run(_go())
|
||||||
|
|
||||||
|
# DISTINCT ON returns the newest sample per (resource, metric).
|
||||||
|
assert latest[hostname]["cpu_pct"] == 30.0
|
||||||
|
assert latest[hostname + ":/"]["disk_used_pct"] == 80.0
|
||||||
|
|
||||||
|
# SQL date_bin aggregation returns cpu_pct buckets; averages stay in range.
|
||||||
|
cpu = hist["cpu_pct"]
|
||||||
|
assert cpu, "expected bucketed cpu_pct history"
|
||||||
|
assert all(10.0 <= v <= 30.0 for _, v in cpu)
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_rollup_aggregates_old_raw_and_prunes(app):
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.core.metrics_retention import rollup_plugin_metrics
|
||||||
|
from steward.models.metrics import PluginMetric
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hostname = "rollup-host-" + uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
for tbl in ("plugin_metrics", "plugin_metrics_hourly"):
|
||||||
|
await s.execute(
|
||||||
|
text(f"DELETE FROM {tbl} WHERE resource_name LIKE :p"),
|
||||||
|
{"p": hostname + "%"},
|
||||||
|
)
|
||||||
|
old = (now - timedelta(days=10)).replace(minute=5, second=0, microsecond=0)
|
||||||
|
s.add_all([
|
||||||
|
PluginMetric(source_module="host_agent", resource_name=hostname,
|
||||||
|
metric_name="cpu_pct", value=10.0, recorded_at=old),
|
||||||
|
PluginMetric(source_module="host_agent", resource_name=hostname,
|
||||||
|
metric_name="cpu_pct", value=20.0,
|
||||||
|
recorded_at=old.replace(minute=35)), # same hour bucket
|
||||||
|
PluginMetric(source_module="host_agent", resource_name=hostname,
|
||||||
|
metric_name="cpu_pct", value=99.0,
|
||||||
|
recorded_at=now - timedelta(hours=1)), # recent → kept raw
|
||||||
|
])
|
||||||
|
async with s.begin():
|
||||||
|
counts = await rollup_plugin_metrics(s, raw_days=7, rollup_days=90, now=now)
|
||||||
|
hrly = (await s.execute(text(
|
||||||
|
"SELECT value_avg, sample_count FROM plugin_metrics_hourly "
|
||||||
|
"WHERE resource_name = :h AND metric_name = 'cpu_pct'"), {"h": hostname})).all()
|
||||||
|
raw_left = (await s.execute(text(
|
||||||
|
"SELECT count(*) FROM plugin_metrics WHERE resource_name = :h"), {"h": hostname})).scalar()
|
||||||
|
return counts, hrly, raw_left
|
||||||
|
|
||||||
|
counts, hrly, raw_left = asyncio.run(_go())
|
||||||
|
assert counts["buckets_rolled"] == 1
|
||||||
|
assert counts["raw_rows_rolled"] == 2
|
||||||
|
assert len(hrly) == 1
|
||||||
|
assert abs(float(hrly[0][0]) - 15.0) < 0.001 # avg(10, 20)
|
||||||
|
assert hrly[0][1] == 2
|
||||||
|
assert raw_left == 1 # only the recent sample remains
|
||||||
|
|
||||||
|
|
||||||
|
@_NEEDS_DB
|
||||||
|
def test_history_merges_hourly_and_raw(app):
|
||||||
|
from sqlalchemy import text
|
||||||
|
from steward.models.metrics import PluginMetric, PluginMetricHourly
|
||||||
|
from plugins.host_agent.metrics_query import _history_for_host
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hostname = "merge-host-" + uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
async def _go():
|
||||||
|
async with app.db_sessionmaker() as s:
|
||||||
|
async with s.begin():
|
||||||
|
for tbl in ("plugin_metrics", "plugin_metrics_hourly"):
|
||||||
|
await s.execute(
|
||||||
|
text(f"DELETE FROM {tbl} WHERE resource_name LIKE :p"),
|
||||||
|
{"p": hostname + "%"},
|
||||||
|
)
|
||||||
|
old_bucket = (now - timedelta(days=10)).replace(minute=0, second=0, microsecond=0)
|
||||||
|
s.add(PluginMetricHourly(
|
||||||
|
source_module="host_agent", resource_name=hostname, metric_name="cpu_pct",
|
||||||
|
bucket=old_bucket, value_avg=42.0, value_max=50.0, sample_count=120))
|
||||||
|
s.add(PluginMetric(
|
||||||
|
source_module="host_agent", resource_name=hostname, metric_name="cpu_pct",
|
||||||
|
value=77.0, recorded_at=now - timedelta(hours=1)))
|
||||||
|
# 30d range with a 7d raw window → spans the rollup boundary.
|
||||||
|
return await _history_for_host(s, hostname, now - timedelta(days=30), raw_days=7)
|
||||||
|
|
||||||
|
hist = asyncio.run(_go())
|
||||||
|
cpu = hist["cpu_pct"]
|
||||||
|
vals = [v for _, v in cpu]
|
||||||
|
assert 42.0 in vals, "expected the rolled-up hourly point"
|
||||||
|
assert 77.0 in vals, "expected the recent raw point"
|
||||||
|
assert cpu == sorted(cpu, key=lambda p: p[0]), "series must be time-ordered"
|
||||||
@@ -65,3 +65,35 @@ def test_no_event_when_unchanged():
|
|||||||
old = {"web": {"status": "running", "health": "healthy", "oom_killed": False, "exit_code": None}}
|
old = {"web": {"status": "running", "health": "healthy", "oom_killed": False, "exit_code": None}}
|
||||||
events = _derive_events(old, [_c("web", "running", health="healthy")])
|
events = _derive_events(old, [_c("web", "running", health="healthy")])
|
||||||
assert events == []
|
assert events == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── container-log row shaping (m79, pure) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_log_rows_shapes_and_filters():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from plugins.docker.ingest import _log_rows
|
||||||
|
|
||||||
|
rec_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
batches = [(rec_at, [
|
||||||
|
{"container": "web", "stream": "stdout",
|
||||||
|
"ts": "2026-01-01T00:00:01+00:00", "line": "hello"},
|
||||||
|
{"container": "web", "stream": "stderr", "ts": None, "line": "no-ts"},
|
||||||
|
{"container": "_steward", "stream": "stderr",
|
||||||
|
"ts": None, "line": "truncated"}, # advisory marker → dropped
|
||||||
|
{"container": "web", "stream": "weird", "ts": None, "line": "bad-stream"},
|
||||||
|
{"container": "", "line": "x"}, # no name → dropped
|
||||||
|
{"container": "web", "ts": None, "line": None}, # no line → dropped
|
||||||
|
"not-a-dict", # malformed → dropped
|
||||||
|
])]
|
||||||
|
rows = list(_log_rows(batches, "host-1"))
|
||||||
|
assert [r["line"] for r in rows] == ["hello", "no-ts", "bad-stream"]
|
||||||
|
assert rows[0]["host_id"] == "host-1" and rows[0]["container_name"] == "web"
|
||||||
|
assert rows[1]["ts"] == rec_at # None ts → recorded_at fallback
|
||||||
|
assert rows[2]["stream"] == "stdout" # unknown stream normalised
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_rows_skips_non_list_records():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from plugins.docker.ingest import _log_rows
|
||||||
|
rec_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
assert list(_log_rows([(rec_at, None)], "h")) == []
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Unit tests for the docker plugin's presentation layer (no DB).
|
||||||
|
|
||||||
|
CI never renders these templates through the running app (the unit-lane app is
|
||||||
|
created with testing=True, which skips plugin loading), so a Jinja syntax error
|
||||||
|
would otherwise ship green. These tests parse every docker template and smoke
|
||||||
|
the routes module so a broken tag or import is caught in the unit lane.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
from plugins.docker import routes as r
|
||||||
|
|
||||||
|
_TEMPLATES = pathlib.Path(r.__file__).parent / "templates" / "docker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_docker_templates_parse():
|
||||||
|
env = jinja2.Environment()
|
||||||
|
files = sorted(_TEMPLATES.glob("*.html"))
|
||||||
|
assert files, "no docker templates found"
|
||||||
|
for f in files:
|
||||||
|
# Raises TemplateSyntaxError on an unbalanced/typo'd tag.
|
||||||
|
env.parse(f.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_module_exposes_new_views():
|
||||||
|
# Import-smoke + confirms the #942 view functions are defined.
|
||||||
|
for name in ("container_detail", "container_history", "swarm", "disk", "index", "rows"):
|
||||||
|
assert callable(getattr(r, name)), name
|
||||||
|
assert r.docker_bp.name == "docker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_prune_view_defined():
|
||||||
|
# M78 admin-gated prune action.
|
||||||
|
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."""
|
||||||
|
assert r._prune_extra_vars("containers") == {"prune_target": "containers"}
|
||||||
|
assert r._prune_extra_vars("images") == {
|
||||||
|
"prune_target": "images", "prune_all_images": True,
|
||||||
|
}
|
||||||
|
# System prune stays conservative — no prune_all_images key.
|
||||||
|
assert r._prune_extra_vars("system") == {"prune_target": "system"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_bytes_formats_binary_units():
|
||||||
|
assert r._human_bytes(None) == "—"
|
||||||
|
assert r._human_bytes(0) == "0 B"
|
||||||
|
assert r._human_bytes(512) == "512 B"
|
||||||
|
assert r._human_bytes(1024) == "1.0 KiB"
|
||||||
|
assert r._human_bytes(1536) == "1.5 KiB"
|
||||||
|
assert r._human_bytes(1024 ** 2) == "1.0 MiB"
|
||||||
|
assert r._human_bytes(int(1.5 * 1024 ** 3)) == "1.5 GiB"
|
||||||
|
assert r._human_bytes(1024 ** 4) == "1.0 TiB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_human_uptime_compact():
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
assert r._human_uptime(None) is None
|
||||||
|
assert r._human_uptime(now - timedelta(minutes=8)).endswith("m")
|
||||||
|
assert "h" in r._human_uptime(now - timedelta(hours=5, minutes=12))
|
||||||
|
assert "d" in r._human_uptime(now - timedelta(days=3, hours=4))
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Unit tests for the swarm-aware container view builder (pure, no DB/ORM)."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from plugins.docker.swarm_view import build_swarm_services
|
||||||
|
|
||||||
|
NODE_HOSTNAME = {"nA": "docker01", "nB": "docker02", "nC": "docker03"}
|
||||||
|
HOST_NAME = {"h1": "docker01", "h2": "docker02"} # agents run on docker01/02 only
|
||||||
|
|
||||||
|
|
||||||
|
def _c(host_id, name, *, service=None, node=None, status="running", cpu=1.0):
|
||||||
|
return SimpleNamespace(host_id=host_id, name=name, service_name=service,
|
||||||
|
node_id=node, status=status, cpu_pct=cpu, mem_pct=2.0,
|
||||||
|
health=None, restart_count=0, container_id=name)
|
||||||
|
|
||||||
|
|
||||||
|
def _svc(name, running, desired, placement, mode="replicated"):
|
||||||
|
import json
|
||||||
|
return SimpleNamespace(service_name=name, running=running, desired=desired,
|
||||||
|
mode=mode, placement_json=json.dumps(placement))
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_replicas_plus_agentless_ghost():
|
||||||
|
containers = [
|
||||||
|
_c("h1", "web.1.aaa", service="web", node="nA"),
|
||||||
|
_c("h2", "web.2.bbb", service="web", node="nB"),
|
||||||
|
_c("h1", "db", node=None), # standalone, non-swarm
|
||||||
|
]
|
||||||
|
services = [_svc("web", running=3, desired=3,
|
||||||
|
placement=[{"node_id": "nA", "running": 1},
|
||||||
|
{"node_id": "nB", "running": 1},
|
||||||
|
{"node_id": "nC", "running": 1}])] # nC has no agent
|
||||||
|
|
||||||
|
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
|
||||||
|
|
||||||
|
assert len(view["services"]) == 1
|
||||||
|
svc = view["services"][0]
|
||||||
|
assert svc["name"] == "web" and svc["state"] == "healthy"
|
||||||
|
reals = [r for r in svc["replicas"] if not r["ghost"]]
|
||||||
|
ghosts = [r for r in svc["replicas"] if r["ghost"]]
|
||||||
|
assert sorted(r["host"] for r in reals) == ["docker01", "docker02"]
|
||||||
|
assert len(ghosts) == 1 and ghosts[0]["host"] == "docker03" and ghosts[0]["count"] == 1
|
||||||
|
|
||||||
|
# The non-swarm container is grouped by host, untouched.
|
||||||
|
assert len(view["standalone"]) == 1
|
||||||
|
g = view["standalone"][0]
|
||||||
|
assert g["host"] == "docker01" and [c.name for c in g["containers"]] == ["db"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_placement_fully_covered_has_no_ghosts():
|
||||||
|
containers = [_c("h1", "api.1.x", service="api", node="nA"),
|
||||||
|
_c("h2", "api.2.y", service="api", node="nB")]
|
||||||
|
services = [_svc("api", 2, 2, [{"node_id": "nA", "running": 1},
|
||||||
|
{"node_id": "nB", "running": 1}])]
|
||||||
|
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
|
||||||
|
assert all(not r["ghost"] for r in view["services"][0]["replicas"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_tasks_same_node_partially_agentless():
|
||||||
|
# Placement says 2 running on nA, but we only have 1 local row → 1 ghost on nA.
|
||||||
|
containers = [_c("h1", "cache.1.x", service="cache", node="nA")]
|
||||||
|
services = [_svc("cache", 2, 2, [{"node_id": "nA", "running": 2}])]
|
||||||
|
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
|
||||||
|
ghosts = [r for r in view["services"][0]["replicas"] if r["ghost"]]
|
||||||
|
assert len(ghosts) == 1 and ghosts[0]["count"] == 1 and ghosts[0]["host"] == "docker01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_without_a_service_row_is_synthesised():
|
||||||
|
# Container labeled with a service, but no manager reported the service row.
|
||||||
|
containers = [_c("h1", "orphan.1.x", service="orphan", node="nA")]
|
||||||
|
view = build_swarm_services(containers, [], NODE_HOSTNAME, HOST_NAME)
|
||||||
|
svc = view["services"][0]
|
||||||
|
assert svc["name"] == "orphan" and svc["running"] == 1 and svc["desired"] == 1
|
||||||
|
assert all(not r["ghost"] for r in svc["replicas"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_down_service_state():
|
||||||
|
services = [_svc("idle", 0, 2, [])]
|
||||||
|
view = build_swarm_services([], services, NODE_HOSTNAME, HOST_NAME)
|
||||||
|
assert view["services"][0]["state"] == "down"
|
||||||
@@ -401,3 +401,190 @@ def test_read_config_honours_explicit_docker_socket(tmp_path):
|
|||||||
"docker_socket = /run/user/1000/docker.sock\n")
|
"docker_socket = /run/user/1000/docker.sock\n")
|
||||||
cfg = a.read_config(str(cfg_file))
|
cfg = a.read_config(str(cfg_file))
|
||||||
assert cfg["docker_socket"] == "/run/user/1000/docker.sock"
|
assert cfg["docker_socket"] == "/run/user/1000/docker.sock"
|
||||||
|
|
||||||
|
|
||||||
|
# ── container logs (m79) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _frame(stream: int, payload: bytes) -> bytes:
|
||||||
|
"""Build one Docker multiplexed-log frame (8-byte header + payload)."""
|
||||||
|
return bytes([stream, 0, 0, 0]) + len(payload).to_bytes(4, "big") + payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_demux_docker_logs_framed():
|
||||||
|
raw = _frame(1, b"out line\n") + _frame(2, b"err line\n")
|
||||||
|
assert a._demux_docker_logs(raw) == [
|
||||||
|
("stdout", b"out line\n"), ("stderr", b"err line\n")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_demux_docker_logs_tty_fallback():
|
||||||
|
# A TTY container's stream isn't framed — the leading byte ('2') is not a
|
||||||
|
# valid stream id, so the whole blob is treated as one stdout payload.
|
||||||
|
raw = b"2023-11-14T12:00:00.000000000Z hi\n"
|
||||||
|
assert a._demux_docker_logs(raw) == [("stdout", raw)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_demux_docker_logs_empty():
|
||||||
|
assert a._demux_docker_logs(b"") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_log_ts_handles_nanoseconds_and_z():
|
||||||
|
dt = a._parse_log_ts("2023-11-14T12:00:00.123456789Z")
|
||||||
|
assert dt is not None
|
||||||
|
assert dt.year == 2023 and dt.microsecond == 123456 # ns trimmed to µs
|
||||||
|
assert dt.utcoffset().total_seconds() == 0
|
||||||
|
assert a._parse_log_ts("not-a-time") is None
|
||||||
|
assert a._parse_log_ts("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_container_logs_splits_streams_and_ts():
|
||||||
|
raw = (_frame(1, b"2023-11-14T12:00:00.000000001Z hello world\n")
|
||||||
|
+ _frame(2, b"2023-11-14T12:00:01.000000000Z oops\n"))
|
||||||
|
parsed = a._parse_container_logs(raw)
|
||||||
|
assert len(parsed) == 2
|
||||||
|
dt0, s0, l0 = parsed[0]
|
||||||
|
dt1, s1, l1 = parsed[1]
|
||||||
|
assert (s0, l0) == ("stdout", "hello world")
|
||||||
|
assert (s1, l1) == ("stderr", "oops")
|
||||||
|
assert dt1 > dt0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_container_logs_keeps_unparseable_line():
|
||||||
|
# No timestamp prefix (e.g. a partial write) → dt is None, full line kept.
|
||||||
|
parsed = a._parse_container_logs(_frame(1, b"no-timestamp-here\n"))
|
||||||
|
assert parsed == [(None, "stdout", "no-timestamp-here")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_since_cursor_and_dedup(monkeypatch):
|
||||||
|
containers = [{"name": "web", "status": "running"}]
|
||||||
|
calls = []
|
||||||
|
t1 = "2023-11-14T12:00:00.000000000Z"
|
||||||
|
t2 = "2023-11-14T12:00:01.000000000Z"
|
||||||
|
t3 = "2023-11-14T12:00:02.000000000Z"
|
||||||
|
|
||||||
|
def fake_raw(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||||
|
calls.append(path)
|
||||||
|
if "tail=" in path: # first interval seeds from a tail
|
||||||
|
return _frame(1, f"{t1} a\n".encode()) + _frame(1, f"{t2} b\n".encode())
|
||||||
|
# second interval: daemon re-returns the boundary line (t2) + a new one
|
||||||
|
return _frame(1, f"{t2} b\n".encode()) + _frame(1, f"{t3} c\n".encode())
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "_docker_request_raw", fake_raw)
|
||||||
|
state: dict = {}
|
||||||
|
first = a.collect_docker_logs("/sock", containers, state)
|
||||||
|
assert [r["line"] for r in first] == ["a", "b"]
|
||||||
|
assert "tail=" in calls[0]
|
||||||
|
|
||||||
|
second = a.collect_docker_logs("/sock", containers, state)
|
||||||
|
# boundary line b (t2) deduped against the cursor; only c (t3) is new
|
||||||
|
assert [r["line"] for r in second] == ["c"]
|
||||||
|
assert "since=" in calls[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_excludes_and_skips_non_running(monkeypatch):
|
||||||
|
containers = [
|
||||||
|
{"name": "web", "status": "running"},
|
||||||
|
{"name": "noisy", "status": "running"},
|
||||||
|
{"name": "db", "status": "exited"},
|
||||||
|
]
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_raw(socket_path, path, timeout=a.DOCKER_API_TIMEOUT):
|
||||||
|
seen.append(path)
|
||||||
|
return _frame(1, b"2023-11-14T12:00:00.000000000Z x\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "_docker_request_raw", fake_raw)
|
||||||
|
out = a.collect_docker_logs("/sock", containers, {}, exclude=["noisy"])
|
||||||
|
# only web is fetched: noisy is excluded, db isn't running
|
||||||
|
assert seen and all("/containers/web/" in p for p in seen)
|
||||||
|
assert {r["container"] for r in out} == {"web"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_byte_cap_truncates(monkeypatch):
|
||||||
|
containers = [{"name": "web", "status": "running"}]
|
||||||
|
blob = b"".join(
|
||||||
|
_frame(1, f"2023-11-14T12:00:{i:02d}.000000000Z {'x' * 20}\n".encode())
|
||||||
|
for i in range(10))
|
||||||
|
monkeypatch.setattr(a, "_docker_request_raw",
|
||||||
|
lambda s, p, timeout=a.DOCKER_API_TIMEOUT: blob)
|
||||||
|
out = a.collect_docker_logs("/sock", containers, {}, max_bytes=50)
|
||||||
|
# Once the cap is crossed a single marker line is appended and we stop.
|
||||||
|
assert out[-1]["container"] == "_steward"
|
||||||
|
assert "truncated" in out[-1]["line"]
|
||||||
|
real = [r for r in out if r["container"] == "web"]
|
||||||
|
assert 0 < len(real) < 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_docker_logs_forgets_gone_container_cursors(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
a, "_docker_request_raw",
|
||||||
|
lambda s, p, timeout=a.DOCKER_API_TIMEOUT:
|
||||||
|
_frame(1, b"2023-11-14T12:00:00.000000000Z x\n"))
|
||||||
|
state: dict = {}
|
||||||
|
a.collect_docker_logs("/sock", [{"name": "web", "status": "running"}], state)
|
||||||
|
assert "web" in state["docker_log_cursors"]
|
||||||
|
# web is gone next interval → its cursor is pruned so state can't grow forever
|
||||||
|
a.collect_docker_logs("/sock", [{"name": "db", "status": "running"}], state)
|
||||||
|
assert "web" not in state["docker_log_cursors"]
|
||||||
|
assert "db" in state["docker_log_cursors"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sample_includes_logs_when_present(monkeypatch):
|
||||||
|
monkeypatch.setattr(a, "collect_docker",
|
||||||
|
lambda _s: [{"name": "web", "status": "running"}])
|
||||||
|
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||||
|
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
a, "collect_docker_logs",
|
||||||
|
lambda *args, **kw: [{"container": "web", "stream": "stdout",
|
||||||
|
"ts": "t", "line": "hello"}])
|
||||||
|
sample = a.build_sample(["/"], {}, "/var/run/docker.sock")
|
||||||
|
assert sample["docker_logs"][0]["line"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sample_omits_logs_when_disabled(monkeypatch):
|
||||||
|
monkeypatch.setattr(a, "collect_docker",
|
||||||
|
lambda _s: [{"name": "web", "status": "running"}])
|
||||||
|
monkeypatch.setattr(a, "collect_swarm", lambda _s: None)
|
||||||
|
monkeypatch.setattr(a, "collect_disk_usage", lambda _s: None)
|
||||||
|
called = {"logs": False}
|
||||||
|
|
||||||
|
def _logs(*args, **kw):
|
||||||
|
called["logs"] = True
|
||||||
|
return [{"container": "web", "line": "x"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(a, "collect_docker_logs", _logs)
|
||||||
|
sample = a.build_sample(["/"], {}, "/var/run/docker.sock",
|
||||||
|
docker_logs_enabled=False)
|
||||||
|
assert "docker_logs" not in sample
|
||||||
|
assert called["logs"] is False # collection skipped entirely, not just dropped
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_logs_strips_only_logs():
|
||||||
|
sample = {"ts": "t", "cpu_pct": 5.0,
|
||||||
|
"docker": [{"name": "web"}], "docker_logs": [{"line": "x"}]}
|
||||||
|
out = a._drop_logs(sample)
|
||||||
|
assert "docker_logs" not in out
|
||||||
|
assert out["docker"] == [{"name": "web"}] and out["cpu_pct"] == 5.0 # metrics kept
|
||||||
|
assert a._drop_logs(out) is out # idempotent
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_docker_logs_default_on(tmp_path):
|
||||||
|
p = tmp_path / "agent.conf"
|
||||||
|
p.write_text("url = x\ntoken = y\n")
|
||||||
|
cfg = a.read_config(str(p))
|
||||||
|
assert cfg["docker_logs_enabled"] is True
|
||||||
|
assert cfg["docker_log_exclude"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_disables_docker_logs(tmp_path):
|
||||||
|
p = tmp_path / "agent.conf"
|
||||||
|
p.write_text("url = x\ntoken = y\ndocker_logs_enabled = false\n")
|
||||||
|
cfg = a.read_config(str(p))
|
||||||
|
assert cfg["docker_logs_enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_parses_docker_log_exclude(tmp_path):
|
||||||
|
p = tmp_path / "agent.conf"
|
||||||
|
p.write_text("url = x\ntoken = y\ndocker_log_exclude = watchtower, foo\n")
|
||||||
|
cfg = a.read_config(str(p))
|
||||||
|
assert cfg["docker_log_exclude"] == ["watchtower", "foo"]
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Unit tests for mapping config-defined SNMP devices onto a Steward host."""
|
||||||
|
from plugins.snmp.routes import _devices_for_host
|
||||||
|
|
||||||
|
_CFG = [
|
||||||
|
{"name": "core-switch", "host": "192.168.1.1"},
|
||||||
|
{"name": "ups", "host": "192.168.1.10"},
|
||||||
|
{"name": "by-name", "host": "edge-router"},
|
||||||
|
"not-a-dict", # tolerated, skipped
|
||||||
|
{"name": "no-host"}, # no host → never matches
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_by_address():
|
||||||
|
out = _devices_for_host(_CFG, "192.168.1.1", "somehost")
|
||||||
|
assert [d["name"] for d in out] == ["core-switch"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_by_name_case_insensitive():
|
||||||
|
out = _devices_for_host(_CFG, "10.0.0.9", "Edge-Router")
|
||||||
|
assert [d["name"] for d in out] == ["by-name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_match_returns_empty():
|
||||||
|
assert _devices_for_host(_CFG, "10.0.0.1", "nope") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_address_and_name_match_nothing():
|
||||||
|
# A host with no address/name must not match a device with a blank host.
|
||||||
|
assert _devices_for_host(_CFG, "", None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_implicit_match_unaffected_when_host_id_passed():
|
||||||
|
# Passing the new host_id arg must not break the legacy implicit match.
|
||||||
|
out = _devices_for_host(_CFG, "192.168.1.1", "somehost", host_id="h-1")
|
||||||
|
assert [d["name"] for d in out] == ["core-switch"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Explicit binding ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_explicit_host_id_matches_exact_uuid():
|
||||||
|
cfg = [{"name": "sw", "host": "10.0.0.5", "host_id": "uuid-abc"}]
|
||||||
|
out = _devices_for_host(cfg, "10.0.0.9", "other", host_id="uuid-abc")
|
||||||
|
assert [d["name"] for d in out] == ["sw"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_steward_host_matches_by_name_or_address():
|
||||||
|
cfg = [{"name": "pdu", "host": "10.0.0.5", "steward_host": "Switch01"}]
|
||||||
|
# Bind hits the Host's name despite a different poll target / address.
|
||||||
|
by_name = _devices_for_host(cfg, "10.0.0.9", "switch01", host_id="h9")
|
||||||
|
by_addr = _devices_for_host(cfg, "switch01", "whatever", host_id="h9")
|
||||||
|
assert [d["name"] for d in by_name] == ["pdu"]
|
||||||
|
assert [d["name"] for d in by_addr] == ["pdu"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_binding_is_exclusive():
|
||||||
|
# Device polls 192.168.1.1 but is explicitly bound to host A by name.
|
||||||
|
cfg = [{"name": "sw", "host": "192.168.1.1", "steward_host": "hostA"}]
|
||||||
|
# Host B *would* match implicitly by the poll target — but must NOT,
|
||||||
|
# because the explicit binding takes over and points only at host A.
|
||||||
|
hostB = _devices_for_host(cfg, "192.168.1.1", "hostB", host_id="b")
|
||||||
|
hostA = _devices_for_host(cfg, "10.0.0.2", "hostA", host_id="a")
|
||||||
|
assert hostB == []
|
||||||
|
assert [d["name"] for d in hostA] == ["sw"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_host_id_wrong_uuid_does_not_match():
|
||||||
|
cfg = [{"name": "sw", "host": "10.0.0.5", "host_id": "uuid-abc"}]
|
||||||
|
out = _devices_for_host(cfg, "10.0.0.5", "sw", host_id="uuid-zzz")
|
||||||
|
assert out == []
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Unit tests for the SNMP poller (no pysnmp / no network).
|
||||||
|
|
||||||
|
The unit lane doesn't install the `snmp` extra, so these exercise the parts that
|
||||||
|
don't need pysnmp: the version→mpModel mapping, that the poller is async, and the
|
||||||
|
graceful "pysnmp missing → empty result" path. Live polling against a real device
|
||||||
|
is verified out-of-band (CI has no SNMP target). Uses asyncio.run() directly so it
|
||||||
|
doesn't depend on the pytest-asyncio mode.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from plugins.snmp import poller
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_device_is_coroutine():
|
||||||
|
# The scheduler awaits it directly (no executor) — it must be async.
|
||||||
|
assert inspect.iscoroutinefunction(poller.poll_device)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mp_model_maps_version_to_int():
|
||||||
|
assert poller._mp_model("1") == 0 # SNMP v1
|
||||||
|
assert poller._mp_model("2c") == 1 # SNMP v2c
|
||||||
|
assert poller._mp_model("2") == 1 # anything non-"1" → v2c model
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_device_without_pysnmp_returns_empty(monkeypatch):
|
||||||
|
# When the optional dep is absent, polling is disabled gracefully (no raise).
|
||||||
|
monkeypatch.setattr(poller, "_pysnmp_available", lambda: False)
|
||||||
|
out = asyncio.run(
|
||||||
|
poller.poll_device("192.0.2.1", 161, "public", "2c", [{"oid": "1.3.6.1.2.1.1.3.0"}])
|
||||||
|
)
|
||||||
|
assert out == {}
|
||||||
|
|
||||||
|
|
||||||
|
# --- _close_engine: the fd-leak guard ---------------------------------------
|
||||||
|
# A fresh SnmpEngine per poll leaks its UDP socket unless closed; _close_engine
|
||||||
|
# must release it across both pysnmp API shapes. These fakes stand in for the
|
||||||
|
# engine since the unit lane has no pysnmp.
|
||||||
|
|
||||||
|
def test_close_engine_uses_engine_level_close_7x():
|
||||||
|
# canonical pysnmp 7.x: close method lives directly on the engine.
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Engine:
|
||||||
|
def close_dispatcher(self):
|
||||||
|
calls.append("engine")
|
||||||
|
|
||||||
|
poller._close_engine(Engine())
|
||||||
|
assert calls == ["engine"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_engine_falls_back_to_dispatcher_6x():
|
||||||
|
# pysnmp-lextudio 6.2.x: no engine-level close; go through the dispatcher.
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Dispatcher:
|
||||||
|
def closeDispatcher(self):
|
||||||
|
calls.append("dispatcher")
|
||||||
|
|
||||||
|
class Engine:
|
||||||
|
transportDispatcher = Dispatcher()
|
||||||
|
|
||||||
|
poller._close_engine(Engine())
|
||||||
|
assert calls == ["dispatcher"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_engine_never_raises():
|
||||||
|
# A failing close must not break the poll loop.
|
||||||
|
class Engine:
|
||||||
|
def close_dispatcher(self):
|
||||||
|
raise OSError("boom")
|
||||||
|
|
||||||
|
poller._close_engine(Engine()) # no exception
|
||||||
|
|
||||||
|
# No close path at all (defensive) is also fine.
|
||||||
|
poller._close_engine(object())
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Unit tests for the UniFi poll scheduler's client lifecycle (no network/DB).
|
||||||
|
|
||||||
|
The leak guard: the module-cached UnifiClient holds a long-lived httpx pool, so
|
||||||
|
whenever the scheduler discards it (login failure, or a mid-poll error that
|
||||||
|
forces re-auth) it MUST close it first — otherwise every failure tick orphans a
|
||||||
|
connection pool and leaks file descriptors. These tests drive both discard paths
|
||||||
|
and assert the client was closed and the cache cleared. Uses asyncio.run()
|
||||||
|
directly so it doesn't depend on the pytest-asyncio mode.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import types
|
||||||
|
|
||||||
|
import plugins.unifi.client as client_mod
|
||||||
|
import plugins.unifi.scheduler as scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app():
|
||||||
|
app = types.SimpleNamespace()
|
||||||
|
app.config = {"PLUGINS": {"unifi": {"host": "h", "username": "u", "password": "p"}}}
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
"""Stand-in for UnifiClient; records close() and can fail on demand."""
|
||||||
|
created: list["_FakeClient"] = []
|
||||||
|
login_fails = False
|
||||||
|
health_fails = False
|
||||||
|
|
||||||
|
def __init__(self, **_kwargs):
|
||||||
|
self.closed = False
|
||||||
|
_FakeClient.created.append(self)
|
||||||
|
|
||||||
|
async def login(self):
|
||||||
|
if _FakeClient.login_fails:
|
||||||
|
raise RuntimeError("login boom")
|
||||||
|
|
||||||
|
async def get_health(self):
|
||||||
|
if _FakeClient.health_fails:
|
||||||
|
raise RuntimeError("health boom")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _install(monkeypatch):
|
||||||
|
monkeypatch.setattr(client_mod, "UnifiClient", _FakeClient)
|
||||||
|
monkeypatch.setattr(scheduler, "_client", None)
|
||||||
|
_FakeClient.created = []
|
||||||
|
_FakeClient.login_fails = False
|
||||||
|
_FakeClient.health_fails = False
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_failure_closes_and_clears_client(monkeypatch):
|
||||||
|
_install(monkeypatch)
|
||||||
|
_FakeClient.login_fails = True
|
||||||
|
|
||||||
|
asyncio.run(scheduler._do_poll(_make_app()))
|
||||||
|
|
||||||
|
assert len(_FakeClient.created) == 1
|
||||||
|
assert _FakeClient.created[0].closed is True # closed, not just dropped
|
||||||
|
assert scheduler._client is None # cache cleared → re-auth next tick
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_failure_closes_and_clears_client(monkeypatch):
|
||||||
|
_install(monkeypatch)
|
||||||
|
_FakeClient.health_fails = True # login OK, first API call blows up
|
||||||
|
|
||||||
|
asyncio.run(scheduler._do_poll(_make_app()))
|
||||||
|
|
||||||
|
assert len(_FakeClient.created) == 1
|
||||||
|
assert _FakeClient.created[0].closed is True
|
||||||
|
assert scheduler._client is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_client_is_noop_when_unset(monkeypatch):
|
||||||
|
_install(monkeypatch)
|
||||||
|
# No client cached — dropping must not raise.
|
||||||
|
asyncio.run(scheduler._drop_client())
|
||||||
|
assert scheduler._client is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_client_swallows_close_errors(monkeypatch):
|
||||||
|
_install(monkeypatch)
|
||||||
|
|
||||||
|
class _Boom:
|
||||||
|
async def close(self):
|
||||||
|
raise OSError("close boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(scheduler, "_client", _Boom())
|
||||||
|
asyncio.run(scheduler._drop_client()) # must not propagate
|
||||||
|
assert scheduler._client is None
|
||||||
+30
-1
@@ -1,6 +1,6 @@
|
|||||||
import textwrap
|
import textwrap
|
||||||
import pytest
|
import pytest
|
||||||
from steward.config import load_bootstrap
|
from steward.config import load_bootstrap, _resolve_secret_key
|
||||||
|
|
||||||
|
|
||||||
def test_load_bootstrap_from_yaml(tmp_path):
|
def test_load_bootstrap_from_yaml(tmp_path):
|
||||||
@@ -36,3 +36,32 @@ def test_missing_database_url_raises(tmp_path):
|
|||||||
cfg_file.write_text("secret_key: s\n")
|
cfg_file.write_text("secret_key: s\n")
|
||||||
with pytest.raises(ValueError, match="Database URL is required"):
|
with pytest.raises(ValueError, match="Database URL is required"):
|
||||||
load_bootstrap(cfg_file)
|
load_bootstrap(cfg_file)
|
||||||
|
|
||||||
|
|
||||||
|
# ── secret key resolution ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_secret_key_existing_file_is_reused(tmp_path, monkeypatch):
|
||||||
|
key_file = tmp_path / "secret.key"
|
||||||
|
key_file.write_text("persisted-key\n")
|
||||||
|
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
|
||||||
|
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
||||||
|
assert _resolve_secret_key({}) == "persisted-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_key_generated_and_persisted_when_writable(tmp_path, monkeypatch):
|
||||||
|
key_file = tmp_path / "sub" / "secret.key" # parent doesn't exist yet
|
||||||
|
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", key_file)
|
||||||
|
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
||||||
|
key = _resolve_secret_key({})
|
||||||
|
assert key and key_file.read_text().strip() == key
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_key_unpersistable_raises_instead_of_ephemeral(tmp_path, monkeypatch):
|
||||||
|
# A file sits where a directory is needed, so mkdir/write fails → must raise
|
||||||
|
# (an ephemeral key would silently orphan every encrypted secret).
|
||||||
|
blocker = tmp_path / "blocker"
|
||||||
|
blocker.write_text("not a dir")
|
||||||
|
monkeypatch.setattr("steward.config._SECRET_KEY_FILE", blocker / "secret.key")
|
||||||
|
monkeypatch.delenv("STEWARD_SECRET_KEY", raising=False)
|
||||||
|
with pytest.raises(RuntimeError, match="could not persist"):
|
||||||
|
_resolve_secret_key({})
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Syntax-parse every first-party template so a broken tag fails the unit lane.
|
||||||
|
|
||||||
|
The app only renders a handful of templates in unit tests (e.g. the login page),
|
||||||
|
so a Jinja syntax error elsewhere would otherwise ship green. env.parse() checks
|
||||||
|
syntax without resolving extends/includes/imports — enough to catch an unbalanced
|
||||||
|
or mistyped tag across the whole template tree (e.g. the base.html layout).
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
import steward
|
||||||
|
|
||||||
|
_REPO = pathlib.Path(steward.__file__).parent.parent
|
||||||
|
_CORE_TEMPLATES = _REPO / "steward" / "templates"
|
||||||
|
_PLUGINS = _REPO / "plugins"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_steward_templates_parse():
|
||||||
|
env = jinja2.Environment()
|
||||||
|
files = sorted(_CORE_TEMPLATES.rglob("*.html"))
|
||||||
|
assert files, "no steward templates found"
|
||||||
|
for f in files:
|
||||||
|
env.parse(f.read_text()) # raises TemplateSyntaxError on a bad tag
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_plugin_templates_parse():
|
||||||
|
# First-party plugins ship templates too (host_agent fragments, docker, …);
|
||||||
|
# they aren't rendered in the unit lane, so parse them here to catch a bad tag.
|
||||||
|
env = jinja2.Environment()
|
||||||
|
files = sorted(_PLUGINS.rglob("templates/**/*.html"))
|
||||||
|
assert files, "no plugin templates found"
|
||||||
|
for f in files:
|
||||||
|
env.parse(f.read_text())
|
||||||
Reference in New Issue
Block a user