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') %} {%- 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 '' }}"> <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 %} {% 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 %} {% 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> <span style="color:var(--orange);">{{ s.failures_24h }} task{{ 's' if s.failures_24h != 1 }} failed in the last 24h</span>
{% endif %} {% endif %}
@@ -7,7 +7,7 @@
{% if container is none %} {% if container is none %}
<div class="card" style="text-align:center;padding:3rem;"> <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;"> <div style="color:var(--text-muted);font-size:0.9rem;">
No container named <code>{{ name }}</code> is currently reported for this host. 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. It may have been removed, or the host agent hasn't reported recently.
+1 -1
View File
@@ -14,7 +14,7 @@
{% if host_groups %} {% if host_groups %}
{% for g in host_groups %} {% for g in host_groups %}
<div style="margin-bottom:2rem;"> <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 ────────────────────────────────────────────────────── #} {# ── Summary stats ────────────────────────────────────────────────────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;"> <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 %} {% if swarm_services %}
<div class="card-flush" style="margin-bottom:1.5rem;"> <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);"> <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> <a href="/plugins/docker/swarm" style="font-size:0.78rem;color:var(--text-muted);">Topology →</a>
</div> </div>
<div style="padding:0.5rem 0.75rem;display:grid;gap:0.85rem;"> <div style="padding:0.5rem 0.75rem;display:grid;gap:0.85rem;">
@@ -55,7 +55,7 @@
<div> <div>
<div style="display:flex;align-items:center;gap:0.55rem;flex-wrap:wrap;margin-bottom:0.35rem;"> <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 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 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;"> <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 {{ s.running }}/{{ s.desired }} running
@@ -91,9 +91,9 @@
<div class="card-flush" style="margin-bottom:1.5rem;"> <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);"> <div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
{% if g.host %} {% 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 %} {% 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 %} {% endif %}
<span style="font-size:0.78rem;color:var(--text-muted);"> <span style="font-size:0.78rem;color:var(--text-muted);">
{{ g.running }} running{% if g.stopped %} · {{ g.stopped }} stopped{% endif %} {{ g.running }} running{% if g.stopped %} · {{ g.stopped }} stopped{% endif %}
@@ -112,7 +112,7 @@
<tbody> <tbody>
{% for sub in g.subgroups %} {% for sub in g.subgroups %}
{% if g.grouped and sub.label %} {% 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 %} {% endif %}
{% for item in sub.containers %} {% for item in sub.containers %}
{% set c = item.container %} {% set c = item.container %}
+1 -1
View File
@@ -11,7 +11,7 @@
{% for g in swarms %} {% for g in swarms %}
<div style="margin-bottom:2rem;"> <div style="margin-bottom:2rem;">
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.75rem;flex-wrap:wrap;"> <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);"> <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(", ") }} reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }}
</span> </span>
+3 -3
View File
@@ -5,11 +5,11 @@
<div style="display:flex;gap:1.25rem;margin-bottom:0.65rem;"> <div style="display:flex;gap:1.25rem;margin-bottom:0.65rem;">
<div> <div>
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ running_count }}</span> <span style="font-size:1.4rem;font-weight: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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">running</span>
</div> </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)"> <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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">failed (24h)</span>
</div> </div>
</div> </div>
@@ -24,7 +24,7 @@
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;"> <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 class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
<span style="font-weight:500;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.name }}</span> <span style="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> <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>
<div style="display:flex;flex-wrap:wrap;gap:0.25rem;margin:0.2rem 0 0 1rem;"> <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") 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: def _stale_after_seconds() -> int:
return int( return int(
current_app.config.get("PLUGINS", {}) current_app.config.get("PLUGINS", {})
@@ -803,11 +825,10 @@ async def fleet():
managed_key_set = bool( managed_key_set = bool(
(current_app.config.get("ANSIBLE", {}).get("ssh_public_key") or "").strip()) (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( return await render_template(
"settings_list.html", "settings_list.html",
registrations=[ registrations=fleet_rows(regs, hosts_by_id, now, stale_after),
{"reg": r, "host": hosts_by_id.get(r.host_id)} for r in regs
],
new_token=new_token, new_token=new_token,
install_url=install_url, install_url=install_url,
ansible_available=ansible_available, ansible_available=ansible_available,
+13 -13
View File
@@ -54,30 +54,30 @@
interaction: { mode: "index", intersect: false }, interaction: { mode: "index", intersect: false },
elements: { point: { radius: 0 } }, elements: { point: { radius: 0 } },
scales: { scales: {
x: { type: "linear", ticks: { callback: (v) => fmtTime(v), maxTicksLimit: 8, 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: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } }, y: { beginAtZero: true, max: ymax, ticks: { font: { size: 10 } } },
}, },
plugins: { 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) : "" } }, tooltip: { callbacks: { title: (items) => items.length ? fmtTime(items[0].parsed.x) : "" } },
}, },
}); });
const defs = { const defs = {
"chart-util": { ymax: 100, ds: [ "chart-util": { ymax: 100, ds: [
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" }, { key: "cpu_pct", label: "CPU %", color: stewardColor("--gold") },
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" }, { key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") },
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" }, { key: "disk_used_pct_worst", label: "Disk % (worst)", color: stewardColor("--yellow") },
] }, ] },
"chart-net": { ymax: undefined, ds: [ "chart-net": { ymax: undefined, ds: [
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" }, { key: "net_rx_bps", label: "Net RX", color: stewardColor("--green") },
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" }, { key: "net_tx_bps", label: "Net TX", color: stewardColor("--red") },
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" }, { key: "disk_read_bps", label: "Disk read", color: stewardColor("--blue") },
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" }, { key: "disk_write_bps", label: "Disk write", color: stewardColor("--gold") },
] }, ] },
"chart-pressure": { ymax: undefined, ds: [ "chart-pressure": { ymax: undefined, ds: [
{ key: "load_1m", label: "Load 1m", color: "#c8a840" }, { key: "load_1m", label: "Load 1m", color: stewardColor("--gold") },
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" }, { key: "psi_mem_some_avg10", label: "Mem PSI %", color: stewardColor("--red") },
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" }, { key: "temp_c_max", label: "Temp °C", color: stewardColor("--yellow") },
] }, ] },
}; };
const charts = {}; const charts = {};
+40 -36
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs, status_badge %}
{% block title %}Agent fleet — Steward{% endblock %} {% block title %}Agent fleet — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Agent fleet", "")]) }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Agent fleet", "")]) }}{% endblock %}
{% block content %} {% block content %}
@@ -21,6 +21,45 @@
</div> </div>
{% endif %} {% 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"> <div class="card">
<form method="post" action="/plugins/host_agent/fleet/add-host" <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() }} style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
@@ -137,39 +176,4 @@
{% endif %} {% endif %}
{% 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 %} {% endblock %}
@@ -16,16 +16,14 @@
toggle that re-requests this widget without entering edit mode. #} 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;"> <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 %} {% 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 %} {% else %}
<strong style="color:var(--text);">{{ host.name }}</strong> <strong style="color:var(--text);">{{ host.name }}</strong>
{% endif %} {% 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] %} {% for opt in [1, 6, 24] %}
<button type="button" onclick="histRange({{ wid|int }}, {{ opt|int }})" <button type="button" onclick="histRange({{ wid|int }}, {{ opt|int }})"
style="border:0;cursor:pointer;padding:0.1rem 0.45rem;font-size:0.72rem; class="range-btn{% if opt == hours %} active{% endif %}">{{ opt }}h</button>
background:{% if opt == hours %}var(--accent){% else %}transparent{% endif %};
color:{% if opt == hours %}#fff{% else %}var(--text-muted){% endif %};">{{ opt }}h</button>
{% endfor %} {% endfor %}
</span> </span>
</div> </div>
@@ -57,9 +55,9 @@ window.histRange = function (wid, hours) {
return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2); return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2);
}; };
var ds = [ var ds = [
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" }, { key: "cpu_pct", label: "CPU %", color: stewardColor("--gold") },
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" }, { key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") },
{ key: "disk_root", label: "Disk / %", color: "#c87840" }, { key: "disk_root", label: "Disk / %", color: stewardColor("--yellow") },
]; ];
new Chart(el.getContext("2d"), { new Chart(el.getContext("2d"), {
type: "line", type: "line",
@@ -75,14 +73,14 @@ window.histRange = function (wid, hours) {
interaction: { mode: "index", intersect: false }, interaction: { mode: "index", intersect: false },
elements: { point: { radius: 0 } }, elements: { point: { radius: 0 } },
scales: { 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 // 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 // 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. // 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: { 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) : ""; } } }, 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="") %} {% macro metric_cell(label, value, fmt, svg, style="", title="") %}
<div style="min-width:62px;flex:0 0 auto;" title="{{ 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-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 %} {% if value is not none %}{{ fmt|format(value) }}{% else %}—{% endif %}
</div> </div>
<div style="line-height:0;height:16px;">{{ svg | safe }}</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> 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;"> <span style="min-width:0;">
{% if session.user_id %} {% 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 %} {% 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 %} {% endif %}
<span style="display:block;font-size:0.7rem;color:var(--text-dim);"> <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" }} {{ 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> <span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
<div style="margin-left:auto;display:flex;gap:0.5rem;"> <div style="margin-left:auto;display:flex;gap:0.5rem;">
{% for h in [1, 6, 24, 168] %} {% for h in [1, 6, 24, 168] %}
<a href="?hours={{ h }}" <a href="?hours={{ h }}" class="range-btn{% if hours == h %} active{% endif %}"{% if hours == h %} aria-current="true"{% endif %}>
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 %}">
{% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %} {% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %}
</a> </a>
{% endfor %} {% endfor %}
@@ -27,7 +25,8 @@
(function(){ (function(){
const raw = {{ history_json | safe }}; const raw = {{ history_json | safe }};
const labels = {{ oid_labels | tojson }}; 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 // Collect all unique timestamps, sorted
const tsSet = new Set(); const tsSet = new Set();
@@ -66,12 +65,10 @@
responsive: true, responsive: true,
interaction: { mode: "index", intersect: false }, interaction: { mode: "index", intersect: false },
plugins: { plugins: {
legend: { position: "top", labels: { color: "#aaa", boxWidth: 12 } }, legend: { position: "top", labels: { boxWidth: 12 } },
tooltip: { backgroundColor: "#1e1e2e", titleColor: "#ccc", bodyColor: "#aaa" },
}, },
scales: { scales: {
x: { ticks: { color: "#888", maxTicksLimit: 12 }, grid: { color: "#2a2a3a" } }, x: { ticks: { maxTicksLimit: 12 } },
y: { ticks: { color: "#888" }, grid: { color: "#2a2a3a" } },
}, },
}, },
}); });
+2 -2
View File
@@ -9,7 +9,7 @@
{% for device in devices %} {% for device in devices %}
<div style="{% if not loop.last %}margin-bottom:1rem;{% endif %}"> <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;"> <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> <span style="font-size:0.78rem;color:var(--text-dim);font-family:ui-monospace,monospace;">{{ device.host }}</span>
{% if device.bound %} {% if device.bound %}
<span title="Explicitly bound to this host (host_id / steward_host)" <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="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> <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 %} {% 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> {% 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> {% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.72rem;color:var(--text-muted);">K</span>
{% else %}{{ "%.4g"|format(val) }}{% endif %} {% else %}{{ "%.4g"|format(val) }}{% endif %}
+17 -34
View File
@@ -1,56 +1,39 @@
{# plugins/snmp/templates/snmp/index.html #} {# plugins/snmp/templates/snmp/index.html #}
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
{% block title %}SNMP — Steward{% endblock %} {% block title %}SNMP — Steward{% endblock %}
{% block content %} {% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;"> {{ page_header("SNMP devices", devices|length ~ " device" ~ ("s" if devices|length != 1 else "") ~ " configured") }}
<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>
{% if not devices %} {% if not devices %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;"> <div class="card">{{ empty_state("No SNMP devices configured. Add them to the snmp plugin's plugin.yaml.") }}</div>
No SNMP devices configured. Add devices to <code>plugin.yaml</code>.
</div>
{% else %} {% else %}
{% for device in devices %} {% for device in devices %}
<div class="card" style="margin-bottom:1rem;"> <section class="card" aria-labelledby="snmp-{{ loop.index }}">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;"> <div class="host-health-head">
<h2 style="margin:0;font-size:1rem;font-weight:600;">{{ device.name }}</h2> {{ status_badge('up', 'Reachable') if device.readings else status_badge('pending', 'No data yet') }}
<span style="font-size:0.78rem;color:var(--text-dim);">{{ device.host }}</span> <h2 id="snmp-{{ loop.index }}" class="section-title" style="margin:0;">{{ device.name }}</h2>
{% if device.readings %} <span class="field-hint mono">{{ device.host }}</span>
<span style="font-size:0.75rem;color:var(--green);margin-left:auto;">&#9679; reachable</span> <a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-sm btn-ghost" style="margin-left:auto;">History</a>
{% 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>
</div> </div>
{% if device.readings %} {% 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 %} {% for oid_cfg in device.oids %}
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %} {% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
{% set val = device.readings.get(label) %} {% set val = device.readings.get(label) %}
<div style="background:var(--bg-elevated);border-radius:6px;padding:0.6rem 0.9rem;"> {{ stat_tile(label,
<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:monospace;">{{ label }}</div> "—" if val is none else
{% if val is not none %} ("%.2fM"|format(val / 1000000)) if val >= 1000000 else
<div style="font-size:1.1rem;font-weight:600;font-variant-numeric:tabular-nums;"> ("%.1fK"|format(val / 1000)) if val >= 1000 else
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.75rem;color:var(--text-muted);">M</span> "%.4g"|format(val)) }}
{% 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>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p style="font-size:0.85rem;color:var(--text-muted);margin:0;"> <p class="field-hint">No readings yet; waiting for the next poll.</p>
No readings yet — waiting for next poll cycle.
</p>
{% endif %} {% endif %}
</div> </section>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
+40 -1
View File
@@ -9,6 +9,7 @@ import json
from steward.auth.middleware import require_role from steward.auth.middleware import require_role
from steward.models.users import UserRole from steward.models.users import UserRole
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample 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 from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
traefik_bp = Blueprint("traefik", __name__, template_folder="templates") 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("/") @traefik_bp.get("/")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def index(): async def index():
@@ -59,6 +90,7 @@ async def rows():
"""HTMX fragment: service cards with sparklines scoped to selected time range.""" """HTMX fragment: service cards with sparklines scoped to selected time range."""
since, range_key = parse_range(request.args.get("range")) since, range_key = parse_range(request.args.get("range"))
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
thresholds = current_app.config.get("THRESHOLDS", {})
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
# All known routers # All known routers
@@ -103,9 +135,12 @@ async def rows():
if not latest: if not latest:
continue continue
state, why = router_state(latest, thresholds)
router_data.append({ router_data.append({
"name": router, "name": router,
"latest": latest, "latest": latest,
"state": state,
"why": why,
"sparkline_req": _sparkline([r.request_rate for r in history]), "sparkline_req": _sparkline([r.request_rate for r in history]),
"sparkline_p95": _sparkline([r.latency_p95_ms 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]), "sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]),
@@ -136,9 +171,13 @@ async def rows():
"days_remaining": days, "days_remaining": days,
}) })
problems, healthy = split_routers(router_data)
return await render_template( return await render_template(
"traefik/rows.html", "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, global_stat=global_stat,
certs=certs, certs=certs,
range_key=range_key, range_key=range_key,
+85 -105
View File
@@ -1,124 +1,104 @@
{# plugins/traefik/templates/traefik/rows.html #} {# traefik/rows.html — HTMX fragment for the Traefik page: routers that need
attention first (routes.router_state), the proxy's own counts, the healthy
{# ── Global stats bar ──────────────────────────────────────────────────────── #} routers folded into one table, then certificates by soonest expiry. #}
{% if global_stat %} {% from "_macros.html" import stat_tile, status_badge %}
<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 %}
{# ── 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 %} {% if global_stat.config_last_reload_success is not none %}
<span> {{ stat_tile("Config", "OK" if global_stat.config_last_reload_success == 1.0 else "Reload failed",
<span style="color:var(--text-muted);">Config</span> state="up" if global_stat.config_last_reload_success == 1.0 else "crit",
{% if global_stat.config_last_reload_success == 1.0 %} hint=(global_stat.config_reloads_total|int ~ " reloads") if global_stat.config_reloads_total is not none else none) }}
<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>
{% endif %} {% endif %}
{% 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 %} {% if global_stat.process_memory_bytes is not none %}{{ stat_tile("Memory", global_stat.process_memory_bytes|bytes) }}{% endif %}
<span>
<span style="color:var(--text-muted);">Memory</span>
<span style="margin-left:0.4rem;">
{{ global_stat.process_memory_bytes|bytes }}
</span>
</span>
{% endif %} {% endif %}
</div>
<span style="margin-left:auto;color:var(--text-dim);font-size:0.78rem;"> {% macro router_card(r) %}
{{ global_stat.scraped_at.strftime("%H:%M:%S") }} UTC <div class="card" style="break-inside:avoid;margin-bottom:1rem;">
</span> <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> </div>
{% endif %} {% endif %}
{# ── Per-service cards + cert card in shared columns layout ───────────────── #} {# ── Healthy routers, folded ─────────────────────────────────────────────── #}
{% if router_data or certs %} {% if healthy %}
<div style="column-width:340px;column-count:3;column-gap:1rem;"> <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>
{% for r in router_data %} <div class="table-wrap" style="margin-top:0.75rem;">
<div class="card" style="break-inside:avoid;margin-bottom:1rem;"> <table class="table" style="font-size:0.85rem;">
<h3 class="section-title" style="margin-bottom:0.75rem;word-break:break-all;">{{ r.name }}</h3> <thead><tr><th>Router</th><th>Req/s</th><th>Bandwidth</th><th>p95</th><th>4xx</th><th>5xx</th></tr></thead>
<table class="table" style="font-size:0.85rem;"> <tbody>
<tr> {% for r in healthy %}
<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 %}
<tr> <tr>
<td style="padding:0.2rem 0 0.2rem 0;word-break:break-all;">{{ cert.cn }}</td> <td style="word-break:break-all;">{{ r.name }}</td>
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-weight:600; <td>{{ "%.2f"|format(r.latest.request_rate) }}</td>
{% if cert.days_remaining < 7 %}color:var(--red); <td>{{ r.latest.response_bytes_rate|rate }}</td>
{% elif cert.days_remaining < 30 %}color:var(--yellow); <td class="tone-{{ status_info(status_class(r.latest.latency_p95_ms, 'latency'))[0] }}">{{ "%.0f"|format(r.latest.latency_p95_ms) }} ms</td>
{% else %}color:var(--green);{% endif %}"> <td>{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%</td>
{{ cert.days_remaining | int }}d <td>{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%</td>
</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>
</tr> </tr>
{% endfor %} {% 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> </table>
</div> </div>
{% endif %} {% endif %}
</div>
{% else %} {% else %}
<div class="card"> <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> </div>
{% endif %} {% 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;"> <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>
<span style="color:var(--text-muted);">Total</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> <span>
<span style="color:var(--text-muted);">Internal</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> <span>
<span style="color:var(--text-muted);">External</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) }}%) {{ summary.external }} ({{ "%.1f"|format(summary.external_pct) }}%)
</span> </span>
</span> </span>
@@ -5,11 +5,11 @@
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;"> <div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<div> <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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total req</span>
</div> </div>
<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) }}% {{ "%.1f" | format(summary.external_pct) }}%
</span> </span>
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">external</span> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">external</span>
@@ -19,8 +19,8 @@
{ {
label: "Req/s", label: "Req/s",
data: {{ req_rate_json | safe }}, data: {{ req_rate_json | safe }},
borderColor: "#7c5ef4", borderColor: stewardColor("--blue"),
backgroundColor: "rgba(124,94,244,0.08)", backgroundColor: stewardColor("--blue-dim"),
fill: true, fill: true,
tension: 0.3, tension: 0.3,
pointRadius: 0, pointRadius: 0,
@@ -30,7 +30,7 @@
{ {
label: "5xx %", label: "5xx %",
data: {{ error_rate_json | safe }}, data: {{ error_rate_json | safe }},
borderColor: "#e83848", borderColor: stewardColor("--red"),
fill: false, fill: false,
tension: 0.3, tension: 0.3,
pointRadius: 0, pointRadius: 0,
@@ -45,24 +45,23 @@
animation: false, animation: false,
plugins: { plugins: {
legend: { legend: {
labels: { color: "#6858a0", boxWidth: 10, font: { size: 10 } } labels: { boxWidth: 10, font: { size: 10 } }
} }
}, },
scales: { scales: {
x: { display: false }, x: { display: false },
y: { y: {
min: 0, min: 0,
grid: { color: "rgba(30,30,62,0.8)" }, ticks: { font: { size: 10 } },
ticks: { color: "#6858a0", font: { size: 10 } }, title: { display: true, text: "req/s", font: { size: 9 } },
title: { display: true, text: "req/s", color: "#6858a0", font: { size: 9 } },
}, },
y2: { y2: {
position: "right", position: "right",
min: 0, min: 0,
max: 100, max: 100,
grid: { drawOnChartArea: false }, grid: { drawOnChartArea: false },
ticks: { color: "#e83848", font: { size: 10 } }, ticks: { color: stewardColor("--red"), font: { size: 10 } },
title: { display: true, text: "5xx%", color: "#e83848", font: { size: 9 } }, 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") }} {{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
</td> </td>
<td style="padding:0.25rem 0.5rem;"> <td style="padding:0.25rem 0.5rem;">
<span style="font-size:0.75rem;padding:0.1rem 0.4rem;border-radius:3px;background: <span class="badge badge-{{ {'wlan': 'info', 'wan': 'ok', 'ids': 'crit'}.get(ev.category, 'dim') }}">{{ ev.category }}</span>
{% 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>
</td> </td>
<td style="padding:0.25rem 0.5rem;color:var(--text-dim);font-family:monospace;font-size:0.75rem;"> <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 "" }} {{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }}
+2 -7
View File
@@ -61,7 +61,7 @@
{% if rule.ruleset != ns.current_rs %} {% if rule.ruleset != ns.current_rs %}
{% set ns.current_rs = rule.ruleset %} {% set ns.current_rs = rule.ruleset %}
<tr> <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 }} {{ rule.ruleset }}
</td> </td>
</tr> </tr>
@@ -73,12 +73,7 @@
</span> </span>
</td> </td>
<td style="padding:0.2rem 0.3rem;white-space:nowrap;"> <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: <span class="badge badge-{{ {'accept': 'ok', 'drop': 'crit'}.get(rule.action, 'warn') }}">{{ rule.action }}</span>
{% 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>
</td> </td>
<td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;"> <td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;">
{% if rule.protocol %}{{ rule.protocol }}{% endif %} {% if rule.protocol %}{{ rule.protocol }}{% endif %}
+19 -55
View File
@@ -1,53 +1,20 @@
{# plugins/unifi/templates/unifi/rows.html #} {# plugins/unifi/templates/unifi/rows.html #}
{# ── WAN health bar ───────────────────────────────────────────────────────── #} {% from "_macros.html" import stat_tile, status_badge, status_dot %}
{# ── WAN health ───────────────────────────────────────────────────────────── #}
{% if latest_wan %} {% 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;"> <div class="stat-strip">
<span> {{ stat_tile("WAN", "Up" if latest_wan.is_up else "Down", state="up" if latest_wan.is_up else "down",
<span style="color:var(--text-muted);">WAN</span> hint=latest_wan.wan_ip) }}
<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>
{% if latest_wan.latency_ms is not none %} {% if latest_wan.latency_ms is not none %}
<span> {{ stat_tile("Latency", "%.0f ms"|format(latest_wan.latency_ms), state=status_class(latest_wan.latency_ms, 'latency'),
<span style="color:var(--text-muted);">Latency</span> hint=sparkline_latency|safe) }}
<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>
{% endif %} {% endif %}
{% if latest_wan.rx_bytes_rate is not none %} {% if latest_wan.rx_bytes_rate is not none %}
<span> {{ stat_tile("Download", latest_wan.rx_bytes_rate|rate, hint=sparkline_rx|safe) }}
<span style="color:var(--text-muted);">&#8595; RX</span> {{ stat_tile("Upload", latest_wan.tx_bytes_rate|rate, hint=sparkline_tx|safe) }}
<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>
{% endif %} {% endif %}
{% if latest_wan.uptime_seconds is not none %}{{ stat_tile("WAN uptime", latest_wan.uptime_seconds|duration) }}{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -63,7 +30,7 @@
</div> </div>
<div style="display:flex;gap:1.5rem;font-size:0.9rem;margin-bottom:0.75rem;font-variant-numeric:tabular-nums;"> <div style="display:flex;gap:1.5rem;font-size:0.9rem;margin-bottom:0.75rem;font-variant-numeric:tabular-nums;">
<span> <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 style="color:var(--text-muted);font-size:0.8rem;margin-left:0.2rem;">total</span>
</span> </span>
<span style="color:var(--text-muted);font-size:0.85rem;align-self:flex-end;"> <span style="color:var(--text-muted);font-size:0.85rem;align-self:flex-end;">
@@ -78,12 +45,12 @@
{# Infrastructure devices card #} {# Infrastructure devices card #}
{% if devices %} {% if devices %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;"> <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%;"> <table class="table" style="font-size:0.83rem;width:100%;">
{% for d in devices %} {% for d in devices %}
<tr> <tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;"> <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> <span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
</td> </td>
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</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 #} {# Top talkers card #}
{% if top_clients %} {% if top_clients %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;"> <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%;"> <table class="table" style="font-size:0.82rem;width:100%;">
{% for c in top_clients %} {% for c in top_clients %}
<tr> <tr>
@@ -130,7 +97,7 @@
{# WLAN SSIDs card #} {# WLAN SSIDs card #}
{% if wlan_latest %} {% if wlan_latest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;"> <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%;"> <table class="table" style="font-size:0.83rem;width:100%;">
<tr style="color:var(--text-muted);font-size:0.78rem;"> <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> <th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">SSID</th>
@@ -141,10 +108,7 @@
<tr> <tr>
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ w.ssid }}</td> <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.3rem;">{{ w.num_sta }}</td>
<td style="text-align:right;padding:0.2rem 0; <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 %}
{% if w.satisfaction >= 80 %}color:var(--green){% elif w.satisfaction >= 60 %}color:var(--yellow){% else %}color:var(--red){% endif %}
{% endif %};">
{% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %} {% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
</td> </td>
</tr> </tr>
@@ -156,19 +120,19 @@
{# Speedtest card #} {# Speedtest card #}
{% if speedtest %} {% if speedtest %}
<div class="card" style="break-inside:avoid;margin-bottom:1rem;"> <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;"> <div style="display:flex;gap:1.5rem;font-size:0.88rem;font-variant-numeric:tabular-nums;">
{% if speedtest.download_mbps is not none %} {% if speedtest.download_mbps is not none %}
<span> <span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8595; DL</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 style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span> </span>
{% endif %} {% endif %}
{% if speedtest.upload_mbps is not none %} {% if speedtest.upload_mbps is not none %}
<span> <span>
<span style="color:var(--text-muted);font-size:0.8rem;">&#8593; UL</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 style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
</span> </span>
{% endif %} {% endif %}
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="section-title" style="margin-bottom:0.75rem;"> <div class="section-title" style="margin-bottom:0.75rem;">
Active Alarms Active Alarms
{% if 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 %} {% endif %}
</div> </div>
{% if not active_alarms %} {% if not active_alarms %}
+1 -1
View File
@@ -55,7 +55,7 @@
<span class="ping-name" style="font-size:0.8rem;">Clients</span> <span class="ping-name" style="font-size:0.8rem;">Clients</span>
</div> </div>
<div style="flex:1;display:flex;gap:1rem;font-size:0.82rem;"> <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> <span style="color:var(--text-muted);">{{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e</span>
</div> </div>
</div> </div>
@@ -5,15 +5,15 @@
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;"> <div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<div> <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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total</span>
</div> </div>
<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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wireless</span>
</div> </div>
<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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wired</span>
</div> </div>
</div> </div>
@@ -10,12 +10,12 @@
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;"> <div style="display:flex;gap:1rem;margin-bottom:0.75rem;">
<div> <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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">online</span>
</div> </div>
{% if offline %} {% if offline %}
<div> <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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">offline</span>
</div> </div>
{% endif %} {% endif %}
+16 -10
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
from quart import Blueprint, render_template, request, current_app from quart import Blueprint, render_template, request, current_app
from sqlalchemy import select from sqlalchemy import select
from steward.audit.service import entity_labels
from steward.auth.middleware import require_role from steward.auth.middleware import require_role
from steward.models.audit import AuditEvent from steward.models.audit import AuditEvent
from steward.models.users import UserRole from steward.models.users import UserRole
@@ -23,16 +24,21 @@ async def list_events():
result = await db.execute(stmt) result = await db.execute(stmt)
events = result.scalars().all() events = result.scalars().all()
# Parse detail_json for display # Parse detail_json for display
parsed = [] parsed = []
for e in events: for e in events:
detail = {} detail = {}
if e.detail_json: if e.detail_json:
try: try:
detail = json.loads(e.detail_json) detail = json.loads(e.detail_json)
except (ValueError, TypeError): except (ValueError, TypeError):
detail = {"raw": e.detail_json} detail = {"raw": e.detail_json}
parsed.append({"event": e, "detail": detail}) 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( return await render_template(
"audit/list.html", "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); color: var(--gold); border-left-color: var(--gold);
background: color-mix(in srgb, var(--gold) 8%, transparent); background: color-mix(in srgb, var(--gold) 8%, transparent);
} }
/* How many alerts need attention, beside the Alerts link. */ /* A small count that needs attention: alerts beside the Alerts link, UniFi alarms. */
.nav-count { .count-badge {
margin-left: auto; padding: 0 0.45em; border-radius: var(--fs-radius-sm); 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; background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 500;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
+3 -3
View File
@@ -16,13 +16,13 @@
</div> </div>
<details open> <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 }} \ <pre style="{{ pre }}">claude mcp add --transport http steward {{ mcp_url }} \
--header "Authorization: Bearer {{ tok }}"</pre> --header "Authorization: Bearer {{ tok }}"</pre>
</details> </details>
<details style="margin-top:0.75rem;"> <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;"> <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 Add to <code>claude_desktop_config.json</code>. It bridges through
<code>mcp-remote</code>, which needs Node.js. <code>mcp-remote</code>, which needs Node.js.
@@ -40,7 +40,7 @@
</details> </details>
<details style="margin-top:0.75rem;"> <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;"> <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 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 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> <h1 class="page-title">Token created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;"> <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. Copy this token now. It can't be shown again.
</div> </div>
+1 -1
View File
@@ -42,7 +42,7 @@
{% set t = row.token %} {% set t = row.token %}
<tr {% if row.state != 'active' %}style="opacity:0.55;"{% endif %}> <tr {% if row.state != 'active' %}style="opacity:0.55;"{% endif %}>
<td> <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> <div style="font-family:ui-monospace,monospace;font-size:0.75rem;color:var(--text-muted);">{{ t.prefix }}…</div>
</td> </td>
<td><span class="badge badge-dim">{{ t.role.value }}</span></td> <td><span class="badge badge-dim">{{ t.role.value }}</span></td>
@@ -24,7 +24,7 @@
</label> </label>
{% endif %} {% endif %}
{% if variables %} {% 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 Playbook variables
</div> </div>
<p style="color:var(--text-dim);font-size:0.76rem;margin:0 0 0.6rem;"> <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> <span style="color:var(--red);" title="{{ s.last_error }}">launch error</span>
{% elif row.last_run %} {% 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)") %} {% 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> <span style="color:var(--text-dim);">· {{ s.last_run_at.strftime("%m-%d %H:%M") }}</span>
{% else %} {% else %}
<span style="color:var(--text-dim);">never</span> <span style="color:var(--text-dim);">never</span>
+15 -26
View File
@@ -1,25 +1,25 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;"> {% call page_header("Audit log", "Who changed what, newest first.") %}
<h1 class="page-title" style="margin-bottom:0;">Audit Log</h1>
<form method="get" style="display:flex;gap:0.5rem;align-items:center;"> <form method="get" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" name="action" value="{{ action_filter }}" <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;"> style="width:200px;font-size:0.85rem;">
<button type="submit" class="btn btn-ghost btn-sm">Filter</button> <button type="submit" class="btn btn-ghost btn-sm">Filter</button>
{% if action_filter %} {% if action_filter %}
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a> <a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
{% endif %} {% endif %}
</form> </form>
</div> {% endcall %}
{% if events %} {% if events %}
<div class="card-flush"> <div class="card-flush">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th style="min-width:160px;">Time (UTC)</th> <th>When</th>
<th>User</th> <th>User</th>
<th>Action</th> <th>Action</th>
<th>Entity</th> <th>Entity</th>
@@ -31,30 +31,21 @@
{% set e = item.event %} {% set e = item.event %}
{% set d = item.detail %} {% set d = item.detail %}
<tr> <tr>
<td style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:nowrap;color:var(--text-muted);"> <td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
{{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}
</td>
<td style="font-size:0.85rem;">{{ e.username }}</td> <td style="font-size:0.85rem;">{{ e.username }}</td>
<td><span class="mono">{{ e.action }}</span></td>
<td> <td>
{% set cat = e.action.split('.')[0] %} {% if item.entity %}
<span style=" <span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
font-size:0.78rem;font-family:ui-monospace,monospace; {% if item.entity.url %}<a href="{{ item.entity.url }}">{{ item.entity.label }}</a>{% else %}{{ item.entity.label }}{% endif %}
padding:0.15rem 0.4rem;border-radius:3px; {% if item.entity.gone %}<span class="field-hint">(deleted)</span>{% endif %}
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 %}; {% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% 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 %}
</td> </td>
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;"> <td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
{% if d %} {% if d %}
{% for k, v in d.items() %} {% for k, v in d.items() %}
<span style="color:var(--text-dim);">{{ k }}:</span> <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 %} {% if not loop.last %} &nbsp;·&nbsp; {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -71,8 +62,6 @@
{% endif %} {% endif %}
</p> </p>
{% else %} {% else %}
<div class="card" style="text-align:center;padding:3rem;"> <div class="card">{{ empty_state("No audit events recorded yet.") }}</div>
<p style="color:var(--text-muted);">No audit events recorded yet.</p>
</div>
{% endif %} {% endif %}
{% endblock %} {% 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='fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
@@ -62,7 +63,7 @@
<div class="nav-group"> <div class="nav-group">
<div class="nav-group-label">Monitoring</div> <div class="nav-group-label">Monitoring</div>
<a href="/monitors/" {% if _p.startswith('/monitors') %}class="active" aria-current="page"{% endif %}>Monitors</a> <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>
<div class="nav-group"> <div class="nav-group">
<div class="nav-group-label">Automation</div> <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; <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;"> padding:0.7rem 0.85rem;cursor:pointer;list-style:none;user-select:none;">
<div style="flex:1;min-width:0;"> <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 style="font-size:0.74rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
</div> </div>
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span> <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="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;"> <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 %} {% if dash.is_default %}
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span> <span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
{% endif %} {% endif %}
@@ -36,7 +36,7 @@
<details style="position:relative;"> <details style="position:relative;">
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary> <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() }} <form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">{{ csrf_field() }}
<input type="text" name="name" value="{{ dash.name }}" <input type="text" name="name" value="{{ dash.name }}"
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required> 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="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;"> <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 %} {% if dash.owner_id is none %}
<span class="badge badge-dim" style="font-size:0.68rem;">System</span> <span class="badge badge-dim" style="font-size:0.68rem;">System</span>
{% endif %} {% 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 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="display:flex;align-items:center;gap:0.75rem;">
<div style="flex:1;min-width:0;"> <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" }} {{ token.label or "Unnamed link" }}
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %} {% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
</div> </div>
@@ -7,7 +7,7 @@
<h1 class="page-title">Share Link Created</h1> <h1 class="page-title">Share Link Created</h1>
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;"> <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 Copy this link now — it cannot be shown again
</div> </div>
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fonts.css') }}"> <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='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='steward.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 {# 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. #} other page; only the read-only page's own frame and its grid live here. #}
<style> <style>
+1 -1
View File
@@ -33,7 +33,7 @@
{% set hs = host_status.get(host.id) %} {% set hs = host_status.get(host.id) %}
{% set ut = uptime.get(host.id, {}) %} {% set ut = uptime.get(host.id, {}) %}
<tr> <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="color:var(--text-muted);">{{ host.address }}</td>
<td style="text-align:center;"> <td style="text-align:center;">
{% if not hs %} {% if not hs %}
+2 -2
View File
@@ -16,9 +16,9 @@
style="flex-shrink:0;" style="flex-shrink:0;"
title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span> title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span>
{% if session.user_id %} {% 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 %} {% 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 %} {% endif %}
</div> </div>
{# Line 2 — monitors + agent metrics; wraps instead of truncating #} {# 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;"> <div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
{% if up %} {% 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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span></div>
{% endif %} {% endif %}
{% if down %} {% 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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span></div>
{% endif %} {% endif %}
{% if pending %} {% 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> <span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span></div>
{% endif %} {% endif %}
</div> </div>
@@ -12,7 +12,7 @@
<div id="ansible-source-view-{{ idx }}" <div id="ansible-source-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;"> 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; 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 %}; 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 %};"> color:{% if src.type == 'git' %}var(--accent){% else %}var(--text-muted){% endif %};">
@@ -20,7 +20,7 @@
</span> </span>
<div style="flex:1;min-width:0;"> <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; <div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{% if src.type == 'git' %} {% if src.type == 'git' %}
@@ -136,7 +136,7 @@
<details class="card" style="padding:0;overflow:hidden;"> <details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem; <summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;"> 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> <span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary> </summary>
@@ -20,7 +20,7 @@
<div style="display:flex;align-items:flex-start;gap:1rem;"> <div style="display:flex;align-items:flex-start;gap:1rem;">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;"> <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> <span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
{% if update_available %} {% if update_available %}
@@ -12,7 +12,7 @@
<div id="plugin-repo-view-{{ idx }}" <div id="plugin-repo-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;"> style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<div style="flex:1;min-width:0;"> <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; <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> white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
</div> </div>
@@ -65,7 +65,7 @@
<details class="card" style="padding:0;overflow:hidden;"> <details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem; <summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;"> 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> <span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary> </summary>
<form hx-post="/settings/plugins/repos/add" <form hx-post="/settings/plugins/repos/add"
@@ -50,7 +50,7 @@
name="plugin.{{ plugin._dir }}.enabled" name="plugin.{{ plugin._dir }}.enabled"
id="plugin-{{ plugin._dir }}-enabled" id="plugin-{{ plugin._dir }}-enabled"
{% if plugin._enabled %}checked{% endif %}> {% 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 Enable this plugin
</label> </label>
</div> </div>
+2 -2
View File
@@ -22,7 +22,7 @@
{% endif %} {% endif %}
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;"> <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> <span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.get('version', '?') }}</span>
{% if fail_reason %} {% if fail_reason %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px; <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> title="{{ orphan.reason or 'Configured but not installed' }}"></span>
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;"> <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 %} {% if orphan.reason %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px; <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> 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> <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;"> <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="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 }}" <input type="number" name="{{ warn_field }}" step="{{ step }}"
value="{{ settings[warn_key] }}" style="max-width:110px;"> value="{{ settings[warn_key] }}" style="max-width:110px;">
</span> </span>
<span style="display:flex;align-items:center;gap:0.4rem;"> <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 }}" <input type="number" name="{{ crit_field }}" step="{{ step }}"
value="{{ settings[crit_key] }}" style="max-width:110px;"> value="{{ settings[crit_key] }}" style="max-width:110px;">
</span> </span>
+1 -1
View File
@@ -31,7 +31,7 @@
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span> <span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
{% endif %} {% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span> <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> </div>
{% endfor %} {% endfor %}
{% if problems|length > 8 %} {% 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/"): async with app.test_request_context("/alerts/"):
session["user_id"], session["user_role"] = "u1", "viewer" session["user_id"], session["user_role"] = "u1", "viewer"
page = await render_template("alerts/list.html", active=[], rules=[], operators=[]) 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): 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=[]) fragment = await render_template("alerts/list.html", active=[], rules=[], operators=[])
async with app.test_request_context("/alerts/"): async with app.test_request_context("/alerts/"):
logged_out = await render_template("alerts/list.html", active=[], rules=[], operators=[]) 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). # 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 = 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(): def _sheets():
@@ -45,11 +45,17 @@ def hex_colours(text: str) -> list[str]:
return _HEX.findall(_COMMENTS.sub("", text)) 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(): def test_the_guards_can_fail():
assert undefined_vars([":root { --a: 1; } .x { color: var(--a); background: var(--b); }"]) == {"--b"} 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>' 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 len(hex_colours(page)) == 2
assert hex_colours("{# color:#fff #}<a href=\"#abc\">") == [] 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(): 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) 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(): def test_hex_colours_in_templates_only_go_down():
count = sum(len(hex_colours(p.read_text())) for p in _templates()) count = sum(len(hex_colours(p.read_text())) for p in _templates())
assert count <= HEX_CEILING, ( assert count <= HEX_CEILING, (