feat: add http, snmp, docker plugins; new widgets and routes for traefik/unifi/ups

- Add HTTP monitor plugin: endpoint checking with status history, latency tracking, time-range views
- Add SNMP plugin: OID polling, device management, metric recording
- Add Docker plugin: container status, resource usage, widget
- Traefik: access log widget, request chart widget, expanded routes
- UniFi: clients widget, devices widget, expanded poll routes
- UPS: history widget, additional routes
- Update plugin index with new entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:40:36 -04:00
parent d9886e8680
commit be4654fc72
45 changed files with 2701 additions and 1 deletions
+24
View File
@@ -0,0 +1,24 @@
# plugins/docker/__init__.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quart import Quart
_app: "Quart | None" = None
def setup(app: "Quart") -> None:
global _app
_app = app
from .models import DockerContainer, DockerMetric # noqa: F401 — register with Base
def get_scheduled_tasks() -> list:
from .scheduler import make_task
return [make_task(_app)]
def get_blueprint():
from .routes import docker_bp
return docker_bp
View File
+70
View File
@@ -0,0 +1,70 @@
# plugins/docker/migrations/env.py
"""Alembic env.py for the Docker plugin."""
import asyncio
import os
import sys
from pathlib import Path
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from fabledscryer.models.base import Base
import fabledscryer.models # noqa: F401
from plugins.docker.models import DockerContainer, DockerMetric # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_url() -> str:
import yaml
cfg_path = os.environ.get("FABLEDSCRYER_CONFIG", "config.yaml")
try:
with open(cfg_path) as f:
cfg = yaml.safe_load(f) or {}
url = cfg.get("database", {}).get("url", "")
except FileNotFoundError:
url = ""
return os.environ.get("FABLEDSCRYER_DATABASE__URL", url)
def run_migrations_offline() -> None:
context.configure(
url=_get_url(), target_metadata=target_metadata,
literal_binds=True, dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
cfg = config.get_section(config.config_ini_section, {})
cfg["sqlalchemy.url"] = _get_url()
connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,47 @@
"""Docker plugin initial tables
Revision ID: docker_001_initial
Revises: (none — branch off core via depends_on)
Create Date: 2026-03-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "docker_001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = "docker"
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
def upgrade() -> None:
op.create_table(
"docker_containers",
sa.Column("name", sa.String(255), primary_key=True),
sa.Column("container_id", sa.String(64), nullable=False, server_default=""),
sa.Column("image", sa.String(512), nullable=False, server_default=""),
sa.Column("status", sa.String(32), nullable=False, server_default="unknown"),
sa.Column("cpu_pct", sa.Float, nullable=True),
sa.Column("mem_usage_bytes", sa.Integer, nullable=True),
sa.Column("mem_limit_bytes", sa.Integer, nullable=True),
sa.Column("mem_pct", sa.Float, nullable=True),
sa.Column("restart_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("ports_json", sa.Text, nullable=False, server_default="[]"),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"docker_metrics",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("container_name", sa.String(255), nullable=False, index=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("cpu_pct", sa.Float, nullable=False, server_default="0"),
sa.Column("mem_pct", sa.Float, nullable=False, server_default="0"),
sa.Column("mem_usage_bytes", sa.Integer, nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_table("docker_metrics")
op.drop_table("docker_containers")
+49
View File
@@ -0,0 +1,49 @@
# plugins/docker/models.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledscryer.models.base import Base
class DockerContainer(Base):
"""Latest known state per container — upserted by name on each scrape."""
__tablename__ = "docker_containers"
# Container name is the stable natural key; container_id changes on recreation
name: Mapped[str] = mapped_column(String(255), primary_key=True)
container_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
image: Mapped[str] = mapped_column(String(512), nullable=False, default="")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
# running | stopped | paused | exited | dead
cpu_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
mem_usage_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
mem_limit_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
mem_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
restart_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ports_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# JSON: [{"host_port": 8080, "container_port": 80, "protocol": "tcp"}]
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
scraped_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
)
class DockerMetric(Base):
"""Time-series CPU/memory per container — one row per scrape per running container."""
__tablename__ = "docker_metrics"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
container_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
scraped_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
index=True,
)
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_usage_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+17
View File
@@ -0,0 +1,17 @@
name: docker
version: "1.0.0"
description: "Docker container status, resource usage, restart tracking via Docker socket"
author: "FabledScryer"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/FabledScryer-plugins/src/branch/main/docker"
tags:
- containers
- docker
- infrastructure
config:
socket_path: /var/run/docker.sock
scrape_interval_seconds: 60
include_stopped: false
+161
View File
@@ -0,0 +1,161 @@
# plugins/docker/routes.py
from __future__ import annotations
import json
from datetime import datetime, timezone
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import Integer, cast, func, select
from fabledscryer.auth.middleware import require_role
from fabledscryer.models.users import UserRole
from fabledscryer.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds
from .models import DockerContainer, DockerMetric
docker_bp = Blueprint("docker", __name__, template_folder="templates")
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
if len(values) < 2:
return f'<svg width="{width}" height="{height}"></svg>'
mn, mx = min(values), max(values)
if mx == mn:
mx = mn + 1.0
step = width / (len(values) - 1)
pts = []
for i, v in enumerate(values):
x = i * step
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts)
return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
f'</svg>'
)
@docker_bp.get("/")
@require_role(UserRole.viewer)
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
current_range = request.args.get("range", DEFAULT_RANGE)
return await render_template(
"docker/index.html",
poll_interval=poll_interval,
current_range=current_range,
)
@docker_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: container list with status and resource sparklines."""
since, range_key = parse_range(request.args.get("range"))
b_secs = bucket_seconds(since)
bucket_col = (
cast(func.strftime('%s', DockerMetric.scraped_at), Integer) / b_secs
).label("bucket")
async with current_app.db_sessionmaker() as db:
# All known containers ordered by running first, then name
result = await db.execute(
select(DockerContainer)
.order_by(
# running first
(DockerContainer.status == "running").desc(),
DockerContainer.name,
)
)
containers = list(result.scalars())
# Build per-container sparkline histories
histories: dict[str, list] = {}
for c in containers:
result = await db.execute(
select(
func.avg(DockerMetric.cpu_pct).label("cpu_pct"),
func.avg(DockerMetric.mem_pct).label("mem_pct"),
bucket_col,
)
.where(DockerMetric.container_name == c.name)
.where(DockerMetric.scraped_at >= since)
.group_by(bucket_col)
.order_by(bucket_col)
)
histories[c.name] = result.all()
running = sum(1 for c in containers if c.status == "running")
stopped = len(containers) - running
container_data = []
for c in containers:
hist = histories.get(c.name, [])
ports = json.loads(c.ports_json) if c.ports_json else []
container_data.append({
"container": c,
"ports": ports,
"sparkline_cpu": _sparkline([r.cpu_pct or 0 for r in hist]),
"sparkline_mem": _sparkline([r.mem_pct or 0 for r in hist]),
})
return await render_template(
"docker/rows.html",
container_data=container_data,
running=running,
stopped=stopped,
range_key=range_key,
)
@docker_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""HTMX dashboard widget: container status overview."""
show_stopped = request.args.get("show_stopped", "no") == "yes"
widget_id = request.args.get("wid", "0")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(DockerContainer)
.order_by(
(DockerContainer.status == "running").desc(),
DockerContainer.name,
)
)
all_containers = list(result.scalars())
running = [c for c in all_containers if c.status == "running"]
stopped = [c for c in all_containers if c.status != "running"]
display = all_containers if show_stopped else running
return await render_template(
"docker/widget.html",
containers=display,
running_count=len(running),
stopped_count=len(stopped),
show_stopped=show_stopped,
widget_id=widget_id,
)
@docker_bp.get("/widget/resources")
@require_role(UserRole.viewer)
async def widget_resources():
"""HTMX dashboard widget: CPU + memory usage for running containers."""
limit = max(1, min(20, int(request.args.get("limit", 10) or 10)))
widget_id = request.args.get("wid", "0")
async with current_app.db_sessionmaker() as db:
result = await db.execute(
select(DockerContainer)
.where(DockerContainer.status == "running")
.order_by(DockerContainer.cpu_pct.desc().nullslast())
)
containers = list(result.scalars())[:limit]
return await render_template(
"docker/widget_resources.html",
containers=containers,
widget_id=widget_id,
)
+93
View File
@@ -0,0 +1,93 @@
# plugins/docker/scheduler.py
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from fabledscryer.core.scheduler import ScheduledTask
from fabledscryer.core.alerts import record_metric
logger = logging.getLogger(__name__)
def make_task(app) -> ScheduledTask:
interval = int(
app.config["PLUGINS"]["docker"].get("scrape_interval_seconds", 60)
)
async def scrape():
await _do_scrape(app)
return ScheduledTask(
name="docker_scrape",
coro_factory=scrape,
interval_seconds=interval,
run_on_startup=True,
)
async def _do_scrape(app) -> None:
from .scraper import scrape_docker
from .models import DockerContainer, DockerMetric
cfg = app.config["PLUGINS"]["docker"]
socket_path = cfg.get("socket_path", "/var/run/docker.sock")
include_stopped = bool(cfg.get("include_stopped", False))
try:
containers = await scrape_docker(socket_path, include_stopped)
except ConnectionError as exc:
logger.error("Docker scrape failed: %s", exc)
return
except Exception:
logger.exception("Docker scrape error")
return
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
for c in containers:
# Upsert container state
existing = await session.get(DockerContainer, c["name"])
if existing is None:
existing = DockerContainer(name=c["name"])
session.add(existing)
existing.container_id = c["container_id"]
existing.image = c["image"]
existing.status = c["status"]
existing.cpu_pct = c["cpu_pct"]
existing.mem_usage_bytes = c["mem_usage_bytes"]
existing.mem_limit_bytes = c["mem_limit_bytes"]
existing.mem_pct = c["mem_pct"]
existing.restart_count = c["restart_count"]
existing.ports_json = json.dumps(c["ports"])
existing.started_at = c["started_at"]
existing.scraped_at = now
# Time-series metric (running containers only)
if c["status"] == "running" and c["cpu_pct"] is not None:
session.add(DockerMetric(
container_name=c["name"],
scraped_at=now,
cpu_pct=c["cpu_pct"],
mem_pct=c["mem_pct"] or 0.0,
mem_usage_bytes=c["mem_usage_bytes"] or 0,
))
# Feed alert pipeline
await record_metric(
session=session,
source_module="docker",
resource_name=c["name"],
metric_name="cpu_pct",
value=c["cpu_pct"],
)
if c["mem_pct"] is not None:
await record_metric(
session=session,
source_module="docker",
resource_name=c["name"],
metric_name="mem_pct",
value=c["mem_pct"],
)
+140
View File
@@ -0,0 +1,140 @@
# plugins/docker/scraper.py
"""Docker API client via Unix socket."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
import httpx
logger = logging.getLogger(__name__)
def _short_id(container_id: str) -> str:
return container_id[:12] if container_id else ""
def _calc_cpu_pct(stats: dict) -> float:
"""Calculate CPU % from a Docker stats snapshot (one-shot)."""
try:
cpu = stats.get("cpu_stats", {})
precpu = stats.get("precpu_stats", {})
cpu_delta = (
cpu["cpu_usage"]["total_usage"] - precpu["cpu_usage"]["total_usage"]
)
sys_delta = cpu.get("system_cpu_usage", 0) - precpu.get("system_cpu_usage", 0)
num_cpus = cpu.get("online_cpus") or len(
cpu["cpu_usage"].get("percpu_usage") or [None]
)
if sys_delta <= 0 or cpu_delta < 0:
return 0.0
return round((cpu_delta / sys_delta) * num_cpus * 100.0, 2)
except (KeyError, TypeError, ZeroDivisionError):
return 0.0
def _calc_mem(stats: dict) -> tuple[int, int, float]:
"""Return (usage_bytes, limit_bytes, mem_pct) from Docker stats."""
try:
mem = stats.get("memory_stats", {})
usage = mem.get("usage", 0)
limit = mem.get("limit", 0)
# Subtract page cache for working set (cgroup v1: "cache", v2: "inactive_file")
cache = mem.get("stats", {}).get("cache", 0) or \
mem.get("stats", {}).get("inactive_file", 0)
actual = max(0, usage - cache)
pct = round(actual / limit * 100.0, 2) if limit > 0 else 0.0
return actual, limit, pct
except (KeyError, TypeError):
return 0, 0, 0.0
def _parse_ports(ports: list) -> list[dict]:
"""Normalise Docker port bindings to a compact list."""
result = []
for p in (ports or []):
if p.get("PublicPort"):
result.append({
"host_port": p["PublicPort"],
"container_port": p["PrivatePort"],
"protocol": p.get("Type", "tcp"),
})
return result
async def scrape_docker(socket_path: str, include_stopped: bool) -> list[dict]:
"""
Fetch all containers + resource stats from the Docker daemon.
Returns a list of normalised dicts ready for the scheduler to persist.
Raises ConnectionError if the socket is unreachable.
"""
transport = httpx.AsyncHTTPTransport(uds=socket_path)
try:
async with httpx.AsyncClient(
transport=transport,
base_url="http://docker",
timeout=10.0,
) as client:
resp = await client.get(
"/containers/json",
params={"all": "true" if include_stopped else "false"},
)
resp.raise_for_status()
containers = resp.json()
async def _get_stats(c: dict) -> tuple[dict, dict | None]:
if c.get("State") != "running":
return c, None
try:
r = await client.get(
f"/containers/{c['Id']}/stats",
params={"stream": "false", "one-shot": "true"},
timeout=5.0,
)
return c, r.json()
except Exception as exc:
logger.debug(
"Stats unavailable for %s: %s", _short_id(c.get("Id", "")), exc
)
return c, None
pairs = await asyncio.gather(*[_get_stats(c) for c in containers])
except httpx.ConnectError as exc:
raise ConnectionError(
f"Cannot connect to Docker socket at {socket_path}: {exc}"
) from exc
results = []
now = datetime.now(timezone.utc)
for container, stats in pairs:
names = container.get("Names") or []
name = names[0].lstrip("/") if names else _short_id(container.get("Id", ""))
cpu_pct = mem_usage = mem_limit = mem_pct = None
if stats is not None:
cpu_pct = _calc_cpu_pct(stats)
mem_usage, mem_limit, mem_pct = _calc_mem(stats)
# Created is a Unix epoch int from /containers/json
created_ts = container.get("Created")
started_at = (
datetime.fromtimestamp(created_ts, tz=timezone.utc)
if isinstance(created_ts, (int, float)) else None
)
results.append({
"name": name,
"container_id": _short_id(container.get("Id", "")),
"image": container.get("Image", ""),
"status": container.get("State", "unknown"),
"cpu_pct": cpu_pct,
"mem_usage_bytes": mem_usage,
"mem_limit_bytes": mem_limit,
"mem_pct": mem_pct,
"restart_count": 0, # would require /inspect; left as 0 for now
"ports": _parse_ports(container.get("Ports", [])),
"started_at": started_at,
})
return results
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Docker — Fabled Scryer{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
{% include "_time_range.html" %}
</div>
<div id="docker-rows"
hx-get="/plugins/docker/rows"
hx-trigger="load, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading...</div>
</div>
{% endblock %}
+86
View File
@@ -0,0 +1,86 @@
{# docker/rows.html — HTMX fragment for the Docker main page #}
{# ── Summary strip ─────────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Running</div>
<span class="stat-val" style="color:var(--green);">{{ running }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Stopped</div>
<span class="stat-val" style="{% if stopped %}color:var(--text-muted){% endif %};">{{ stopped }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.35rem;">Total</div>
<span class="stat-val">{{ container_data | length }}</span>
</div>
</div>
{# ── Container table ─────────────────────────────────────────────────────── #}
{% if container_data %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Container</th>
<th>Image</th>
<th>Ports</th>
<th>CPU %</th>
<th style="min-width:100px;">CPU history</th>
<th>Mem %</th>
<th style="min-width:100px;">Mem history</th>
</tr>
</thead>
<tbody>
{% for item in container_data %}
{% set c = item.container %}
<tr>
<td>
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="dot {% if c.status == 'running' %}dot-up{% elif c.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
<div>
<div style="font-weight:500;font-size:0.9rem;">{{ c.name }}</div>
<div style="font-size:0.73rem;color:var(--text-muted);">{{ c.status }}</div>
</div>
</div>
</td>
<td style="font-size:0.82rem;color:var(--text-muted);max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ c.image }}
</td>
<td style="font-size:0.78rem;font-family:ui-monospace,monospace;">
{% for p in item.ports %}
<div style="color:var(--text-muted);">{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}</div>
{% endfor %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
{% if c.cpu_pct is not none %}
<span style="color:{% if c.cpu_pct > 80 %}var(--red){% elif c.cpu_pct > 50 %}var(--orange){% else %}var(--text){% endif %};">
{{ "%.1f" | format(c.cpu_pct) }}%
</span>
{% else %}
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td>{{ item.sparkline_cpu | safe }}</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
{% if c.mem_pct is not none %}
<span style="color:{% if c.mem_pct > 90 %}var(--red){% elif c.mem_pct > 70 %}var(--orange){% else %}var(--text){% endif %};">
{{ "%.1f" | format(c.mem_pct) }}%
</span>
{% else %}
<span style="color:var(--text-dim);"></span>
{% endif %}
</td>
<td>{{ item.sparkline_mem | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="color:var(--text-muted);font-size:0.9rem;">
No containers found. Make sure the Docker socket is accessible and the plugin is configured correctly.
</div>
</div>
{% endif %}
+35
View File
@@ -0,0 +1,35 @@
{# docker/widget.html — dashboard widget: container status overview #}
{% if not containers and running_count == 0 and stopped_count == 0 %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No containers found.</div>
{% else %}
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;">
<div>
<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>
</div>
{% if stopped_count %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ stopped_count }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">stopped</span>
</div>
{% endif %}
</div>
<div style="display:grid;gap:2px;">
{% for c in containers %}
<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 style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{{ c.name }}
</span>
{% 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;">
{{ "%.1f" | format(c.cpu_pct) }}%
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
@@ -0,0 +1,36 @@
{# docker/widget_resources.html — dashboard widget: CPU + memory bars for running containers #}
{% if not containers %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">No running containers.</div>
{% else %}
<div style="display:grid;gap:0.5rem;">
{% for c in containers %}
<div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.15rem;">
<span style="font-size:0.8rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:55%;">
{{ c.name }}
</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>
{% if c.cpu_pct is not none %}
<div style="height:3px;background:var(--bg-elevated);border-radius:2px;margin-bottom:2px;">
<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>
{% endfor %}
</div>
{% endif %}