-
+
{{ "%.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 %}
- ●
+ {{ status_dot('up' if d.state == 1 else 'down') }}
{{ d.name or d.mac }}
{{ d.model or d.device_type or "" }}
@@ -104,7 +71,7 @@
{# Top talkers card #}
{% if top_clients %}
-
Top Talkers
+
Top talkers
{% for c in top_clients %}
@@ -130,7 +97,7 @@
{# WLAN SSIDs card #}
{% if wlan_latest %}
-
Wireless Networks
+
Wireless networks
SSID
@@ -141,10 +108,7 @@
{{ w.ssid }}
{{ w.num_sta }}
-
+
{% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
@@ -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 %}
- {{ t.name }}
+ {{ t.name }}
{{ t.prefix }}…
{{ t.role.value }}
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.") %}
-
+{% endcall %}
{% if events %}
- Time (UTC)
+ When
User
Action
Entity
@@ -31,30 +31,21 @@
{% set e = item.event %}
{% set d = item.detail %}
-
- {{ 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 @@
Automation
diff --git a/steward/templates/dashboard/_widget_drawer.html b/steward/templates/dashboard/_widget_drawer.html
index 24fb186..47f9957 100644
--- a/steward/templates/dashboard/_widget_drawer.html
+++ b/steward/templates/dashboard/_widget_drawer.html
@@ -20,7 +20,7 @@
-
{{ w.label }}
+
{{ w.label }}
{{ w.description }}
Add ▾
diff --git a/steward/templates/dashboard/list.html b/steward/templates/dashboard/list.html
index f174b47..3899d61 100644
--- a/steward/templates/dashboard/list.html
+++ b/steward/templates/dashboard/list.html
@@ -22,7 +22,7 @@