feat(plugins): Traefik problems first, UniFi/SNMP/fleet on shared shapes, audit names its entities, templates on tokens (#5126)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m5s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 57s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:25:09 -04:00
co-authored by Claude Opus 5.5
parent c2a79c8bb0
commit 414c942cbf
60 changed files with 686 additions and 391 deletions
@@ -8,7 +8,7 @@
{%- if s.crash_looping or s.failures_24h or (e and s.state != 'healthy') %}
<div style="display:flex;flex-wrap:wrap;align-items:baseline;gap:0.2rem 0.6rem;font-size:0.78rem;color:var(--text-muted);{{ style or '' }}">
{% if s.crash_looping %}
<span style="color:var(--red);font-weight:600;">Crash-looping · {{ s.failures_1h }} tasks failed in the last hour</span>
<span style="color:var(--red);font-weight:500;">Crash-looping · {{ s.failures_1h }} tasks failed in the last hour</span>
{% elif s.failures_24h %}
<span style="color:var(--orange);">{{ s.failures_24h }} task{{ 's' if s.failures_24h != 1 }} failed in the last 24h</span>
{% endif %}
@@ -7,7 +7,7 @@
{% if container is none %}
<div class="card" style="text-align:center;padding:3rem;">
<div style="font-weight:600;margin-bottom:0.4rem;">Container not found</div>
<div style="font-weight:500;margin-bottom:0.4rem;">Container not found</div>
<div style="color:var(--text-muted);font-size:0.9rem;">
No container named <code>{{ name }}</code> is currently reported for this host.
It may have been removed, or the host agent hasn't reported recently.
+1 -1
View File
@@ -14,7 +14,7 @@
{% if host_groups %}
{% for g in host_groups %}
<div style="margin-bottom:2rem;">
<div style="font-weight:600;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}</div>
<div style="font-weight:500;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}</div>
{# ── Summary stats ────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
+5 -5
View File
@@ -47,7 +47,7 @@
{% if swarm_services %}
<div class="card-flush" style="margin-bottom:1.5rem;">
<div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
<span style="font-weight:600;font-size:0.95rem;">Swarm services</span>
<span style="font-weight:500;font-size:0.95rem;">Swarm services</span>
<a href="/plugins/docker/swarm" style="font-size:0.78rem;color:var(--text-muted);">Topology →</a>
</div>
<div style="padding:0.5rem 0.75rem;display:grid;gap:0.85rem;">
@@ -55,7 +55,7 @@
<div>
<div style="display:flex;align-items:center;gap:0.55rem;flex-wrap:wrap;margin-bottom:0.35rem;">
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
<span style="font-weight:600;font-size:0.9rem;">{{ s.name }}</span>
<span style="font-weight:500;font-size:0.9rem;">{{ s.name }}</span>
<span style="font-size:0.72rem;color:var(--text-dim);">{{ s.mode }}</span>
<span class="tone-{{ 'ok' if s.state == 'healthy' else ('warn' if s.state == 'degraded' else 'crit') }}" style="font-size:0.78rem;font-variant-numeric:tabular-nums;">
{{ s.running }}/{{ s.desired }} running
@@ -91,9 +91,9 @@
<div class="card-flush" style="margin-bottom:1.5rem;">
<div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
{% if g.host %}
<a href="/hosts/{{ g.host.id }}" style="font-weight:600;font-size:0.95rem;color:inherit;text-decoration:none;">{{ g.host.name }}</a>
<a href="/hosts/{{ g.host.id }}" style="font-weight:500;font-size:0.95rem;color:inherit;text-decoration:none;">{{ g.host.name }}</a>
{% else %}
<span style="font-weight:600;font-size:0.95rem;">{{ g.host_name }}</span>
<span style="font-weight:500;font-size:0.95rem;">{{ g.host_name }}</span>
{% endif %}
<span style="font-size:0.78rem;color:var(--text-muted);">
{{ g.running }} running{% if g.stopped %} · {{ g.stopped }} stopped{% endif %}
@@ -112,7 +112,7 @@
<tbody>
{% for sub in g.subgroups %}
{% if g.grouped and sub.label %}
<tr><td colspan="5" style="padding:0.4rem 0.75rem 0.2rem;font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-weight:600;">{{ sub.label }}</td></tr>
<tr><td colspan="5" style="padding:0.4rem 0.75rem 0.2rem;font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-weight:500;">{{ sub.label }}</td></tr>
{% endif %}
{% for item in sub.containers %}
{% set c = item.container %}
+1 -1
View File
@@ -11,7 +11,7 @@
{% for g in swarms %}
<div style="margin-bottom:2rem;">
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.95rem;">Swarm</span>
<span style="font-weight:500;font-size:0.95rem;">Swarm</span>
<span style="font-size:0.78rem;color:var(--text-muted);">
reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }}
</span>
+3 -3
View File
@@ -5,11 +5,11 @@
<div style="display:flex;gap:1.25rem;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:1.4rem;font-weight:500;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>
<div title="Containers that died or were OOM-killed in the last 24h, plus swarm tasks that failed (a task Swarm replaced is not a failure)">
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:{% if failed_24h %}var(--red){% else %}var(--text-muted){% endif %};">{{ failed_24h }}</span>
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:{% if failed_24h %}var(--red){% else %}var(--text-muted){% endif %};">{{ failed_24h }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">failed (24h)</span>
</div>
</div>
@@ -24,7 +24,7 @@
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;">
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
<span style="font-weight:500;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.name }}</span>
{% if s.crash_looping %}<span style="color:var(--red);font-size:0.7rem;font-weight:600;flex-shrink:0;" title="{{ s.failures_1h }} tasks failed in the last hour{% if s.last_error %}: {{ s.last_error.error or s.last_error.message or s.last_error.state }}{% endif %}">crash-looping</span>{% endif %}
{% if s.crash_looping %}<span style="color:var(--red);font-size:0.7rem;font-weight:500;flex-shrink:0;" title="{{ s.failures_1h }} tasks failed in the last hour{% if s.last_error %}: {{ s.last_error.error or s.last_error.message or s.last_error.state }}{% endif %}">crash-looping</span>{% endif %}
<span style="color:var(--text-muted);font-family:ui-monospace,monospace;font-size:0.74rem;margin-left:auto;flex-shrink:0;">{{ s.running }}/{{ s.desired }}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.25rem;margin:0.2rem 0 0 1rem;">
+24 -3
View File
@@ -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,
+13 -13
View File
@@ -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 = {};
+40 -36
View File
@@ -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 @@
</div>
{% endif %}
<div class="card-flush" style="margin-bottom:1.5rem;">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Status</th><th>Host</th><th>Last seen</th><th>Agent version</th><th>Reported IP</th><th>Distro</th>
<th class="td-actions">Actions</th>
</tr>
</thead>
<tbody>
{% for item in registrations %}
<tr>
<td>{{ status_badge(item.state, {'up': 'Reporting', 'stale': 'Silent', 'pending': 'Never reported'}[item.state]) }}</td>
<td>{% if item.host %}<a href="/hosts/{{ item.host.id }}">{{ item.host.name }}</a>{% else %}<span class="mono">{{ item.reg.host_id }}</span>{% endif %}</td>
<td>{% if item.reg.last_seen_at %}<span title="{{ item.reg.last_seen_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ item.reg.last_seen_at|ago }}</span>{% else %}never{% endif %}</td>
<td>{{ item.reg.agent_version or "—" }}</td>
<td class="mono">{{ item.reg.host_ip or "—" }}</td>
<td>{{ item.reg.distro or "—" }}</td>
<td class="td-actions">
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
style="display:inline;"
data-confirm="Rotate the token for {{ item.name }}? The existing agent will stop working until reinstalled with the new token.">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">Rotate token</button>
</form>
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
style="display:inline;"
data-confirm="Delete the agent registration for {{ item.name }}? The agent will be unable to report metrics until re-registered.">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">No hosts registered. Add one below.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card">
<form method="post" action="/plugins/host_agent/fleet/add-host"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
@@ -137,39 +176,4 @@
{% endif %}
{% endif %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Host</th><th>Reported IP</th><th>Agent version</th><th>Distro</th>
<th>Last seen</th><th class="td-actions">Actions</th>
</tr>
</thead>
<tbody>
{% for item in registrations %}
<tr>
<td>{{ item.host.name if item.host else item.reg.host_id }}</td>
<td>{{ item.reg.host_ip or "—" }}</td>
<td>{{ item.reg.agent_version or "—" }}</td>
<td>{{ item.reg.distro or "—" }}</td>
<td>{{ item.reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") if item.reg.last_seen_at else "never" }}</td>
<td class="td-actions">
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
style="display:inline;"
data-confirm="Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.">{{ csrf_field() }}
<button type="submit" class="btn btn-ghost btn-sm">Rotate token</button>
</form>
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
style="display:inline;"
data-confirm="Delete this host registration? The agent will be unable to report metrics until re-registered.">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">No hosts registered. Add one above.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -16,16 +16,14 @@
toggle that re-requests this widget without entering edit mode. #}
<div style="flex:0 0 auto;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;font-size:0.75rem;color:var(--text-muted);margin-bottom:0.3rem;">
{% if session.user_id %}
<a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a>
<a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a>
{% else %}
<strong style="color:var(--text);">{{ host.name }}</strong>
{% endif %}
<span class="hist-range" style="margin-left:auto;display:inline-flex;border:1px solid var(--border-mid);border-radius:5px;overflow:hidden;">
<span class="hist-range" style="margin-left:auto;display:inline-flex;gap:0.25rem;">
{% for opt in [1, 6, 24] %}
<button type="button" onclick="histRange({{ wid|int }}, {{ opt|int }})"
style="border:0;cursor:pointer;padding:0.1rem 0.45rem;font-size:0.72rem;
background:{% if opt == hours %}var(--accent){% else %}transparent{% endif %};
color:{% if opt == hours %}#fff{% else %}var(--text-muted){% endif %};">{{ opt }}h</button>
class="range-btn{% if opt == hours %} active{% endif %}">{{ opt }}h</button>
{% endfor %}
</span>
</div>
@@ -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) : ""; } } },
},
},
@@ -7,7 +7,7 @@
{% macro metric_cell(label, value, fmt, svg, style="", title="") %}
<div style="min-width:62px;flex:0 0 auto;" title="{{ title }}">
<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.03em;">{{ label }}</div>
<div style="font-weight:600;font-size:0.8rem;font-variant-numeric:tabular-nums;{{ style }}">
<div style="font-weight:500;font-size:0.8rem;font-variant-numeric:tabular-nums;{{ style }}">
{% if value is not none %}{{ fmt|format(value) }}{% else %}—{% endif %}
</div>
<div style="line-height:0;height:16px;">{{ svg | safe }}</div>
@@ -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 %}"></span>
<span style="min-width:0;">
{% if session.user_id %}
<a href="/hosts/{{ r.host.id }}" style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</a>
<a href="/hosts/{{ r.host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</a>
{% else %}
<span style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span>
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span>
{% endif %}
<span style="display:block;font-size:0.7rem;color:var(--text-dim);">
{{ r.reg.last_seen_at.strftime("%H:%M:%S") if r.reg.last_seen_at else "never" }}
+5 -8
View File
@@ -9,9 +9,7 @@
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
{% for h in [1, 6, 24, 168] %}
<a href="?hours={{ h }}"
class="btn btn-ghost"
style="font-size:0.78rem;padding:0.2rem 0.6rem;{% if hours == h %}background:var(--accent);color:#fff;border-color:var(--accent);{% endif %}">
<a href="?hours={{ h }}" class="range-btn{% if hours == h %} active{% endif %}"{% if hours == h %} aria-current="true"{% endif %}>
{% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %}
</a>
{% 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 } },
},
},
});
+2 -2
View File
@@ -9,7 +9,7 @@
{% for device in devices %}
<div style="{% if not loop.last %}margin-bottom:1rem;{% endif %}">
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.92rem;">{{ device.name }}</span>
<span style="font-weight:500;font-size:0.92rem;">{{ device.name }}</span>
<span style="font-size:0.78rem;color:var(--text-dim);font-family:ui-monospace,monospace;">{{ device.host }}</span>
{% if device.bound %}
<span title="Explicitly bound to this host (host_id / steward_host)"
@@ -32,7 +32,7 @@
<div style="background:var(--bg-elevated);border-radius:5px;padding:0.5rem 0.7rem;border:1px solid var(--border);">
<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</div>
{% if val is not none %}
<div style="font-size:1rem;font-weight:600;font-variant-numeric:tabular-nums;">
<div style="font-size:1rem;font-weight:500;font-variant-numeric:tabular-nums;">
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.72rem;color:var(--text-muted);">M</span>
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.72rem;color:var(--text-muted);">K</span>
{% else %}{{ "%.4g"|format(val) }}{% endif %}
+17 -34
View File
@@ -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 %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin:0;">SNMP Devices</h1>
<span style="font-size:0.8rem;color:var(--text-dim);">{{ devices|length }} device(s) configured</span>
</div>
{{ page_header("SNMP devices", devices|length ~ " device" ~ ("s" if devices|length != 1 else "") ~ " configured") }}
{% if not devices %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;">
No SNMP devices configured. Add devices to <code>plugin.yaml</code>.
</div>
<div class="card">{{ empty_state("No SNMP devices configured. Add them to the snmp plugin's plugin.yaml.") }}</div>
{% else %}
{% for device in devices %}
<div class="card" style="margin-bottom:1rem;">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;">
<h2 style="margin:0;font-size:1rem;font-weight:600;">{{ device.name }}</h2>
<span style="font-size:0.78rem;color:var(--text-dim);">{{ device.host }}</span>
{% if device.readings %}
<span style="font-size:0.75rem;color:var(--green);margin-left:auto;">&#9679; reachable</span>
{% else %}
<span style="font-size:0.75rem;color:var(--text-dim);margin-left:auto;">&#9675; no data yet</span>
{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-ghost" style="font-size:0.78rem;padding:0.2rem 0.6rem;">History</a>
<section class="card" aria-labelledby="snmp-{{ loop.index }}">
<div class="host-health-head">
{{ status_badge('up', 'Reachable') if device.readings else status_badge('pending', 'No data yet') }}
<h2 id="snmp-{{ loop.index }}" class="section-title" style="margin:0;">{{ device.name }}</h2>
<span class="field-hint mono">{{ device.host }}</span>
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-sm btn-ghost" style="margin-left:auto;">History</a>
</div>
{% if device.readings %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.75rem;">
<div class="stat-strip" style="margin-bottom:0;">
{% for oid_cfg in device.oids %}
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
{% set val = device.readings.get(label) %}
<div style="background:var(--bg-elevated);border-radius:6px;padding:0.6rem 0.9rem;">
<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:monospace;">{{ label }}</div>
{% if val is not none %}
<div style="font-size:1.1rem;font-weight:600;font-variant-numeric:tabular-nums;">
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.75rem;color:var(--text-muted);">M</span>
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.75rem;color:var(--text-muted);">K</span>
{% else %}{{ "%.4g"|format(val) }}{% endif %}
</div>
{% else %}
<div style="font-size:0.85rem;color:var(--text-dim);">—</div>
{% endif %}
</div>
{{ 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 %}
</div>
{% else %}
<p style="font-size:0.85rem;color:var(--text-muted);margin:0;">
No readings yet — waiting for next poll cycle.
</p>
<p class="field-hint">No readings yet; waiting for the next poll.</p>
{% endif %}
</div>
</section>
{% endfor %}
{% endif %}
+40 -1
View File
@@ -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,
+85 -105
View File
@@ -1,124 +1,104 @@
{# plugins/traefik/templates/traefik/rows.html #}
{# ── Global stats bar ──────────────────────────────────────────────────────── #}
{% if global_stat %}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;">
{% if global_stat.open_conns_total is not none %}
<span>
<span style="color:var(--text-muted);">Open conns</span>
<span style="margin-left:0.4rem;font-weight:600;">{{ global_stat.open_conns_total | int }}</span>
</span>
{% 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 ───────────────────────────────────────────────────────────────── #}
<div class="stat-strip">
{{ 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 %}
<span>
<span style="color:var(--text-muted);">Config</span>
{% if global_stat.config_last_reload_success == 1.0 %}
<span style="margin-left:0.4rem;color:var(--green);">&#10003; OK</span>
{% else %}
<span style="margin-left:0.4rem;color:var(--red);">&#10007; failed</span>
{% endif %}
{% if global_stat.config_reloads_total is not none %}
<span style="color:var(--text-dim);margin-left:0.3rem;">({{ global_stat.config_reloads_total | int }} reloads)</span>
{% endif %}
</span>
{{ 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 %}
<span>
<span style="color:var(--text-muted);">Memory</span>
<span style="margin-left:0.4rem;">
{{ global_stat.process_memory_bytes|bytes }}
</span>
</span>
{% 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 %}
</div>
<span style="margin-left:auto;color:var(--text-dim);font-size:0.78rem;">
{{ global_stat.scraped_at.strftime("%H:%M:%S") }} UTC
</span>
{% macro router_card(r) %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="host-health-head">
{{ status_badge(r.state, "Failing" if r.state == 'crit' else "Warning") }}
<h3 class="section-title" style="margin:0;word-break:break-all;">{{ r.name }}</h3>
</div>
<p class="problem-detail" style="margin-bottom:0.5rem;">{{ r.why }}</p>
<table class="table" style="font-size:0.85rem;">
<tr><td class="field-hint">Req/s</td><td>{{ "%.2f"|format(r.latest.request_rate) }}</td><td>{{ r.sparkline_req|safe }}</td></tr>
<tr><td class="field-hint">Bandwidth</td><td>{{ r.latest.response_bytes_rate|rate }}</td><td></td></tr>
<tr><td class="field-hint">p95</td><td class="tone-{{ status_info(status_class(r.latest.latency_p95_ms, 'latency'))[0] }}">{{ "%.0f"|format(r.latest.latency_p95_ms) }} ms</td><td>{{ r.sparkline_p95|safe }}</td></tr>
<tr><td class="field-hint">p99</td><td>{{ "%.0f"|format(r.latest.latency_p99_ms) }} ms</td><td></td></tr>
<tr><td class="field-hint">4xx</td><td class="{{ 'tone-warn' if r.latest.error_rate_4xx_pct > error_4xx else '' }}">{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%</td><td></td></tr>
<tr><td class="field-hint">5xx</td><td class="{{ 'tone-crit' if r.latest.error_rate_5xx_pct > error_5xx else '' }}">{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%</td><td>{{ r.sparkline_5xx|safe }}</td></tr>
</table>
<div class="field-hint" style="margin-top:0.5rem;">Last scraped {{ r.latest.scraped_at|ago }}</div>
</div>
{% endmacro %}
{% if problems or healthy or certs %}
{# ── Routers that need attention ─────────────────────────────────────────── #}
{% if problems %}
<div style="column-width:340px;column-gap:1rem;">
{% for r in problems %}{{ router_card(r) }}{% endfor %}
</div>
{% endif %}
{# ── Per-service cards + cert card in shared columns layout ───────────────── #}
{% if router_data or certs %}
<div style="column-width:340px;column-count:3;column-gap:1rem;">
{% for r in router_data %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<h3 class="section-title" style="margin-bottom:0.75rem;word-break:break-all;">{{ r.name }}</h3>
<table class="table" style="font-size:0.85rem;">
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">Req/s</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.2f"|format(r.latest.request_rate) }}</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_req | safe }}</td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">Bandwidth</td>
<td style="padding:0.2rem 0.5rem;">
{% set bps = r.latest.response_bytes_rate %}
{{ bps|rate }}
</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">p95 ms</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p95_ms) }}</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_p95 | safe }}</td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">p99 ms</td>
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p99_ms) }}</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">4xx %</td>
<td style="color:{% if r.latest.error_rate_4xx_pct > 1 %}var(--yellow){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%
</td>
<td style="padding:0.2rem 0;"></td>
</tr>
<tr>
<td style="color:var(--text-muted);padding:0.2rem 0;">5xx %</td>
<td style="color:{% if r.latest.error_rate_5xx_pct > 0.1 %}var(--red){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%
</td>
<td style="padding:0.2rem 0;">{{ r.sparkline_5xx | safe }}</td>
</tr>
</table>
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.5rem;">
Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC
</div>
</div>
{% endfor %}
{% if certs %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">TLS Certificates</div>
<table class="table" style="font-size:0.83rem;width:100%;">
{% for cert in certs %}
{# ── Healthy routers, folded ─────────────────────────────────────────────── #}
{% if healthy %}
<details class="card" id="traefik-healthy" style="margin-bottom:1rem;">
<summary class="section-title" style="cursor:pointer;">{{ healthy|length }} healthy router{{ 's' if healthy|length != 1 }}</summary>
<div class="table-wrap" style="margin-top:0.75rem;">
<table class="table" style="font-size:0.85rem;">
<thead><tr><th>Router</th><th>Req/s</th><th>Bandwidth</th><th>p95</th><th>4xx</th><th>5xx</th></tr></thead>
<tbody>
{% for r in healthy %}
<tr>
<td style="padding:0.2rem 0 0.2rem 0;word-break:break-all;">{{ cert.cn }}</td>
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-weight:600;
{% if cert.days_remaining < 7 %}color:var(--red);
{% elif cert.days_remaining < 30 %}color:var(--yellow);
{% else %}color:var(--green);{% endif %}">
{{ cert.days_remaining | int }}d
</td>
</tr>
<tr>
<td colspan="2" style="padding:0 0 0.4rem 0;color:var(--text-dim);font-size:0.75rem;">
{{ cert.not_after.strftime("%Y-%m-%d") }}
</td>
<td style="word-break:break-all;">{{ r.name }}</td>
<td>{{ "%.2f"|format(r.latest.request_rate) }}</td>
<td>{{ r.latest.response_bytes_rate|rate }}</td>
<td class="tone-{{ status_info(status_class(r.latest.latency_p95_ms, 'latency'))[0] }}">{{ "%.0f"|format(r.latest.latency_p95_ms) }} ms</td>
<td>{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%</td>
<td>{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
<script>
// The fragment is re-polled; keep the healthy list as the reader left it.
(function () {
var el = document.getElementById("traefik-healthy");
if (!el) return;
el.open = !!window.__traefikHealthyOpen;
el.addEventListener("toggle", function () { window.__traefikHealthyOpen = el.open; });
})();
</script>
{% endif %}
{# ── Certificates, soonest expiry first ──────────────────────────────────── #}
{% if certs %}
<div class="card">
<div class="section-title" style="margin-bottom:0.75rem;">TLS certificates</div>
<table class="table" style="font-size:0.85rem;">
<thead><tr><th>Name</th><th>Expires</th><th style="text-align:right;">Left</th></tr></thead>
<tbody>
{% for cert in certs %}
<tr>
<td style="word-break:break-all;">{{ cert.cn }}</td>
<td class="field-hint">{{ cert.not_after.strftime("%Y-%m-%d") }}</td>
<td style="text-align:right;white-space:nowrap;" class="tone-{{ status_info(status_class(cert.days_remaining, 'tls_days'))[0] }}">{{ cert.days_remaining|int }} days</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% else %}
<div class="card">
<p style="color:var(--text-dim);">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
<p class="field-hint">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
</div>
{% endif %}
@@ -5,15 +5,15 @@
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
<span>
<span style="color:var(--text-muted);">Total</span>
<span style="margin-left:0.4rem;font-weight:600;">{{ summary.total }}</span>
<span style="margin-left:0.4rem;font-weight:500;">{{ summary.total }}</span>
</span>
<span>
<span style="color:var(--text-muted);">Internal</span>
<span style="margin-left:0.4rem;color:var(--green);font-weight:600;">{{ summary.internal }}</span>
<span style="margin-left:0.4rem;color:var(--green);font-weight:500;">{{ summary.internal }}</span>
</span>
<span>
<span style="color:var(--text-muted);">External</span>
<span style="margin-left:0.4rem;color:{% if summary.external_pct > 50 %}var(--yellow){% else %}var(--green){% endif %};font-weight:600;">
<span style="margin-left:0.4rem;color:{% if summary.external_pct > 50 %}var(--yellow){% else %}var(--green){% endif %};font-weight:500;">
{{ summary.external }} ({{ "%.1f"|format(summary.external_pct) }}%)
</span>
</span>
@@ -5,11 +5,11 @@
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ summary.total | int }}</span>
<span style="font-size:1.4rem;font-weight:500;line-height:1;">{{ summary.total | int }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total req</span>
</div>
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--orange);">
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--orange);">
{{ "%.1f" | format(summary.external_pct) }}%
</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">external</span>
@@ -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 } },
},
}
}
+1 -7
View File
@@ -28,13 +28,7 @@
{{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="padding:0.25rem 0.5rem;">
<span style="font-size:0.75rem;padding:0.1rem 0.4rem;border-radius:3px;background:
{% if ev.category == 'wlan' %}rgba(96,96,192,0.25)
{% elif ev.category == 'wan' %}rgba(96,160,96,0.25)
{% elif ev.category == 'ids' %}rgba(192,64,64,0.25)
{% else %}rgba(128,128,128,0.15){% endif %};">
{{ ev.category }}
</span>
<span class="badge badge-{{ {'wlan': 'info', 'wan': 'ok', 'ids': 'crit'}.get(ev.category, 'dim') }}">{{ ev.category }}</span>
</td>
<td style="padding:0.25rem 0.5rem;color:var(--text-dim);font-family:monospace;font-size:0.75rem;">
{{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }}
+2 -7
View File
@@ -61,7 +61,7 @@
{% if rule.ruleset != ns.current_rs %}
{% set ns.current_rs = rule.ruleset %}
<tr>
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;">
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:500;letter-spacing:0.04em;text-transform:uppercase;">
{{ rule.ruleset }}
</td>
</tr>
@@ -73,12 +73,7 @@
</span>
</td>
<td style="padding:0.2rem 0.3rem;white-space:nowrap;">
<span style="font-size:0.75rem;padding:0.1rem 0.35rem;border-radius:3px;background:
{% if rule.action == 'accept' %}rgba(64,160,64,0.2)
{% elif rule.action == 'drop' %}rgba(192,64,64,0.2)
{% else %}rgba(192,128,64,0.2){% endif %};">
{{ rule.action }}
</span>
<span class="badge badge-{{ {'accept': 'ok', 'drop': 'crit'}.get(rule.action, 'warn') }}">{{ rule.action }}</span>
</td>
<td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;">
{% if rule.protocol %}{{ rule.protocol }}{% endif %}
+19 -55
View File
@@ -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 %}
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
<span>
<span style="color:var(--text-muted);">WAN</span>
<span style="margin-left:0.4rem;font-weight:600;color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
{% if latest_wan.is_up %}&#10003; Up{% else %}&#10007; Down{% endif %}
</span>
{% if latest_wan.wan_ip %}
<span style="color:var(--text-dim);margin-left:0.4rem;font-family:monospace;font-size:0.8rem;">{{ latest_wan.wan_ip }}</span>
{% endif %}
</span>
<div class="stat-strip">
{{ 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 %}
<span>
<span style="color:var(--text-muted);">Latency</span>
<span style="margin-left:0.4rem;
{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
{{ "%.1f"|format(latest_wan.latency_ms) }}ms
</span>
<span style="margin-left:0.3rem;">{{ sparkline_latency | safe }}</span>
</span>
{{ 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 %}
<span>
<span style="color:var(--text-muted);">&#8595; RX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.rx_bytes_rate %}
{{ bps|rate }}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_rx | safe }}</span>
</span>
<span>
<span style="color:var(--text-muted);">&#8593; TX</span>
<span style="margin-left:0.3rem;">
{% set bps = latest_wan.tx_bytes_rate %}
{{ bps|rate }}
</span>
<span style="margin-left:0.3rem;">{{ sparkline_tx | safe }}</span>
</span>
{% endif %}
{% if latest_wan.uptime_seconds is not none %}
<span style="color:var(--text-dim);font-size:0.8rem;">
up {{ latest_wan.uptime_seconds|duration }}
</span>
{{ 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 %}
</div>
{% endif %}
@@ -63,7 +30,7 @@
</div>
<div style="display:flex;gap:1.5rem;font-size:0.9rem;margin-bottom:0.75rem;font-variant-numeric:tabular-nums;">
<span>
<span style="font-size:1.4rem;font-weight:700;color:var(--text);">{{ latest_clients.total_clients }}</span>
<span style="font-size:1.4rem;color:var(--text);">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);font-size:0.8rem;margin-left:0.2rem;">total</span>
</span>
<span style="color:var(--text-muted);font-size:0.85rem;align-self:flex-end;">
@@ -78,12 +45,12 @@
{# Infrastructure devices card #}
{% if devices %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Infrastructure Devices</div>
<div class="section-title" style="margin-bottom:0.75rem;">Infrastructure devices</div>
<table class="table" style="font-size:0.83rem;width:100%;">
{% for d in devices %}
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
<span style="color:{% if d.state == 1 %}var(--green){% else %}var(--red){% endif %};">&#9679;</span>
{{ status_dot('up' if d.state == 1 else 'down') }}
<span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
</td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</td>
@@ -104,7 +71,7 @@
{# Top talkers card #}
{% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Top Talkers</div>
<div class="section-title" style="margin-bottom:0.75rem;">Top talkers</div>
<table class="table" style="font-size:0.82rem;width:100%;">
{% for c in top_clients %}
<tr>
@@ -130,7 +97,7 @@
{# WLAN SSIDs card #}
{% if wlan_latest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Wireless Networks</div>
<div class="section-title" style="margin-bottom:0.75rem;">Wireless networks</div>
<table class="table" style="font-size:0.83rem;width:100%;">
<tr style="color:var(--text-muted);font-size:0.78rem;">
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">SSID</th>
@@ -141,10 +108,7 @@
<tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ w.ssid }}</td>
<td style="text-align:right;padding:0.2rem 0.3rem;">{{ w.num_sta }}</td>
<td style="text-align:right;padding:0.2rem 0;
{% if w.satisfaction is not none %}
{% if w.satisfaction >= 80 %}color:var(--green){% elif w.satisfaction >= 60 %}color:var(--yellow){% else %}color:var(--red){% endif %}
{% endif %};">
<td style="text-align:right;padding:0.2rem 0;" class="{% if w.satisfaction is not none %}tone-{{ 'ok' if w.satisfaction >= 80 else 'warn' if w.satisfaction >= 60 else 'crit' }}{% endif %}">
{% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
</td>
</tr>
@@ -156,19 +120,19 @@
{# Speedtest card #}
{% if speedtest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Last Speed Test</div>
<div class="section-title" style="margin-bottom:0.75rem;">Last speed test</div>
<div style="display:flex;gap:1.5rem;font-size:0.88rem;font-variant-numeric:tabular-nums;">
{% if speedtest.download_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8595; DL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.download_mbps) }}</span>
<span style="margin-left:0.3rem;">{{ "%.1f"|format(speedtest.download_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
{% if speedtest.upload_mbps is not none %}
<span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8593; UL</span>
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
<span style="margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span>
{% endif %}
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="section-title" style="margin-bottom:0.75rem;">
Active Alarms
{% if active_alarms %}
<span style="float:right;background:var(--red);color:#fff;font-size:0.72rem;padding:0.1rem 0.45rem;border-radius:10px;font-weight:600;">{{ active_alarms|length }}</span>
<span class="count-badge" style="float:right;">{{ active_alarms|length }}</span>
{% endif %}
</div>
{% if not active_alarms %}
+1 -1
View File
@@ -55,7 +55,7 @@
<span class="ping-name" style="font-size:0.8rem;">Clients</span>
</div>
<div style="flex:1;display:flex;gap:1rem;font-size:0.82rem;">
<span style="font-weight:600;">{{ latest_clients.total_clients }}</span>
<span style="font-weight:500;">{{ latest_clients.total_clients }}</span>
<span style="color:var(--text-muted);">{{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e</span>
</div>
</div>
@@ -5,15 +5,15 @@
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ snapshot.total_clients }}</span>
<span style="font-size:1.4rem;font-weight:500;line-height:1;">{{ snapshot.total_clients }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total</span>
</div>
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--accent);">{{ snapshot.wireless_clients }}</span>
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--accent);">{{ snapshot.wireless_clients }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wireless</span>
</div>
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ snapshot.wired_clients }}</span>
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--text-muted);">{{ snapshot.wired_clients }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wired</span>
</div>
</div>
@@ -10,12 +10,12 @@
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;">
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ online | length }}</span>
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ online | length }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">online</span>
</div>
{% if offline %}
<div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ offline | length }}</span>
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ offline | length }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">offline</span>
</div>
{% endif %}
+16 -10
View File
@@ -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",
+84
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
// Design tokens as colours a <canvas> 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');
}
})();
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -16,13 +16,13 @@
</div>
<details open>
<summary style="cursor:pointer;font-weight:600;">Claude Code</summary>
<summary style="cursor:pointer;font-weight:500;">Claude Code</summary>
<pre style="{{ pre }}">claude mcp add --transport http steward {{ mcp_url }} \
--header "Authorization: Bearer {{ tok }}"</pre>
</details>
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-weight:600;">Claude Desktop</summary>
<summary style="cursor:pointer;font-weight:500;">Claude Desktop</summary>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;line-height:1.5;">
Add to <code>claude_desktop_config.json</code>. It bridges through
<code>mcp-remote</code>, which needs Node.js.
@@ -40,7 +40,7 @@
</details>
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-weight:600;">Open WebUI and other clients</summary>
<summary style="cursor:pointer;font-weight:500;">Open WebUI and other clients</summary>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;line-height:1.5;">
Add an MCP server using the <strong>Streamable HTTP</strong> transport, with the URL
above and bearer authentication set to the token. In Open WebUI this is an external
+1 -1
View File
@@ -7,7 +7,7 @@
<h1 class="page-title">Token created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
<div style="font-size:0.8rem;font-weight:500;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
Copy this token now. It can't be shown again.
</div>
+1 -1
View File
@@ -42,7 +42,7 @@
{% set t = row.token %}
<tr {% if row.state != 'active' %}style="opacity:0.55;"{% endif %}>
<td>
<div style="font-weight:600;">{{ t.name }}</div>
<div style="font-weight:500;">{{ t.name }}</div>
<div style="font-family:ui-monospace,monospace;font-size:0.75rem;color:var(--text-muted);">{{ t.prefix }}…</div>
</td>
<td><span class="badge badge-dim">{{ t.role.value }}</span></td>
@@ -24,7 +24,7 @@
</label>
{% endif %}
{% if variables %}
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:500;">
Playbook variables
</div>
<p style="color:var(--text-dim);font-size:0.76rem;margin:0 0 0.6rem;">
+1 -1
View File
@@ -42,7 +42,7 @@
<span style="color:var(--red);" title="{{ s.last_error }}">launch error</span>
{% 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)") %}
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:600;">{{ row.last_run.status.value }}</a>
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:500;">{{ row.last_run.status.value }}</a>
<span style="color:var(--text-dim);">· {{ s.last_run_at.strftime("%m-%d %H:%M") }}</span>
{% else %}
<span style="color:var(--text-dim);">never</span>
+15 -26
View File
@@ -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 %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Audit Log</h1>
{% call page_header("Audit log", "Who changed what, newest first.") %}
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" name="action" value="{{ action_filter }}"
placeholder="Filter by action…"
placeholder="Filter by action…" aria-label="Filter by action"
style="width:200px;font-size:0.85rem;">
<button type="submit" class="btn btn-ghost btn-sm">Filter</button>
{% if action_filter %}
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
{% endif %}
</form>
</div>
{% endcall %}
{% if events %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th style="min-width:160px;">Time (UTC)</th>
<th>When</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
@@ -31,30 +31,21 @@
{% set e = item.event %}
{% set d = item.detail %}
<tr>
<td style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:nowrap;color:var(--text-muted);">
{{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
<td style="font-size:0.85rem;">{{ e.username }}</td>
<td><span class="mono">{{ e.action }}</span></td>
<td>
{% set cat = e.action.split('.')[0] %}
<span style="
font-size:0.78rem;font-family:ui-monospace,monospace;
padding:0.15rem 0.4rem;border-radius:3px;
background:{% if cat == 'auth' %}var(--bg-elevated){% elif cat == 'plugin' %}var(--gold-dim){% elif cat == 'alert' %}var(--red-dim){% elif cat == 'host' %}var(--green-dim){% elif cat == 'settings' %}var(--bg-elevated){% else %}var(--bg-elevated){% endif %};
color:{% if cat == 'auth' %}var(--accent){% elif cat == 'plugin' %}var(--gold){% elif cat == 'alert' %}var(--red){% elif cat == 'host' %}var(--green){% elif cat == 'settings' %}var(--text-muted){% else %}var(--text-muted){% endif %};">
{{ e.action }}
</span>
</td>
<td style="font-size:0.82rem;color:var(--text-muted);">
{% if e.entity_type %}
<span style="color:var(--text-dim);">{{ e.entity_type }}/</span>{{ e.entity_id or "" }}
{% endif %}
{% if item.entity %}
<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
{% if item.entity.url %}<a href="{{ item.entity.url }}">{{ item.entity.label }}</a>{% else %}{{ item.entity.label }}{% endif %}
{% if item.entity.gone %}<span class="field-hint">(deleted)</span>{% endif %}
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
</td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
{% if d %}
{% for k, v in d.items() %}
<span style="color:var(--text-dim);">{{ k }}:</span>
<span style="font-family:ui-monospace,monospace;">{{ v }}</span>
<span class="mono">{{ v }}</span>
{% if not loop.last %} &nbsp;·&nbsp; {% endif %}
{% endfor %}
{% endif %}
@@ -71,8 +62,6 @@
{% endif %}
</p>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<p style="color:var(--text-muted);">No audit events recorded yet.</p>
</div>
<div class="card">{{ empty_state("No audit events recorded yet.") }}</div>
{% endif %}
{% endblock %}
+2 -1
View File
@@ -25,6 +25,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
{% block head %}{% endblock %}
</head>
<body>
@@ -62,7 +63,7 @@
<div class="nav-group">
<div class="nav-group-label">Monitoring</div>
<a href="/monitors/" {% if _p.startswith('/monitors') %}class="active" aria-current="page"{% endif %}>Monitors</a>
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts{% if active_alert_count %} <span class="nav-count" title="{{ active_alert_count }} alert{{ 's' if active_alert_count != 1 }} need{{ 's' if active_alert_count == 1 }} attention">{{ active_alert_count }}</span>{% endif %}</a>
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts{% if active_alert_count %} <span class="count-badge" title="{{ active_alert_count }} alert{{ 's' if active_alert_count != 1 }} need{{ 's' if active_alert_count == 1 }} attention">{{ active_alert_count }}</span>{% endif %}</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Automation</div>
@@ -20,7 +20,7 @@
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.6rem;
padding:0.7rem 0.85rem;cursor:pointer;list-style:none;user-select:none;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.85rem;">{{ w.label }}</div>
<div style="font-weight:500;font-size:0.85rem;">{{ w.label }}</div>
<div style="font-size:0.74rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
</div>
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
+3 -3
View File
@@ -22,7 +22,7 @@
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
<a href="/d/{{ dash.id }}" style="font-weight:500;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.is_default %}
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
{% endif %}
@@ -36,7 +36,7 @@
<details style="position:relative;">
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:var(--fs-shadow-3);">
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">{{ csrf_field() }}
<input type="text" name="name" value="{{ dash.name }}"
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
@@ -83,7 +83,7 @@
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
<a href="/d/{{ dash.id }}" style="font-weight:500;font-size:0.9rem;">{{ dash.name }}</a>
{% if dash.owner_id is none %}
<span class="badge badge-dim" style="font-size:0.68rem;">System</span>
{% endif %}
+1 -1
View File
@@ -20,7 +20,7 @@
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;{% if expired %}opacity:0.5;{% endif %}">
<div style="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.875rem;">
<div style="font-weight:500;font-size:0.875rem;">
{{ token.label or "Unnamed link" }}
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
</div>
@@ -7,7 +7,7 @@
<h1 class="page-title">Share Link Created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
<div style="font-size:0.8rem;font-weight:500;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
Copy this link now — it cannot be shown again
</div>
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
{# The palette, type and widget classes come from steward.css like every
other page; only the read-only page's own frame and its grid live here. #}
<style>
+1 -1
View File
@@ -33,7 +33,7 @@
{% set hs = host_status.get(host.id) %}
{% set ut = uptime.get(host.id, {}) %}
<tr>
<td><a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a></td>
<td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td>
<td style="color:var(--text-muted);">{{ host.address }}</td>
<td style="text-align:center;">
{% if not hs %}
+2 -2
View File
@@ -16,9 +16,9 @@
style="flex-shrink:0;"
title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span>
{% if session.user_id %}
<a href="/hosts/{{ host.id }}" style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
<a href="/hosts/{{ host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
{% else %}
<span style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</span>
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</span>
{% endif %}
</div>
{# Line 2 — monitors + agent metrics; wraps instead of truncating #}
+3 -3
View File
@@ -7,15 +7,15 @@
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
{% if up %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ up }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span></div>
{% endif %}
{% if down %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ down }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span></div>
{% endif %}
{% if pending %}
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--text-dim);">{{ pending }}</span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span></div>
{% endif %}
</div>
@@ -12,7 +12,7 @@
<div id="ansible-source-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<span style="flex-shrink:0;font-size:0.7rem;font-weight:600;text-transform:uppercase;
<span style="flex-shrink:0;font-size:0.7rem;font-weight:500;text-transform:uppercase;
letter-spacing:0.06em;padding:0.15em 0.5em;border-radius:3px;
background:{% if src.type == 'git' %}var(--fs-accent-faint){% else %}var(--bg-elevated){% endif %};
color:{% if src.type == 'git' %}var(--accent){% else %}var(--text-muted){% endif %};">
@@ -20,7 +20,7 @@
</span>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.9rem;">{{ src.name }}</div>
<div style="font-weight:500;font-size:0.9rem;">{{ src.name }}</div>
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{% if src.type == 'git' %}
@@ -136,7 +136,7 @@
<details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;">
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Source</span>
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Source</span>
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary>
@@ -20,7 +20,7 @@
<div style="display:flex;align-items:flex-start;gap:1rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
<span style="font-weight:600;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
<span style="font-weight:500;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
{% if update_available %}
@@ -12,7 +12,7 @@
<div id="plugin-repo-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
<div style="font-weight:500;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
</div>
@@ -65,7 +65,7 @@
<details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;">
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Repository</span>
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Repository</span>
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary>
<form hx-post="/settings/plugins/repos/add"
@@ -50,7 +50,7 @@
name="plugin.{{ plugin._dir }}.enabled"
id="plugin-{{ plugin._dir }}-enabled"
{% if plugin._enabled %}checked{% endif %}>
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:600;cursor:pointer;">
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:500;cursor:pointer;">
Enable this plugin
</label>
</div>
+2 -2
View File
@@ -22,7 +22,7 @@
{% endif %}
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
<span style="font-weight:500;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.get('version', '?') }}</span>
{% if fail_reason %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
@@ -109,7 +109,7 @@
title="{{ orphan.reason or 'Configured but not installed' }}"></span>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
<span style="font-weight:500;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
{% if orphan.reason %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Failed to load</span>
+2 -2
View File
@@ -12,12 +12,12 @@
<label>{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">({{ unit }})</span></label>
<div style="display:flex;gap:1.25rem;flex-wrap:wrap;margin-top:0.25rem;">
<span style="display:flex;align-items:center;gap:0.4rem;">
<span style="color:var(--yellow);font-size:0.8rem;font-weight:600;">warn</span>
<span style="color:var(--yellow);font-size:0.8rem;font-weight:500;">warn</span>
<input type="number" name="{{ warn_field }}" step="{{ step }}"
value="{{ settings[warn_key] }}" style="max-width:110px;">
</span>
<span style="display:flex;align-items:center;gap:0.4rem;">
<span style="color:var(--red);font-size:0.8rem;font-weight:600;">crit</span>
<span style="color:var(--red);font-size:0.8rem;font-weight:500;">crit</span>
<input type="number" name="{{ crit_field }}" step="{{ step }}"
value="{{ settings[crit_key] }}" style="max-width:110px;">
</span>
+1 -1
View File
@@ -31,7 +31,7 @@
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
{% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span>
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:600;">{{ e.status }}</span>
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:500;">{{ e.status }}</span>
</div>
{% endfor %}
{% if problems|length > 8 %}
+2 -2
View File
@@ -121,7 +121,7 @@ async def test_the_sidebar_counts_alerts_needing_attention(app, monkeypatch):
async with app.test_request_context("/alerts/"):
session["user_id"], session["user_role"] = "u1", "viewer"
page = await render_template("alerts/list.html", active=[], rules=[], operators=[])
assert '<span class="nav-count" title="3 alerts need attention">3</span>' in page
assert '<span class="count-badge" title="3 alerts need attention">3</span>' in page
async def test_no_count_for_a_fragment_or_a_logged_out_page(app, monkeypatch):
@@ -131,4 +131,4 @@ async def test_no_count_for_a_fragment_or_a_logged_out_page(app, monkeypatch):
fragment = await render_template("alerts/list.html", active=[], rules=[], operators=[])
async with app.test_request_context("/alerts/"):
logged_out = await render_template("alerts/list.html", active=[], rules=[], operators=[])
assert "nav-count" not in fragment and "nav-count" not in logged_out
assert "count-badge" not in fragment and "count-badge" not in logged_out
+45
View File
@@ -0,0 +1,45 @@
"""Audit entries name the thing they are about, linked to its page, and keep
the recorded name of something deleted since (#5126)."""
from datetime import datetime, timezone
from types import SimpleNamespace as NS
from quart import render_template
from steward.audit.service import entity_labels
class _Empty:
"""A session whose every query finds nothing: every entity was deleted."""
async def execute(self, _stmt):
return NS(all=lambda: [], scalars=lambda: NS(all=lambda: []))
def _ev(etype, eid, action="x.y"):
return NS(entity_type=etype, entity_id=eid, action=action, username="admin",
timestamp=datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc))
async def test_a_deleted_entity_keeps_the_name_its_entry_recorded():
items = [{"event": _ev("host", "h-123456789"), "detail": {"name": "web-1"}},
{"event": _ev("monitor", "m-abcdef012"), "detail": {}},
{"event": _ev("plugin", "docker"), "detail": {}},
{"event": _ev(None, None), "detail": {}}]
labels = await entity_labels(_Empty(), items)
assert labels[("host", "h-123456789")] == {"label": "web-1", "url": None, "gone": True}
assert labels[("monitor", "m-abcdef012")] == {"label": "m-abcdef", "url": None, "gone": True}
assert labels[("plugin", "docker")] == {"label": "docker", "url": "/settings/plugins/", "gone": False}
assert len(labels) == 3
async def test_the_page_names_and_links_the_entity(app):
events = [
{"event": _ev("host", "h1", "host.renamed"), "detail": {"old": "a", "new": "b"},
"entity": {"label": "web-1", "url": "/hosts/h1", "gone": False}},
{"event": _ev("monitor", "m9", "monitor.deleted"), "detail": {},
"entity": {"label": "api check", "url": None, "gone": True}},
]
async with app.test_request_context("/audit/"):
page = await render_template("audit/list.html", events=events, limit=200, action_filter="")
assert '<a href="/hosts/h1">web-1</a>' in page
assert "api check" in page and "(deleted)" in page
assert "h1</td>" not in page # no bare id where a name is known
@@ -0,0 +1,39 @@
"""The agent fleet page opens on the fleet itself: a status column, silent
agents first (#5126)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from plugins.host_agent.routes import fleet_rows, fleet_state
from tests._templates import render_plugin_template
def _reg(host_id, seen_ago_s):
now = datetime.now(timezone.utc)
return NS(host_id=host_id, last_seen_at=None if seen_ago_s is None else now - timedelta(seconds=seen_ago_s),
host_ip="10.0.0.5", agent_version="1.4.0", distro="debian")
def test_fleet_state():
now = datetime.now(timezone.utc)
assert fleet_state(_reg("a", 30), now, 180) == "up"
assert fleet_state(_reg("a", 600), now, 180) == "stale"
assert fleet_state(_reg("a", None), now, 180) == "pending"
async def test_silent_agents_come_first_and_the_table_leads_the_page(app):
now = datetime.now(timezone.utc)
hosts = {i: NS(id=i, name=n) for i, n in [("h1", "web"), ("h2", "nas"), ("h3", "attic")]}
rows = fleet_rows([_reg("h1", 30), _reg("h2", 900), _reg("h3", None)], hosts, now, 180)
assert [r["name"] for r in rows] == ["nas", "attic", "web"]
async with app.test_request_context("/plugins/host_agent/fleet/"):
page = await render_plugin_template(app, "host_agent", "settings_list.html", registrations=rows,
new_token=None, install_url=None, ansible_available=False,
deploy_targets=[], deploy_groups=[], managed_key_set=False)
assert page.index("<th>Status</th>") < page.index('action="/plugins/host_agent/fleet/add-host"')
assert '<span class="badge badge-warn">Silent</span>' in page
assert '<span class="badge badge-warn">Never reported</span>' in page
assert '<span class="badge badge-ok">Reporting</span>' in page
assert page.index('href="/hosts/h2">nas</a>') < page.index('href="/hosts/h1">web</a>')
assert 'data-confirm="Delete the agent registration for nas?' in page
+22
View File
@@ -0,0 +1,22 @@
"""The SNMP list says each device's state with the shared badge and shows its
readings as stat tiles (#5126)."""
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
async def test_devices_wear_a_status_badge_and_readings_are_tiles(app):
devices = [
{"name": "ups", "host": "10.0.0.9", "oids": [{"oid": "1.3.6", "label": "battery_charge_pct"},
{"oid": "1.3.7", "label": "in_octets"}],
"readings": {"battery_charge_pct": 97.0, "in_octets": 2_500_000.0}},
{"name": "printer", "host": "10.0.0.10", "oids": [], "readings": {}},
]
async with app.test_request_context("/plugins/snmp/"):
page = await render_plugin_template(app, "snmp", "snmp/index.html", devices=devices, poll_interval=60)
assert '<span class="badge badge-ok">Reachable</span>' in page
assert '<span class="badge badge-warn">No data yet</span>' in page
assert '<span class="stat-tile-value">97</span>' in page and '<span class="stat-tile-value">2.50M</span>' in page
assert 'href="/plugins/snmp/device/ups"' in page
main = page[page.index("<main"):]
assert "font-weight:600" not in main and not hex_colours(main), hex_colours(main)
View File
@@ -0,0 +1,65 @@
"""The Traefik page puts failing and slow routers first and folds the healthy
ones into one table; certificates are graded by Settings → Thresholds (#5126)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import pytest
from plugins.traefik.routes import router_state, split_routers
from steward.core.settings import to_thresholds_cfg
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
TH = to_thresholds_cfg({}) # latency crit 200 ms
def _latest(req=1.0, p95=20.0, e4=0.0, e5=0.0):
return NS(request_rate=req, response_bytes_rate=1000.0, latency_p95_ms=p95, latency_p99_ms=p95 * 1.5,
error_rate_4xx_pct=e4, error_rate_5xx_pct=e5,
scraped_at=datetime.now(timezone.utc) - timedelta(seconds=30))
@pytest.mark.parametrize("latest, grade", [
(_latest(), "ok"),
(_latest(e5=2.5), "crit"),
(_latest(p95=450.0), "warn"),
(_latest(e4=5.0), "warn"),
(_latest(p95=120.0, e4=0.5), "ok"), # slow-ish but under the critical cutoff
])
def test_router_state(latest, grade):
assert router_state(latest, TH)[0] == grade
def _router(name, latest):
state, why = router_state(latest, TH)
return {"name": name, "latest": latest, "state": state, "why": why,
"sparkline_req": "", "sparkline_p95": "", "sparkline_5xx": ""}
def test_failing_routers_come_before_slow_ones_and_healthy_ones_split_off():
problems, healthy = split_routers([
_router("b-ok", _latest()), _router("slow", _latest(p95=450.0)),
_router("broken", _latest(e5=3.0)), _router("a-ok", _latest())])
assert [r["name"] for r in problems] == ["broken", "slow"]
assert [r["name"] for r in healthy] == ["a-ok", "b-ok"]
async def test_the_page_leads_with_problems_and_folds_the_healthy(app):
problems, healthy = split_routers([
_router("api", _latest(e5=3.0)), _router("web", _latest()), _router("docs", _latest())])
now = datetime.now(timezone.utc)
certs = [{"serial": "1", "cn": "soon.example", "sans": [], "not_after": now + timedelta(days=2),
"days_remaining": 2.0},
{"serial": "2", "cn": "fine.example", "sans": [], "not_after": now + timedelta(days=60),
"days_remaining": 60.0}]
async with app.test_request_context("/plugins/traefik/rows"):
page = await render_plugin_template(app, "traefik", "traefik/rows.html", problems=problems,
healthy=healthy, global_stat=None, certs=certs, range_key="24h",
error_4xx=1.0, error_5xx=0.1)
assert page.index(">api</h3>") < page.index('id="traefik-healthy"')
assert "3.0% of responses are 5xx" in page
assert ">2 healthy routers</summary>" in page
assert page.index(">docs</td>") < page.index(">web</td>")
assert 'class="tone-crit">2 days</td>' in page and 'class="tone-ok">60 days</td>' in page
assert "font-weight:600" not in page and not hex_colours(page), hex_colours(page)
+34
View File
@@ -0,0 +1,34 @@
"""UniFi's overview reads in the shared shapes: WAN health as stat tiles graded
by Settings → Thresholds, devices with status dots, categories as badges (#5126)."""
from datetime import datetime, timezone
from types import SimpleNamespace as NS
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
async def test_wan_health_and_devices_use_the_shared_shapes(app):
wan = NS(is_up=False, wan_ip="203.0.113.7", latency_ms=250.0, rx_bytes_rate=2_000_000.0,
tx_bytes_rate=500_000.0, uptime_seconds=7200)
devices = [NS(name="switch", mac="aa", model="USW", device_type="usw", ip="10.0.0.2", state=1,
uptime_seconds=3600),
NS(name="ap-attic", mac="bb", model="U6", device_type="uap", ip="10.0.0.3", state=0,
uptime_seconds=None)]
async with app.test_request_context("/plugins/unifi/rows"):
page = await render_plugin_template(app, "unifi", "unifi/rows.html", latest_wan=wan, devices=devices,
sparkline_latency="", sparkline_rx="", sparkline_tx="")
assert '<span class="stat-tile-label">WAN</span>' in page and "stat-crit" in page
assert '<span class="stat-tile-value">250 ms</span>' in page
assert 'class="dot dot-ok"' in page and 'class="dot dot-crit"' in page
assert "font-weight:600" not in page and "font-weight:700" not in page
assert not hex_colours(page), hex_colours(page)
async def test_event_categories_are_badges(app):
events = [NS(event_time=datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc), category="ids",
event_key="EVT_IPS_Alert", message="blocked", subsystem="ids")]
async with app.test_request_context("/plugins/unifi/events"):
page = await render_plugin_template(app, "unifi", "unifi/events.html", events=events)
assert '<span class="badge badge-crit">ids</span>' in page
assert "rgba(" not in page
+13 -1
View File
@@ -22,7 +22,7 @@ _USED = re.compile(r"var\(\s*(--[\w-]+)")
# string -- but not an HTML attribute's value (href="#abc" is a link to an id).
_HEX = re.compile(r"""(?:[:(,]\s*|(?<![\w-]=)['"])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-zA-Z_-])""")
HEX_CEILING = 47 # 75 at #5092, 58 at #5095, 56 at #5121, 54 at #5122, 47 with Ansible runs on tokens (#5125); only lower it
HEX_CEILING = 0 # 75 at #5092, 58 at #5095, 56 at #5121, 54 at #5122, 47 at #5125, 0 with the charts on tokens (#5126)
def _sheets():
@@ -45,11 +45,17 @@ def hex_colours(text: str) -> list[str]:
return _HEX.findall(_COMMENTS.sub("", text))
def heavy_weights(text: str) -> list[str]:
"""Weights past the house pair (400, 500), outside comments."""
return re.findall(r"font-weight\s*:\s*(?:[6-9]00|bold)", _COMMENTS.sub("", text))
def test_the_guards_can_fail():
assert undefined_vars([":root { --a: 1; } .x { color: var(--a); background: var(--b); }"]) == {"--b"}
page = '<div style="color:#fff;background: rgb(0 0 0)" hx-target="#add-form"></div><script>c = "#4aa86a"</script>'
assert len(hex_colours(page)) == 2
assert hex_colours("{# color:#fff #}<a href=\"#abc\">") == []
assert heavy_weights('<b style="font-weight: 600">{# font-weight:700 #}</b>') == ["font-weight: 600"]
def test_every_css_variable_used_is_defined():
@@ -63,6 +69,12 @@ def test_the_base_stylesheet_states_no_colour_or_heavy_weight_of_its_own():
assert not re.findall(r"font-weight\s*:\s*(?:[6-9]00|bold)", css)
def test_templates_use_the_two_house_weights():
"""The house style has two weights, 400 and 500 (design system 1)."""
heavy = {p.name: len(found) for p in _templates() if (found := heavy_weights(p.read_text()))}
assert not heavy, heavy
def test_hex_colours_in_templates_only_go_down():
count = sum(len(hex_colours(p.read_text())) for p in _templates())
assert count <= HEX_CEILING, (