From 414c942cbff426a2311a6c8fa7654febe08e7139 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 18:25:09 -0400 Subject: [PATCH] feat(plugins): Traefik problems first, UniFi/SNMP/fleet on shared shapes, audit names its entities, templates on tokens (#5126) Traefik: routers are graded from their latest scrape (router_state: crit on any real 5xx share, warn on p95 past the latency critical cutoff or a high 4xx share). Failing and slow routers lead as cards that say why; healthy ones fold into one table that stays open or shut across polls. Proxy counts are stat tiles; certificates are graded by Settings -> Thresholds (tls_days). UniFi: WAN health is a stat strip (latency graded by the thresholds), devices carry status dots, event categories and firewall actions are badges, the alarm count reuses .count-badge (renamed from .nav-count). SNMP: each device wears a status badge and its readings are stat tiles. Agent fleet: the table comes first, with a Status column (Reporting / Silent / Never reported, by the host page's stale_after_seconds), silent agents first, hosts linked, last seen as |ago. Audit: entries name their entity (one query per type, steward/audit/ service.py) and link it; something deleted since keeps the name its entry recorded and says "(deleted)". Tokens: charts read colours through chart-colors.js (stewardColor resolves a token to the rgb the browser computed; Chart.js defaults take the page's text and border colours), so host, history, Traefik and SNMP charts drop their hex. Template hex colours reach 0 (HEX_CEILING = 0), the last rgba pills go, and heavy weights (600/700/bold, 60 of them) become 500, the house pair being 400/500; a guard keeps templates there. Co-Authored-By: Claude Opus 5.5 --- .../templates/docker/_swarm_failures.html | 2 +- .../templates/docker/container_detail.html | 2 +- plugins/docker/templates/docker/disk.html | 2 +- plugins/docker/templates/docker/rows.html | 10 +- plugins/docker/templates/docker/swarm.html | 2 +- plugins/docker/templates/docker/widget.html | 6 +- plugins/host_agent/routes.py | 27 ++- plugins/host_agent/templates/host_detail.html | 26 +-- .../host_agent/templates/settings_list.html | 76 +++---- .../host_agent/templates/widget_history.html | 20 +- .../host_agent/templates/widget_table.html | 6 +- plugins/snmp/templates/snmp/device.html | 13 +- plugins/snmp/templates/snmp/host_panel.html | 4 +- plugins/snmp/templates/snmp/index.html | 51 ++--- plugins/traefik/routes.py | 41 +++- plugins/traefik/templates/traefik/rows.html | 190 ++++++++---------- .../traefik/templates/traefik/traffic.html | 6 +- .../templates/traefik/widget_access_log.html | 4 +- .../traefik/widget_request_chart.html | 17 +- plugins/unifi/templates/unifi/events.html | 8 +- plugins/unifi/templates/unifi/inventory.html | 9 +- plugins/unifi/templates/unifi/rows.html | 74 ++----- plugins/unifi/templates/unifi/security.html | 2 +- plugins/unifi/templates/unifi/widget.html | 2 +- .../unifi/templates/unifi/widget_clients.html | 6 +- .../unifi/templates/unifi/widget_devices.html | 4 +- steward/audit/routes.py | 26 ++- steward/audit/service.py | 84 ++++++++ steward/static/chart-colors.js | 23 +++ steward/static/steward.css | 4 +- steward/templates/ai_access/_connect.html | 6 +- steward/templates/ai_access/created.html | 2 +- steward/templates/ai_access/index.html | 2 +- steward/templates/ansible/_playbook_vars.html | 2 +- steward/templates/ansible/schedules.html | 2 +- steward/templates/audit/list.html | 41 ++-- steward/templates/base.html | 3 +- .../templates/dashboard/_widget_drawer.html | 2 +- steward/templates/dashboard/list.html | 6 +- steward/templates/dashboard/share.html | 2 +- .../templates/dashboard/share_created.html | 2 +- steward/templates/dashboard/share_view.html | 1 + steward/templates/hosts/list.html | 2 +- steward/templates/hosts/overview_widget.html | 4 +- steward/templates/monitors/widget.html | 6 +- .../templates/settings/_ansible_sources.html | 6 +- .../templates/settings/_catalog_partial.html | 2 +- steward/templates/settings/_plugin_repos.html | 4 +- steward/templates/settings/plugin_detail.html | 2 +- steward/templates/settings/plugins.html | 4 +- steward/templates/settings/thresholds.html | 4 +- steward/templates/status/widget.html | 2 +- tests/core/test_alerts_page.py | 4 +- tests/core/test_audit_page.py | 45 +++++ tests/plugins/host_agent/test_fleet_page.py | 39 ++++ tests/plugins/snmp/test_snmp_page.py | 22 ++ tests/plugins/traefik/__init__.py | 0 tests/plugins/traefik/test_traefik_page.py | 65 ++++++ tests/plugins/unifi/test_unifi_page.py | 34 ++++ tests/test_style_tokens.py | 14 +- 60 files changed, 686 insertions(+), 391 deletions(-) create mode 100644 steward/audit/service.py create mode 100644 steward/static/chart-colors.js create mode 100644 tests/core/test_audit_page.py create mode 100644 tests/plugins/host_agent/test_fleet_page.py create mode 100644 tests/plugins/snmp/test_snmp_page.py create mode 100644 tests/plugins/traefik/__init__.py create mode 100644 tests/plugins/traefik/test_traefik_page.py create mode 100644 tests/plugins/unifi/test_unifi_page.py diff --git a/plugins/docker/templates/docker/_swarm_failures.html b/plugins/docker/templates/docker/_swarm_failures.html index 836998b..7f24328 100644 --- a/plugins/docker/templates/docker/_swarm_failures.html +++ b/plugins/docker/templates/docker/_swarm_failures.html @@ -8,7 +8,7 @@ {%- if s.crash_looping or s.failures_24h or (e and s.state != 'healthy') %}
{% if s.crash_looping %} - Crash-looping · {{ s.failures_1h }} tasks failed in the last hour + Crash-looping · {{ s.failures_1h }} tasks failed in the last hour {% elif s.failures_24h %} {{ s.failures_24h }} task{{ 's' if s.failures_24h != 1 }} failed in the last 24h {% endif %} diff --git a/plugins/docker/templates/docker/container_detail.html b/plugins/docker/templates/docker/container_detail.html index 282029c..bb0c986 100644 --- a/plugins/docker/templates/docker/container_detail.html +++ b/plugins/docker/templates/docker/container_detail.html @@ -7,7 +7,7 @@ {% if container is none %}
-
Container not found
+
Container not found
No container named {{ name }} is currently reported for this host. It may have been removed, or the host agent hasn't reported recently. diff --git a/plugins/docker/templates/docker/disk.html b/plugins/docker/templates/docker/disk.html index 5b007c0..b84ce15 100644 --- a/plugins/docker/templates/docker/disk.html +++ b/plugins/docker/templates/docker/disk.html @@ -14,7 +14,7 @@ {% if host_groups %} {% for g in host_groups %}
-
{{ g.host_name }}
+
{{ g.host_name }}
{# ── Summary stats ────────────────────────────────────────────────────── #}
diff --git a/plugins/docker/templates/docker/rows.html b/plugins/docker/templates/docker/rows.html index b599b00..24b0b7a 100644 --- a/plugins/docker/templates/docker/rows.html +++ b/plugins/docker/templates/docker/rows.html @@ -47,7 +47,7 @@ {% if swarm_services %}
- Swarm services + Swarm services Topology →
@@ -55,7 +55,7 @@
- {{ s.name }} + {{ s.name }} {{ s.mode }} {{ s.running }}/{{ s.desired }} running @@ -91,9 +91,9 @@
{% if g.host %} - {{ g.host.name }} + {{ g.host.name }} {% else %} - {{ g.host_name }} + {{ g.host_name }} {% endif %} {{ g.running }} running{% if g.stopped %} · {{ g.stopped }} stopped{% endif %} @@ -112,7 +112,7 @@ {% for sub in g.subgroups %} {% if g.grouped and sub.label %} - {{ sub.label }} + {{ sub.label }} {% endif %} {% for item in sub.containers %} {% set c = item.container %} diff --git a/plugins/docker/templates/docker/swarm.html b/plugins/docker/templates/docker/swarm.html index b26704c..efb3342 100644 --- a/plugins/docker/templates/docker/swarm.html +++ b/plugins/docker/templates/docker/swarm.html @@ -11,7 +11,7 @@ {% for g in swarms %}
- Swarm + Swarm reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }} diff --git a/plugins/docker/templates/docker/widget.html b/plugins/docker/templates/docker/widget.html index 9a80a8b..44dd5d8 100644 --- a/plugins/docker/templates/docker/widget.html +++ b/plugins/docker/templates/docker/widget.html @@ -5,11 +5,11 @@
- {{ running_count }} + {{ running_count }} running
- {{ failed_24h }} + {{ failed_24h }} failed (24h)
@@ -24,7 +24,7 @@
{{ s.name }} - {% if s.crash_looping %}crash-looping{% endif %} + {% if s.crash_looping %}crash-looping{% endif %} {{ s.running }}/{{ s.desired }}
diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 672b609..503c711 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -359,6 +359,28 @@ async def agent_source(): return Response(body, mimetype="text/x-python") +_FLEET_ORDER = {"stale": 0, "pending": 1, "up": 2} + + +def fleet_state(reg, now: datetime, stale_after: int) -> str: + """An agent as the fleet table shows it: "up" while it reports, "stale" + once its last report is older than stale_after, "pending" if it never has.""" + if reg.last_seen_at is None: + return "pending" + return "stale" if (now - reg.last_seen_at).total_seconds() > stale_after else "up" + + +def fleet_rows(regs, hosts_by_id: dict, now: datetime, stale_after: int) -> list[dict]: + """The fleet table: silent agents first, then never-reported, then live, + each by host name.""" + rows = [] + for r in regs: + host = hosts_by_id.get(r.host_id) + rows.append({"reg": r, "host": host, "name": host.name if host else r.host_id, + "state": fleet_state(r, now, stale_after)}) + return sorted(rows, key=lambda x: (_FLEET_ORDER[x["state"]], x["name"].lower())) + + def _stale_after_seconds() -> int: return int( current_app.config.get("PLUGINS", {}) @@ -803,11 +825,10 @@ async def fleet(): managed_key_set = bool( (current_app.config.get("ANSIBLE", {}).get("ssh_public_key") or "").strip()) + now, stale_after = datetime.now(timezone.utc), _stale_after_seconds() return await render_template( "settings_list.html", - registrations=[ - {"reg": r, "host": hosts_by_id.get(r.host_id)} for r in regs - ], + registrations=fleet_rows(regs, hosts_by_id, now, stale_after), new_token=new_token, install_url=install_url, ansible_available=ansible_available, diff --git a/plugins/host_agent/templates/host_detail.html b/plugins/host_agent/templates/host_detail.html index 359b1fc..bec12d1 100644 --- a/plugins/host_agent/templates/host_detail.html +++ b/plugins/host_agent/templates/host_detail.html @@ -54,30 +54,30 @@ interaction: { mode: "index", intersect: false }, elements: { point: { radius: 0 } }, scales: { - x: { type: "linear", ticks: { callback: (v) => fmtTime(v), maxTicksLimit: 8, color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } }, - y: { beginAtZero: true, max: ymax, ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } }, + x: { type: "linear", ticks: { callback: (v) => fmtTime(v), maxTicksLimit: 8, font: { size: 10 } } }, + y: { beginAtZero: true, max: ymax, ticks: { font: { size: 10 } } }, }, plugins: { - legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } }, + legend: { labels: { boxWidth: 10, font: { size: 11 } } }, tooltip: { callbacks: { title: (items) => items.length ? fmtTime(items[0].parsed.x) : "" } }, }, }); const defs = { "chart-util": { ymax: 100, ds: [ - { key: "cpu_pct", label: "CPU %", color: "#c8a840" }, - { key: "mem_used_pct", label: "Mem %", color: "#4aa86a" }, - { key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" }, + { key: "cpu_pct", label: "CPU %", color: stewardColor("--gold") }, + { key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") }, + { key: "disk_used_pct_worst", label: "Disk % (worst)", color: stewardColor("--yellow") }, ] }, "chart-net": { ymax: undefined, ds: [ - { key: "net_rx_bps", label: "Net RX", color: "#4aa86a" }, - { key: "net_tx_bps", label: "Net TX", color: "#c84048" }, - { key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" }, - { key: "disk_write_bps", label: "Disk write", color: "#c8a840" }, + { key: "net_rx_bps", label: "Net RX", color: stewardColor("--green") }, + { key: "net_tx_bps", label: "Net TX", color: stewardColor("--red") }, + { key: "disk_read_bps", label: "Disk read", color: stewardColor("--blue") }, + { key: "disk_write_bps", label: "Disk write", color: stewardColor("--gold") }, ] }, "chart-pressure": { ymax: undefined, ds: [ - { key: "load_1m", label: "Load 1m", color: "#c8a840" }, - { key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" }, - { key: "temp_c_max", label: "Temp °C", color: "#c87840" }, + { key: "load_1m", label: "Load 1m", color: stewardColor("--gold") }, + { key: "psi_mem_some_avg10", label: "Mem PSI %", color: stewardColor("--red") }, + { key: "temp_c_max", label: "Temp °C", color: stewardColor("--yellow") }, ] }, }; const charts = {}; diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index ce05d4f..0fcf308 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import crumbs %} +{% from "_macros.html" import crumbs, status_badge %} {% block title %}Agent fleet — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Agent fleet", "")]) }}{% endblock %} {% block content %} @@ -21,6 +21,45 @@
{% endif %} +
+
+ + + + + + + + + {% for item in registrations %} + + + + + + + + + + {% else %} + + {% endfor %} + +
StatusHostLast seenAgent versionReported IPDistroActions
{{ status_badge(item.state, {'up': 'Reporting', 'stale': 'Silent', 'pending': 'Never reported'}[item.state]) }}{% if item.host %}{{ item.host.name }}{% else %}{{ item.reg.host_id }}{% endif %}{% if item.reg.last_seen_at %}{{ item.reg.last_seen_at|ago }}{% else %}never{% endif %}{{ item.reg.agent_version or "—" }}{{ item.reg.host_ip or "—" }}{{ item.reg.distro or "—" }} +
{{ csrf_field() }} + +
+
{{ csrf_field() }} + +
+
No hosts registered. Add one below.
+
+
+
{{ csrf_field() }} @@ -137,39 +176,4 @@ {% endif %} {% endif %} -
- - - - - - - - - {% for item in registrations %} - - - - - - - - - {% else %} - - {% endfor %} - -
HostReported IPAgent versionDistroLast seenActions
{{ item.host.name if item.host else item.reg.host_id }}{{ item.reg.host_ip or "—" }}{{ item.reg.agent_version or "—" }}{{ item.reg.distro or "—" }}{{ item.reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") if item.reg.last_seen_at else "never" }} - {{ csrf_field() }} - - -
{{ csrf_field() }} - -
-
No hosts registered. Add one above.
-
{% endblock %} diff --git a/plugins/host_agent/templates/widget_history.html b/plugins/host_agent/templates/widget_history.html index 9e7c3bc..74c5d80 100644 --- a/plugins/host_agent/templates/widget_history.html +++ b/plugins/host_agent/templates/widget_history.html @@ -16,16 +16,14 @@ toggle that re-requests this widget without entering edit mode. #}
{% if session.user_id %} - {{ host.name }} + {{ host.name }} {% else %} {{ host.name }} {% endif %} - + {% for opt in [1, 6, 24] %} + class="range-btn{% if opt == hours %} active{% endif %}">{{ opt }}h {% endfor %}
@@ -57,9 +55,9 @@ window.histRange = function (wid, hours) { return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); }; var ds = [ - { key: "cpu_pct", label: "CPU %", color: "#c8a840" }, - { key: "mem_used_pct", label: "Mem %", color: "#4aa86a" }, - { key: "disk_root", label: "Disk / %", color: "#c87840" }, + { key: "cpu_pct", label: "CPU %", color: stewardColor("--gold") }, + { key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") }, + { key: "disk_root", label: "Disk / %", color: stewardColor("--yellow") }, ]; new Chart(el.getContext("2d"), { type: "line", @@ -75,14 +73,14 @@ window.histRange = function (wid, hours) { interaction: { mode: "index", intersect: false }, elements: { point: { radius: 0 } }, scales: { - x: { type: "linear", ticks: { callback: function (v) { return fmtTime(v); }, maxTicksLimit: 6, color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } }, + x: { type: "linear", ticks: { callback: function (v) { return fmtTime(v); }, maxTicksLimit: 6, font: { size: 10 } } }, // Fixed 0–{{ y_max }} ceiling (server snaps the data peak up to the next // 10% band). Steady across refreshes — auto-scaling made the zoom jump on // every poll — while a banded ceiling still keeps the lines filling the panel. - y: { min: 0, max: {{ y_max }}, ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } }, + y: { min: 0, max: {{ y_max }}, ticks: { font: { size: 10 } } }, }, plugins: { - legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } }, + legend: { labels: { boxWidth: 10, font: { size: 11 } } }, tooltip: { callbacks: { title: function (items) { return items.length ? fmtTime(items[0].parsed.x) : ""; } } }, }, }, diff --git a/plugins/host_agent/templates/widget_table.html b/plugins/host_agent/templates/widget_table.html index 2c2b1e2..d0e5e50 100644 --- a/plugins/host_agent/templates/widget_table.html +++ b/plugins/host_agent/templates/widget_table.html @@ -7,7 +7,7 @@ {% macro metric_cell(label, value, fmt, svg, style="", title="") %}
{{ label }}
-
+
{% if value is not none %}{{ fmt|format(value) }}{% else %}—{% endif %}
{{ svg | safe }}
@@ -34,9 +34,9 @@ title="{% if stale %}stale / no recent data{% else %}warns at CPU/memory ≥90% or any disk mount ≥90% (the 'disk /' figure shows root only){% endif %}"> {% if session.user_id %} - {{ r.host.name }} + {{ r.host.name }} {% else %} - {{ r.host.name }} + {{ r.host.name }} {% endif %} {{ r.reg.last_seen_at.strftime("%H:%M:%S") if r.reg.last_seen_at else "never" }} diff --git a/plugins/snmp/templates/snmp/device.html b/plugins/snmp/templates/snmp/device.html index b0b1a18..cc51aba 100644 --- a/plugins/snmp/templates/snmp/device.html +++ b/plugins/snmp/templates/snmp/device.html @@ -9,9 +9,7 @@ {{ device.host }}
{% for h in [1, 6, 24, 168] %} - + {% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %} {% endfor %} @@ -27,7 +25,8 @@ (function(){ const raw = {{ history_json | safe }}; const labels = {{ oid_labels | tojson }}; - const palette = ["#6060c0","#e07040","#40b080","#c040c0","#4080e0","#e0c040","#c08040"]; + // Series colours from the tokens, in an order that keeps neighbours apart. + const palette = ["--blue", "--yellow", "--green", "--red", "--gold", "--orange", "--text-muted"].map(stewardColor); // Collect all unique timestamps, sorted const tsSet = new Set(); @@ -66,12 +65,10 @@ responsive: true, interaction: { mode: "index", intersect: false }, plugins: { - legend: { position: "top", labels: { color: "#aaa", boxWidth: 12 } }, - tooltip: { backgroundColor: "#1e1e2e", titleColor: "#ccc", bodyColor: "#aaa" }, + legend: { position: "top", labels: { boxWidth: 12 } }, }, scales: { - x: { ticks: { color: "#888", maxTicksLimit: 12 }, grid: { color: "#2a2a3a" } }, - y: { ticks: { color: "#888" }, grid: { color: "#2a2a3a" } }, + x: { ticks: { maxTicksLimit: 12 } }, }, }, }); diff --git a/plugins/snmp/templates/snmp/host_panel.html b/plugins/snmp/templates/snmp/host_panel.html index 42d471e..de75144 100644 --- a/plugins/snmp/templates/snmp/host_panel.html +++ b/plugins/snmp/templates/snmp/host_panel.html @@ -9,7 +9,7 @@ {% for device in devices %}
- {{ device.name }} + {{ device.name }} {{ device.host }} {% if device.bound %}
{{ label }}
{% if val is not none %} -
+
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}M {% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}K {% else %}{{ "%.4g"|format(val) }}{% endif %} diff --git a/plugins/snmp/templates/snmp/index.html b/plugins/snmp/templates/snmp/index.html index c379c5a..0ad1d43 100644 --- a/plugins/snmp/templates/snmp/index.html +++ b/plugins/snmp/templates/snmp/index.html @@ -1,56 +1,39 @@ {# plugins/snmp/templates/snmp/index.html #} {% extends "base.html" %} +{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %} {% block title %}SNMP — Steward{% endblock %} {% block content %} -
-

SNMP Devices

- {{ devices|length }} device(s) configured -
+{{ page_header("SNMP devices", devices|length ~ " device" ~ ("s" if devices|length != 1 else "") ~ " configured") }} {% if not devices %} -
- No SNMP devices configured. Add devices to plugin.yaml. -
+
{{ empty_state("No SNMP devices configured. Add them to the snmp plugin's plugin.yaml.") }}
{% else %} {% for device in devices %} -
-
-

{{ device.name }}

- {{ device.host }} - {% if device.readings %} - ● reachable - {% else %} - ○ no data yet - {% endif %} - History +
+
+ {{ status_badge('up', 'Reachable') if device.readings else status_badge('pending', 'No data yet') }} +

{{ device.name }}

+ {{ device.host }} + History
{% if device.readings %} -
+
{% 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) %} -
-
{{ label }}
- {% if val is not none %} -
- {% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}M - {% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}K - {% else %}{{ "%.4g"|format(val) }}{% endif %} -
- {% else %} -
—
- {% endif %} -
+ {{ stat_tile(label, + "—" if val is none else + ("%.2fM"|format(val / 1000000)) if val >= 1000000 else + ("%.1fK"|format(val / 1000)) if val >= 1000 else + "%.4g"|format(val)) }} {% endfor %}
{% else %} -

- No readings yet — waiting for next poll cycle. -

+

No readings yet; waiting for the next poll.

{% endif %} -
+
{% endfor %} {% endif %} diff --git a/plugins/traefik/routes.py b/plugins/traefik/routes.py index 80e6c93..5da6f9f 100644 --- a/plugins/traefik/routes.py +++ b/plugins/traefik/routes.py @@ -9,6 +9,7 @@ import json from steward.auth.middleware import require_role from steward.models.users import UserRole from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample +from steward.core.formatting import status_class from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric traefik_bp = Blueprint("traefik", __name__, template_folder="templates") @@ -36,6 +37,36 @@ def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str: ) +# A router serving any real share of 5xx is failing; a 4xx share above this +# is worth a look (clients hitting missing routes, auth failures). +ERROR_5XX_PCT = 0.1 +ERROR_4XX_PCT = 1.0 +_GRADE_ORDER = {"crit": 0, "warn": 1, "ok": 2} + + +def router_state(latest, thresholds: dict) -> tuple[str, str | None]: + """(grade, why) for a router's latest scrape: crit while it serves 5xx, + warn on a p95 past the latency critical cutoff (Settings → Thresholds) + or a high 4xx share, otherwise ok with nothing to say.""" + if (latest.error_rate_5xx_pct or 0) > ERROR_5XX_PCT: + return "crit", f"{latest.error_rate_5xx_pct:.1f}% of responses are 5xx" + p95 = latest.latency_p95_ms + if p95 is not None and status_class(p95, "latency", thresholds) == "crit": + return "warn", f"p95 latency is {p95:.0f} ms" + if (latest.error_rate_4xx_pct or 0) > ERROR_4XX_PCT: + return "warn", f"{latest.error_rate_4xx_pct:.1f}% of responses are 4xx" + return "ok", None + + +def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]: + """(problems, healthy): failing routers before slow or noisy ones, each + group by name; healthy routers by name.""" + problems = sorted((r for r in routers if r["state"] != "ok"), + key=lambda r: (_GRADE_ORDER[r["state"]], r["name"].lower())) + healthy = sorted((r for r in routers if r["state"] == "ok"), key=lambda r: r["name"].lower()) + return problems, healthy + + @traefik_bp.get("/") @require_role(UserRole.viewer) async def index(): @@ -59,6 +90,7 @@ async def rows(): """HTMX fragment: service cards with sparklines scoped to selected time range.""" since, range_key = parse_range(request.args.get("range")) now = datetime.now(timezone.utc) + thresholds = current_app.config.get("THRESHOLDS", {}) async with current_app.db_sessionmaker() as db: # All known routers @@ -103,9 +135,12 @@ async def rows(): if not latest: continue + state, why = router_state(latest, thresholds) router_data.append({ "name": router, "latest": latest, + "state": state, + "why": why, "sparkline_req": _sparkline([r.request_rate for r in history]), "sparkline_p95": _sparkline([r.latency_p95_ms for r in history]), "sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]), @@ -136,9 +171,13 @@ async def rows(): "days_remaining": days, }) + problems, healthy = split_routers(router_data) return await render_template( "traefik/rows.html", - router_data=router_data, + problems=problems, + healthy=healthy, + error_4xx=ERROR_4XX_PCT, + error_5xx=ERROR_5XX_PCT, global_stat=global_stat, certs=certs, range_key=range_key, diff --git a/plugins/traefik/templates/traefik/rows.html b/plugins/traefik/templates/traefik/rows.html index 55202fb..88ae3bf 100644 --- a/plugins/traefik/templates/traefik/rows.html +++ b/plugins/traefik/templates/traefik/rows.html @@ -1,124 +1,104 @@ -{# plugins/traefik/templates/traefik/rows.html #} - -{# ── Global stats bar ──────────────────────────────────────────────────────── #} -{% if global_stat %} -
- - {% if global_stat.open_conns_total is not none %} - - Open conns - {{ global_stat.open_conns_total | int }} - - {% endif %} +{# traefik/rows.html — HTMX fragment for the Traefik page: routers that need + attention first (routes.router_state), the proxy's own counts, the healthy + routers folded into one table, then certificates by soonest expiry. #} +{% from "_macros.html" import stat_tile, status_badge %} +{# ── Counts ───────────────────────────────────────────────────────────────── #} +
+ {{ stat_tile("Needs attention", problems|length, state="crit" if problems else "inactive") }} + {{ stat_tile("Healthy routers", healthy|length, state="up" if healthy else "inactive") }} + {% if global_stat %} {% if global_stat.config_last_reload_success is not none %} - - Config - {% if global_stat.config_last_reload_success == 1.0 %} - ✓ OK - {% else %} - ✗ failed - {% endif %} - {% if global_stat.config_reloads_total is not none %} - ({{ global_stat.config_reloads_total | int }} reloads) - {% endif %} - + {{ stat_tile("Config", "OK" if global_stat.config_last_reload_success == 1.0 else "Reload failed", + state="up" if global_stat.config_last_reload_success == 1.0 else "crit", + hint=(global_stat.config_reloads_total|int ~ " reloads") if global_stat.config_reloads_total is not none else none) }} {% endif %} - - {% if global_stat.process_memory_bytes is not none %} - - Memory - - {{ global_stat.process_memory_bytes|bytes }} - - + {% if global_stat.open_conns_total is not none %}{{ stat_tile("Open connections", global_stat.open_conns_total|int) }}{% endif %} + {% if global_stat.process_memory_bytes is not none %}{{ stat_tile("Memory", global_stat.process_memory_bytes|bytes) }}{% endif %} {% endif %} +
- - {{ global_stat.scraped_at.strftime("%H:%M:%S") }} UTC - +{% macro router_card(r) %} +
+
+ {{ status_badge(r.state, "Failing" if r.state == 'crit' else "Warning") }} +

{{ r.name }}

+
+

{{ r.why }}

+ + + + + + + +
Req/s{{ "%.2f"|format(r.latest.request_rate) }}{{ r.sparkline_req|safe }}
Bandwidth{{ r.latest.response_bytes_rate|rate }}
p95{{ "%.0f"|format(r.latest.latency_p95_ms) }} ms{{ r.sparkline_p95|safe }}
p99{{ "%.0f"|format(r.latest.latency_p99_ms) }} ms
4xx{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%
5xx{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%{{ r.sparkline_5xx|safe }}
+
Last scraped {{ r.latest.scraped_at|ago }}
+
+{% endmacro %} + +{% if problems or healthy or certs %} +{# ── Routers that need attention ─────────────────────────────────────────── #} +{% if problems %} +
+ {% for r in problems %}{{ router_card(r) }}{% endfor %}
{% endif %} -{# ── Per-service cards + cert card in shared columns layout ───────────────── #} -{% if router_data or certs %} -
- -{% for r in router_data %} -
-

{{ r.name }}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Req/s{{ "%.2f"|format(r.latest.request_rate) }}{{ r.sparkline_req | safe }}
Bandwidth - {% set bps = r.latest.response_bytes_rate %} - {{ bps|rate }} -
p95 ms{{ "%.1f"|format(r.latest.latency_p95_ms) }}{{ r.sparkline_p95 | safe }}
p99 ms{{ "%.1f"|format(r.latest.latency_p99_ms) }}
4xx % - {{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}% -
5xx % - {{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}% - {{ r.sparkline_5xx | safe }}
-
- Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC -
-
-{% endfor %} - -{% if certs %} -
-
TLS Certificates
- - {% for cert in certs %} +{# ── Healthy routers, folded ─────────────────────────────────────────────── #} +{% if healthy %} +
+ {{ healthy|length }} healthy router{{ 's' if healthy|length != 1 }} +
+
+ + + {% for r in healthy %} - - - - - + + + + + + {% endfor %} + +
RouterReq/sBandwidthp954xx5xx
{{ cert.cn }} - {{ cert.days_remaining | int }}d -
- {{ cert.not_after.strftime("%Y-%m-%d") }} - {{ r.name }}{{ "%.2f"|format(r.latest.request_rate) }}{{ r.latest.response_bytes_rate|rate }}{{ "%.0f"|format(r.latest.latency_p95_ms) }} ms{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%
+
+ + +{% endif %} + +{# ── Certificates, soonest expiry first ──────────────────────────────────── #} +{% if certs %} +
+
TLS certificates
+ + + + {% for cert in certs %} + + + + + + {% endfor %} +
NameExpiresLeft
{{ cert.cn }}{{ cert.not_after.strftime("%Y-%m-%d") }}{{ cert.days_remaining|int }} days
{% endif %} -
{% else %}
-

No Traefik metrics yet. Configure metrics_url in config.yaml under plugins.traefik.

+

No Traefik metrics yet. Configure metrics_url in config.yaml under plugins.traefik.

{% endif %} diff --git a/plugins/traefik/templates/traefik/traffic.html b/plugins/traefik/templates/traefik/traffic.html index 3a2ee84..f5f6584 100644 --- a/plugins/traefik/templates/traefik/traffic.html +++ b/plugins/traefik/templates/traefik/traffic.html @@ -5,15 +5,15 @@
Total - {{ summary.total }} + {{ summary.total }} Internal - {{ summary.internal }} + {{ summary.internal }} External - + {{ summary.external }} ({{ "%.1f"|format(summary.external_pct) }}%) diff --git a/plugins/traefik/templates/traefik/widget_access_log.html b/plugins/traefik/templates/traefik/widget_access_log.html index 899e09c..3ee201d 100644 --- a/plugins/traefik/templates/traefik/widget_access_log.html +++ b/plugins/traefik/templates/traefik/widget_access_log.html @@ -5,11 +5,11 @@
- {{ summary.total | int }} + {{ summary.total | int }} total req
- + {{ "%.1f" | format(summary.external_pct) }}% external diff --git a/plugins/traefik/templates/traefik/widget_request_chart.html b/plugins/traefik/templates/traefik/widget_request_chart.html index 272ff39..3dee183 100644 --- a/plugins/traefik/templates/traefik/widget_request_chart.html +++ b/plugins/traefik/templates/traefik/widget_request_chart.html @@ -19,8 +19,8 @@ { label: "Req/s", data: {{ req_rate_json | safe }}, - borderColor: "#7c5ef4", - backgroundColor: "rgba(124,94,244,0.08)", + borderColor: stewardColor("--blue"), + backgroundColor: stewardColor("--blue-dim"), fill: true, tension: 0.3, pointRadius: 0, @@ -30,7 +30,7 @@ { label: "5xx %", data: {{ error_rate_json | safe }}, - borderColor: "#e83848", + borderColor: stewardColor("--red"), fill: false, tension: 0.3, pointRadius: 0, @@ -45,24 +45,23 @@ animation: false, plugins: { legend: { - labels: { color: "#6858a0", boxWidth: 10, font: { size: 10 } } + labels: { boxWidth: 10, font: { size: 10 } } } }, scales: { x: { display: false }, y: { min: 0, - grid: { color: "rgba(30,30,62,0.8)" }, - ticks: { color: "#6858a0", font: { size: 10 } }, - title: { display: true, text: "req/s", color: "#6858a0", font: { size: 9 } }, + ticks: { font: { size: 10 } }, + title: { display: true, text: "req/s", font: { size: 9 } }, }, y2: { position: "right", min: 0, max: 100, grid: { drawOnChartArea: false }, - ticks: { color: "#e83848", font: { size: 10 } }, - title: { display: true, text: "5xx%", color: "#e83848", font: { size: 9 } }, + ticks: { color: stewardColor("--red"), font: { size: 10 } }, + title: { display: true, text: "5xx%", color: stewardColor("--red"), font: { size: 9 } }, }, } } diff --git a/plugins/unifi/templates/unifi/events.html b/plugins/unifi/templates/unifi/events.html index 653b89b..2a82d20 100644 --- a/plugins/unifi/templates/unifi/events.html +++ b/plugins/unifi/templates/unifi/events.html @@ -28,13 +28,7 @@ {{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }} - - {{ ev.category }} - + {{ ev.category }} {{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }} diff --git a/plugins/unifi/templates/unifi/inventory.html b/plugins/unifi/templates/unifi/inventory.html index 62e99f7..6e86eb5 100644 --- a/plugins/unifi/templates/unifi/inventory.html +++ b/plugins/unifi/templates/unifi/inventory.html @@ -61,7 +61,7 @@ {% if rule.ruleset != ns.current_rs %} {% set ns.current_rs = rule.ruleset %} - + {{ rule.ruleset }} @@ -73,12 +73,7 @@ - - {{ rule.action }} - + {{ rule.action }} {% if rule.protocol %}{{ rule.protocol }}{% endif %} diff --git a/plugins/unifi/templates/unifi/rows.html b/plugins/unifi/templates/unifi/rows.html index 7085dad..2aa4dc8 100644 --- a/plugins/unifi/templates/unifi/rows.html +++ b/plugins/unifi/templates/unifi/rows.html @@ -1,53 +1,20 @@ {# plugins/unifi/templates/unifi/rows.html #} -{# ── WAN health bar ───────────────────────────────────────────────────────── #} +{% from "_macros.html" import stat_tile, status_badge, status_dot %} +{# ── WAN health ───────────────────────────────────────────────────────────── #} {% if latest_wan %} -
- - WAN - - {% if latest_wan.is_up %}✓ Up{% else %}✗ Down{% endif %} - - {% if latest_wan.wan_ip %} - {{ latest_wan.wan_ip }} - {% endif %} - - +
+ {{ stat_tile("WAN", "Up" if latest_wan.is_up else "Down", state="up" if latest_wan.is_up else "down", + hint=latest_wan.wan_ip) }} {% if latest_wan.latency_ms is not none %} - - Latency - - {{ "%.1f"|format(latest_wan.latency_ms) }}ms - - {{ sparkline_latency | safe }} - + {{ stat_tile("Latency", "%.0f ms"|format(latest_wan.latency_ms), state=status_class(latest_wan.latency_ms, 'latency'), + hint=sparkline_latency|safe) }} {% endif %} - {% if latest_wan.rx_bytes_rate is not none %} - - ↓ RX - - {% set bps = latest_wan.rx_bytes_rate %} - {{ bps|rate }} - - {{ sparkline_rx | safe }} - - - ↑ TX - - {% set bps = latest_wan.tx_bytes_rate %} - {{ bps|rate }} - - {{ sparkline_tx | safe }} - - {% endif %} - - {% if latest_wan.uptime_seconds is not none %} - - up {{ latest_wan.uptime_seconds|duration }} - + {{ stat_tile("Download", latest_wan.rx_bytes_rate|rate, hint=sparkline_rx|safe) }} + {{ stat_tile("Upload", latest_wan.tx_bytes_rate|rate, hint=sparkline_tx|safe) }} {% endif %} + {% if latest_wan.uptime_seconds is not none %}{{ stat_tile("WAN uptime", latest_wan.uptime_seconds|duration) }}{% endif %}
{% endif %} @@ -63,7 +30,7 @@
- {{ latest_clients.total_clients }} + {{ latest_clients.total_clients }} total @@ -78,12 +45,12 @@ {# Infrastructure devices card #} {% if devices %}
-
Infrastructure Devices
+
Infrastructure devices
{% for d in devices %} @@ -104,7 +71,7 @@ {# Top talkers card #} {% if top_clients %}
-
Top Talkers
+
Top talkers
- ● + {{ status_dot('up' if d.state == 1 else 'down') }} {{ d.name or d.mac }} {{ d.model or d.device_type or "" }}
{% for c in top_clients %} @@ -130,7 +97,7 @@ {# WLAN SSIDs card #} {% if wlan_latest %}
-
Wireless Networks
+
Wireless networks
@@ -141,10 +108,7 @@ - @@ -156,19 +120,19 @@ {# Speedtest card #} {% if speedtest %}
-
Last Speed Test
+
Last speed test
{% if speedtest.download_mbps is not none %} ↓ DL - {{ "%.1f"|format(speedtest.download_mbps) }} + {{ "%.1f"|format(speedtest.download_mbps) }} Mbps {% endif %} {% if speedtest.upload_mbps is not none %} ↑ UL - {{ "%.1f"|format(speedtest.upload_mbps) }} + {{ "%.1f"|format(speedtest.upload_mbps) }} Mbps {% endif %} diff --git a/plugins/unifi/templates/unifi/security.html b/plugins/unifi/templates/unifi/security.html index f28d4c0..b854c32 100644 --- a/plugins/unifi/templates/unifi/security.html +++ b/plugins/unifi/templates/unifi/security.html @@ -6,7 +6,7 @@
Active Alarms {% if active_alarms %} - {{ active_alarms|length }} + {{ active_alarms|length }} {% endif %}
{% if not active_alarms %} diff --git a/plugins/unifi/templates/unifi/widget.html b/plugins/unifi/templates/unifi/widget.html index 6235cd1..5ef6aec 100644 --- a/plugins/unifi/templates/unifi/widget.html +++ b/plugins/unifi/templates/unifi/widget.html @@ -55,7 +55,7 @@ Clients
- {{ latest_clients.total_clients }} + {{ latest_clients.total_clients }} {{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e
diff --git a/plugins/unifi/templates/unifi/widget_clients.html b/plugins/unifi/templates/unifi/widget_clients.html index 56059a9..19c5f3d 100644 --- a/plugins/unifi/templates/unifi/widget_clients.html +++ b/plugins/unifi/templates/unifi/widget_clients.html @@ -5,15 +5,15 @@
- {{ snapshot.total_clients }} + {{ snapshot.total_clients }} total
- {{ snapshot.wireless_clients }} + {{ snapshot.wireless_clients }} wireless
- {{ snapshot.wired_clients }} + {{ snapshot.wired_clients }} wired
diff --git a/plugins/unifi/templates/unifi/widget_devices.html b/plugins/unifi/templates/unifi/widget_devices.html index 90994ed..3c04ae7 100644 --- a/plugins/unifi/templates/unifi/widget_devices.html +++ b/plugins/unifi/templates/unifi/widget_devices.html @@ -10,12 +10,12 @@
- {{ online | length }} + {{ online | length }} online
{% if offline %}
- {{ offline | length }} + {{ offline | length }} offline
{% endif %} diff --git a/steward/audit/routes.py b/steward/audit/routes.py index 5ce5b29..0819568 100644 --- a/steward/audit/routes.py +++ b/steward/audit/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations import json from quart import Blueprint, render_template, request, current_app from sqlalchemy import select +from steward.audit.service import entity_labels from steward.auth.middleware import require_role from steward.models.audit import AuditEvent from steward.models.users import UserRole @@ -23,16 +24,21 @@ async def list_events(): result = await db.execute(stmt) events = result.scalars().all() - # Parse detail_json for display - parsed = [] - for e in events: - detail = {} - if e.detail_json: - try: - detail = json.loads(e.detail_json) - except (ValueError, TypeError): - detail = {"raw": e.detail_json} - parsed.append({"event": e, "detail": detail}) + # Parse detail_json for display + parsed = [] + for e in events: + detail = {} + if e.detail_json: + try: + detail = json.loads(e.detail_json) + except (ValueError, TypeError): + detail = {"raw": e.detail_json} + parsed.append({"event": e, "detail": detail}) + labels = await entity_labels(db, parsed) + + for item in parsed: + e = item["event"] + item["entity"] = labels.get((e.entity_type, str(e.entity_id))) if e.entity_id else None return await render_template( "audit/list.html", diff --git a/steward/audit/service.py b/steward/audit/service.py new file mode 100644 index 0000000..40d1cb3 --- /dev/null +++ b/steward/audit/service.py @@ -0,0 +1,84 @@ +"""Name the thing each audit entry is about. + +An audit row stores an entity type and id; the page shows the entity's +current name, linked to its page, in one query per type for the whole page. +An entity deleted since keeps whatever name the entry's own detail recorded. +""" +from __future__ import annotations + +from sqlalchemy import select + +from steward.ansible.inventory_gen import run_title, scope_labels +from steward.models.alerts import AlertRule, MaintenanceWindow +from steward.models.ansible import AnsibleRun +from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget +from steward.models.ansible_schedule import AnsibleSchedule +from steward.models.api_tokens import ApiToken +from steward.models.hosts import Host +from steward.models.monitors import Monitor + +# entity_type -> (model, its page for an id). A type named by a value rather +# than a row (plugin, ansible_playbook) is its own label. +_NAMED = { + "host": (Host, "/hosts/{}"), + "monitor": (Monitor, "/monitors/{}"), + "alert_rule": (AlertRule, "/alerts/history?rule={}"), + "maintenance_window": (MaintenanceWindow, "/alerts/maintenance"), + "ansible_target": (AnsibleTarget, "/ansible/inventory/targets/{}"), + "ansible_group": (AnsibleGroup, "/ansible/inventory/groups/{}"), + "ansible_schedule": (AnsibleSchedule, "/ansible/schedules"), + "api_token": (ApiToken, "/ai-access/"), +} +_SELF_NAMED = {"plugin": "/settings/plugins/", "ansible_playbook": None} + + +def _recorded_name(detail: dict) -> str | None: + """A name the entry recorded when it was written, for a deleted entity.""" + for key in ("name", "new_name", "username", "playbook"): + if detail.get(key): + return str(detail[key]) + return None + + +async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]: + """{(entity_type, entity_id): {"label", "url", "gone"}} for the audit rows + in `items` ({"event", "detail"}), batched one query per type.""" + ids: dict[str, set[str]] = {} + for it in items: + e = it["event"] + if e.entity_type and e.entity_id: + ids.setdefault(e.entity_type, set()).add(str(e.entity_id)) + + labels: dict[tuple[str, str], dict] = {} + for etype, wanted in ids.items(): + if etype in _NAMED: + model, page = _NAMED[etype] + key_col = model.id + keys = [int(i) for i in wanted if i.isdigit()] if etype == "api_token" else list(wanted) + if not keys: + continue + for row_id, name in (await db.execute( + select(key_col, model.name).where(key_col.in_(keys)))).all(): + labels[(etype, str(row_id))] = {"label": name, "url": page.format(row_id), "gone": False} + elif etype == "ansible_run": + runs = (await db.execute( + select(AnsibleRun).where(AnsibleRun.id.in_(list(wanted))))).scalars().all() + scopes = await scope_labels(db, [r.inventory_scope for r in runs]) + for r in runs: + labels[(etype, r.id)] = { + "label": run_title(r.playbook_path, scopes.get(r.inventory_scope, r.inventory_scope)), + "url": f"/ansible/runs/{r.id}", "gone": False} + elif etype in _SELF_NAMED: + for i in wanted: + labels[(etype, i)] = {"label": i, "url": _SELF_NAMED[etype], "gone": False} + + # What a lookup did not find was deleted since: keep the name the entry recorded. + for it in items: + e = it["event"] + if not (e.entity_type and e.entity_id): + continue + key = (e.entity_type, str(e.entity_id)) + if key not in labels: + recorded = _recorded_name(it["detail"] or {}) + labels[key] = {"label": recorded or f"{str(e.entity_id)[:8]}", "url": None, "gone": True} + return labels diff --git a/steward/static/chart-colors.js b/steward/static/chart-colors.js new file mode 100644 index 0000000..a6c36cb --- /dev/null +++ b/steward/static/chart-colors.js @@ -0,0 +1,23 @@ +// Design tokens as colours a can paint with. A chart is drawn on a +// canvas, which can't read var(--green), so each token is resolved through a +// hidden element to the rgb() the browser computed for it. +// +// Loaded after the stylesheets (a script waits for the sheets above it), so the +// tokens are defined by the time this runs. It also gives every Chart.js chart +// the page's text and grid colours, so a chart config only names its series. +(function () { + function stewardColor(name) { + var probe = document.createElement('span'); + probe.style.display = 'none'; + probe.style.color = 'var(' + name + ')'; + document.documentElement.appendChild(probe); + var value = getComputedStyle(probe).color; + probe.remove(); + return value; + } + window.stewardColor = stewardColor; + if (window.Chart) { + Chart.defaults.color = stewardColor('--text-muted'); + Chart.defaults.borderColor = stewardColor('--border'); + } +})(); diff --git a/steward/static/steward.css b/steward/static/steward.css index 5af55fe..ddd1835 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -86,8 +86,8 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b color: var(--gold); border-left-color: var(--gold); background: color-mix(in srgb, var(--gold) 8%, transparent); } -/* How many alerts need attention, beside the Alerts link. */ -.nav-count { +/* A small count that needs attention: alerts beside the Alerts link, UniFi alarms. */ +.count-badge { margin-left: auto; padding: 0 0.45em; border-radius: var(--fs-radius-sm); background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 500; font-variant-numeric: tabular-nums; diff --git a/steward/templates/ai_access/_connect.html b/steward/templates/ai_access/_connect.html index cf2e428..4aa1f07 100644 --- a/steward/templates/ai_access/_connect.html +++ b/steward/templates/ai_access/_connect.html @@ -16,13 +16,13 @@
- Claude Code + Claude Code
claude mcp add --transport http steward {{ mcp_url }} \
   --header "Authorization: Bearer {{ tok }}"
- Claude Desktop + Claude Desktop
Add to claude_desktop_config.json. It bridges through mcp-remote, which needs Node.js. @@ -40,7 +40,7 @@
- Open WebUI and other clients + Open WebUI and other clients
Add an MCP server using the Streamable HTTP transport, with the URL above and bearer authentication set to the token. In Open WebUI this is an external diff --git a/steward/templates/ai_access/created.html b/steward/templates/ai_access/created.html index 7cabaf9..bcff5d2 100644 --- a/steward/templates/ai_access/created.html +++ b/steward/templates/ai_access/created.html @@ -7,7 +7,7 @@

Token created

-
+
Copy this token now. It can't be shown again.
diff --git a/steward/templates/ai_access/index.html b/steward/templates/ai_access/index.html index 777d700..0faea47 100644 --- a/steward/templates/ai_access/index.html +++ b/steward/templates/ai_access/index.html @@ -42,7 +42,7 @@ {% set t = row.token %}
diff --git a/steward/templates/ansible/_playbook_vars.html b/steward/templates/ansible/_playbook_vars.html index 55a4e03..e299eaa 100644 --- a/steward/templates/ansible/_playbook_vars.html +++ b/steward/templates/ansible/_playbook_vars.html @@ -24,7 +24,7 @@ {% endif %} {% if variables %} -
+
Playbook variables

diff --git a/steward/templates/ansible/schedules.html b/steward/templates/ansible/schedules.html index 446b9c6..e4c8ea8 100644 --- a/steward/templates/ansible/schedules.html +++ b/steward/templates/ansible/schedules.html @@ -42,7 +42,7 @@ launch error {% elif row.last_run %} {% set sc = {"queued":"var(--text-dim)","running":"var(--yellow)","success":"var(--green)","failed":"var(--red)","interrupted":"var(--orange)","cancelled":"var(--orange)"}.get(row.last_run.status.value, "var(--text-muted)") %} - {{ row.last_run.status.value }} + {{ row.last_run.status.value }} · {{ s.last_run_at.strftime("%m-%d %H:%M") }} {% else %} never diff --git a/steward/templates/audit/list.html b/steward/templates/audit/list.html index 6aa94e3..6becffc 100644 --- a/steward/templates/audit/list.html +++ b/steward/templates/audit/list.html @@ -1,25 +1,25 @@ {% extends "base.html" %} -{% block title %}Audit Log — Steward{% endblock %} +{% from "_macros.html" import page_header, empty_state %} +{% block title %}Audit log — Steward{% endblock %} {% block content %} -

-

Audit Log

+{% call page_header("Audit log", "Who changed what, newest first.") %}
{% if action_filter %} Clear {% endif %} -
+{% endcall %} {% if events %}
SSID
{{ w.ssid }} {{ w.num_sta }} + {% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
-
{{ t.name }}
+
{{ t.name }}
{{ t.prefix }}…
{{ t.role.value }}
- + @@ -31,30 +31,21 @@ {% set e = item.event %} {% set d = item.detail %} - + + -
Time (UTC)When User Action Entity
- {{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }} - {{ e.timestamp|ago }} {{ e.username }}{{ e.action }} - {% set cat = e.action.split('.')[0] %} - - {{ e.action }} - - - {% if e.entity_type %} - {{ e.entity_type }}/{{ e.entity_id or "" }} - {% endif %} + {% if item.entity %} + {{ e.entity_type|replace('_', ' ') }} + {% if item.entity.url %}{{ item.entity.label }}{% else %}{{ item.entity.label }}{% endif %} + {% if item.entity.gone %}(deleted){% endif %} + {% elif e.entity_type %}{{ e.entity_type|replace('_', ' ') }}{% endif %} {% if d %} {% for k, v in d.items() %} {{ k }}: - {{ v }} + {{ v }} {% if not loop.last %}  ·  {% endif %} {% endfor %} {% endif %} @@ -71,8 +62,6 @@ {% endif %}

{% else %} -
-

No audit events recorded yet.

-
+
{{ empty_state("No audit events recorded yet.") }}
{% endif %} {% endblock %} diff --git a/steward/templates/base.html b/steward/templates/base.html index 275fe3f..7abe5fc 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -25,6 +25,7 @@ + {% block head %}{% endblock %} @@ -62,7 +63,7 @@