feat(plugins): Traefik problems first, UniFi/SNMP/fleet on shared shapes, audit names its entities, templates on tokens (#5126)
Traefik: routers are graded from their latest scrape (router_state: crit on any real 5xx share, warn on p95 past the latency critical cutoff or a high 4xx share). Failing and slow routers lead as cards that say why; healthy ones fold into one table that stays open or shut across polls. Proxy counts are stat tiles; certificates are graded by Settings -> Thresholds (tls_days). UniFi: WAN health is a stat strip (latency graded by the thresholds), devices carry status dots, event categories and firewall actions are badges, the alarm count reuses .count-badge (renamed from .nav-count). SNMP: each device wears a status badge and its readings are stat tiles. Agent fleet: the table comes first, with a Status column (Reporting / Silent / Never reported, by the host page's stale_after_seconds), silent agents first, hosts linked, last seen as |ago. Audit: entries name their entity (one query per type, steward/audit/ service.py) and link it; something deleted since keeps the name its entry recorded and says "(deleted)". Tokens: charts read colours through chart-colors.js (stewardColor resolves a token to the rgb the browser computed; Chart.js defaults take the page's text and border colours), so host, history, Traefik and SNMP charts drop their hex. Template hex colours reach 0 (HEX_CEILING = 0), the last rgba pills go, and heavy weights (600/700/bold, 60 of them) become 500, the house pair being 400/500; a guard keeps templates there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
{%- if s.crash_looping or s.failures_24h or (e and s.state != 'healthy') %}
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:baseline;gap:0.2rem 0.6rem;font-size:0.78rem;color:var(--text-muted);{{ style or '' }}">
|
||||
{% if s.crash_looping %}
|
||||
<span style="color:var(--red);font-weight:600;">Crash-looping · {{ s.failures_1h }} tasks failed in the last hour</span>
|
||||
<span style="color:var(--red);font-weight:500;">Crash-looping · {{ s.failures_1h }} tasks failed in the last hour</span>
|
||||
{% elif s.failures_24h %}
|
||||
<span style="color:var(--orange);">{{ s.failures_24h }} task{{ 's' if s.failures_24h != 1 }} failed in the last 24h</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
{% if container is none %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="font-weight:600;margin-bottom:0.4rem;">Container not found</div>
|
||||
<div style="font-weight:500;margin-bottom:0.4rem;">Container not found</div>
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No container named <code>{{ name }}</code> is currently reported for this host.
|
||||
It may have been removed, or the host agent hasn't reported recently.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{% if host_groups %}
|
||||
{% for g in host_groups %}
|
||||
<div style="margin-bottom:2rem;">
|
||||
<div style="font-weight:600;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}</div>
|
||||
<div style="font-weight:500;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}</div>
|
||||
|
||||
{# ── Summary stats ────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{% if swarm_services %}
|
||||
<div class="card-flush" style="margin-bottom:1.5rem;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
|
||||
<span style="font-weight:600;font-size:0.95rem;">Swarm services</span>
|
||||
<span style="font-weight:500;font-size:0.95rem;">Swarm services</span>
|
||||
<a href="/plugins/docker/swarm" style="font-size:0.78rem;color:var(--text-muted);">Topology →</a>
|
||||
</div>
|
||||
<div style="padding:0.5rem 0.75rem;display:grid;gap:0.85rem;">
|
||||
@@ -55,7 +55,7 @@
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:0.55rem;flex-wrap:wrap;margin-bottom:0.35rem;">
|
||||
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<span style="font-weight:600;font-size:0.9rem;">{{ s.name }}</span>
|
||||
<span style="font-weight:500;font-size:0.9rem;">{{ s.name }}</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-dim);">{{ s.mode }}</span>
|
||||
<span class="tone-{{ 'ok' if s.state == 'healthy' else ('warn' if s.state == 'degraded' else 'crit') }}" style="font-size:0.78rem;font-variant-numeric:tabular-nums;">
|
||||
{{ s.running }}/{{ s.desired }} running
|
||||
@@ -91,9 +91,9 @@
|
||||
<div class="card-flush" style="margin-bottom:1.5rem;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
|
||||
{% if g.host %}
|
||||
<a href="/hosts/{{ g.host.id }}" style="font-weight:600;font-size:0.95rem;color:inherit;text-decoration:none;">{{ g.host.name }}</a>
|
||||
<a href="/hosts/{{ g.host.id }}" style="font-weight:500;font-size:0.95rem;color:inherit;text-decoration:none;">{{ g.host.name }}</a>
|
||||
{% else %}
|
||||
<span style="font-weight:600;font-size:0.95rem;">{{ g.host_name }}</span>
|
||||
<span style="font-weight:500;font-size:0.95rem;">{{ g.host_name }}</span>
|
||||
{% endif %}
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);">
|
||||
{{ g.running }} running{% if g.stopped %} · {{ g.stopped }} stopped{% endif %}
|
||||
@@ -112,7 +112,7 @@
|
||||
<tbody>
|
||||
{% for sub in g.subgroups %}
|
||||
{% if g.grouped and sub.label %}
|
||||
<tr><td colspan="5" style="padding:0.4rem 0.75rem 0.2rem;font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-weight:600;">{{ sub.label }}</td></tr>
|
||||
<tr><td colspan="5" style="padding:0.4rem 0.75rem 0.2rem;font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;font-weight:500;">{{ sub.label }}</td></tr>
|
||||
{% endif %}
|
||||
{% for item in sub.containers %}
|
||||
{% set c = item.container %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{% for g in swarms %}
|
||||
<div style="margin-bottom:2rem;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:0.95rem;">Swarm</span>
|
||||
<span style="font-weight:500;font-size:0.95rem;">Swarm</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);">
|
||||
reported by {{ g.managers | length }} manager{{ 's' if g.managers | length != 1 }} · {{ g.managers | join(", ") }}
|
||||
</span>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
<div style="display:flex;gap:1.25rem;margin-bottom:0.65rem;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ running_count }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ running_count }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">running</span>
|
||||
</div>
|
||||
<div title="Containers that died or were OOM-killed in the last 24h, plus swarm tasks that failed (a task Swarm replaced is not a failure)">
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:{% if failed_24h %}var(--red){% else %}var(--text-muted){% endif %};">{{ failed_24h }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:{% if failed_24h %}var(--red){% else %}var(--text-muted){% endif %};">{{ failed_24h }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">failed (24h)</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;">
|
||||
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<span style="font-weight:500;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.name }}</span>
|
||||
{% if s.crash_looping %}<span style="color:var(--red);font-size:0.7rem;font-weight:600;flex-shrink:0;" title="{{ s.failures_1h }} tasks failed in the last hour{% if s.last_error %}: {{ s.last_error.error or s.last_error.message or s.last_error.state }}{% endif %}">crash-looping</span>{% endif %}
|
||||
{% if s.crash_looping %}<span style="color:var(--red);font-size:0.7rem;font-weight:500;flex-shrink:0;" title="{{ s.failures_1h }} tasks failed in the last hour{% if s.last_error %}: {{ s.last_error.error or s.last_error.message or s.last_error.state }}{% endif %}">crash-looping</span>{% endif %}
|
||||
<span style="color:var(--text-muted);font-family:ui-monospace,monospace;font-size:0.74rem;margin-left:auto;flex-shrink:0;">{{ s.running }}/{{ s.desired }}</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.25rem;margin:0.2rem 0 0 1rem;">
|
||||
|
||||
@@ -359,6 +359,28 @@ async def agent_source():
|
||||
return Response(body, mimetype="text/x-python")
|
||||
|
||||
|
||||
_FLEET_ORDER = {"stale": 0, "pending": 1, "up": 2}
|
||||
|
||||
|
||||
def fleet_state(reg, now: datetime, stale_after: int) -> str:
|
||||
"""An agent as the fleet table shows it: "up" while it reports, "stale"
|
||||
once its last report is older than stale_after, "pending" if it never has."""
|
||||
if reg.last_seen_at is None:
|
||||
return "pending"
|
||||
return "stale" if (now - reg.last_seen_at).total_seconds() > stale_after else "up"
|
||||
|
||||
|
||||
def fleet_rows(regs, hosts_by_id: dict, now: datetime, stale_after: int) -> list[dict]:
|
||||
"""The fleet table: silent agents first, then never-reported, then live,
|
||||
each by host name."""
|
||||
rows = []
|
||||
for r in regs:
|
||||
host = hosts_by_id.get(r.host_id)
|
||||
rows.append({"reg": r, "host": host, "name": host.name if host else r.host_id,
|
||||
"state": fleet_state(r, now, stale_after)})
|
||||
return sorted(rows, key=lambda x: (_FLEET_ORDER[x["state"]], x["name"].lower()))
|
||||
|
||||
|
||||
def _stale_after_seconds() -> int:
|
||||
return int(
|
||||
current_app.config.get("PLUGINS", {})
|
||||
@@ -803,11 +825,10 @@ async def fleet():
|
||||
managed_key_set = bool(
|
||||
(current_app.config.get("ANSIBLE", {}).get("ssh_public_key") or "").strip())
|
||||
|
||||
now, stale_after = datetime.now(timezone.utc), _stale_after_seconds()
|
||||
return await render_template(
|
||||
"settings_list.html",
|
||||
registrations=[
|
||||
{"reg": r, "host": hosts_by_id.get(r.host_id)} for r in regs
|
||||
],
|
||||
registrations=fleet_rows(regs, hosts_by_id, now, stale_after),
|
||||
new_token=new_token,
|
||||
install_url=install_url,
|
||||
ansible_available=ansible_available,
|
||||
|
||||
@@ -54,30 +54,30 @@
|
||||
interaction: { mode: "index", intersect: false },
|
||||
elements: { point: { radius: 0 } },
|
||||
scales: {
|
||||
x: { type: "linear", ticks: { callback: (v) => fmtTime(v), maxTicksLimit: 8, color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
||||
y: { beginAtZero: true, max: ymax, ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
||||
x: { type: "linear", ticks: { callback: (v) => fmtTime(v), maxTicksLimit: 8, font: { size: 10 } } },
|
||||
y: { beginAtZero: true, max: ymax, ticks: { font: { size: 10 } } },
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
|
||||
legend: { labels: { boxWidth: 10, font: { size: 11 } } },
|
||||
tooltip: { callbacks: { title: (items) => items.length ? fmtTime(items[0].parsed.x) : "" } },
|
||||
},
|
||||
});
|
||||
const defs = {
|
||||
"chart-util": { ymax: 100, ds: [
|
||||
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
|
||||
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
|
||||
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: "#c87840" },
|
||||
{ key: "cpu_pct", label: "CPU %", color: stewardColor("--gold") },
|
||||
{ key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") },
|
||||
{ key: "disk_used_pct_worst", label: "Disk % (worst)", color: stewardColor("--yellow") },
|
||||
] },
|
||||
"chart-net": { ymax: undefined, ds: [
|
||||
{ key: "net_rx_bps", label: "Net RX", color: "#4aa86a" },
|
||||
{ key: "net_tx_bps", label: "Net TX", color: "#c84048" },
|
||||
{ key: "disk_read_bps", label: "Disk read", color: "#6aa0d0" },
|
||||
{ key: "disk_write_bps", label: "Disk write", color: "#c8a840" },
|
||||
{ key: "net_rx_bps", label: "Net RX", color: stewardColor("--green") },
|
||||
{ key: "net_tx_bps", label: "Net TX", color: stewardColor("--red") },
|
||||
{ key: "disk_read_bps", label: "Disk read", color: stewardColor("--blue") },
|
||||
{ key: "disk_write_bps", label: "Disk write", color: stewardColor("--gold") },
|
||||
] },
|
||||
"chart-pressure": { ymax: undefined, ds: [
|
||||
{ key: "load_1m", label: "Load 1m", color: "#c8a840" },
|
||||
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: "#c84048" },
|
||||
{ key: "temp_c_max", label: "Temp °C", color: "#c87840" },
|
||||
{ key: "load_1m", label: "Load 1m", color: stewardColor("--gold") },
|
||||
{ key: "psi_mem_some_avg10", label: "Mem PSI %", color: stewardColor("--red") },
|
||||
{ key: "temp_c_max", label: "Temp °C", color: stewardColor("--yellow") },
|
||||
] },
|
||||
};
|
||||
const charts = {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% from "_macros.html" import crumbs, status_badge %}
|
||||
{% block title %}Agent fleet — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Agent fleet", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
@@ -21,6 +21,45 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-flush" style="margin-bottom:1.5rem;">
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th><th>Host</th><th>Last seen</th><th>Agent version</th><th>Reported IP</th><th>Distro</th>
|
||||
<th class="td-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in registrations %}
|
||||
<tr>
|
||||
<td>{{ status_badge(item.state, {'up': 'Reporting', 'stale': 'Silent', 'pending': 'Never reported'}[item.state]) }}</td>
|
||||
<td>{% if item.host %}<a href="/hosts/{{ item.host.id }}">{{ item.host.name }}</a>{% else %}<span class="mono">{{ item.reg.host_id }}</span>{% endif %}</td>
|
||||
<td>{% if item.reg.last_seen_at %}<span title="{{ item.reg.last_seen_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ item.reg.last_seen_at|ago }}</span>{% else %}never{% endif %}</td>
|
||||
<td>{{ item.reg.agent_version or "—" }}</td>
|
||||
<td class="mono">{{ item.reg.host_ip or "—" }}</td>
|
||||
<td>{{ item.reg.distro or "—" }}</td>
|
||||
<td class="td-actions">
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
|
||||
style="display:inline;"
|
||||
data-confirm="Rotate the token for {{ item.name }}? The existing agent will stop working until reinstalled with the new token.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Rotate token</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
|
||||
style="display:inline;"
|
||||
data-confirm="Delete the agent registration for {{ item.name }}? The agent will be unable to report metrics until re-registered.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="empty">No hosts registered. Add one below.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="/plugins/host_agent/fleet/add-host"
|
||||
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
|
||||
@@ -137,39 +176,4 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th><th>Reported IP</th><th>Agent version</th><th>Distro</th>
|
||||
<th>Last seen</th><th class="td-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in registrations %}
|
||||
<tr>
|
||||
<td>{{ item.host.name if item.host else item.reg.host_id }}</td>
|
||||
<td>{{ item.reg.host_ip or "—" }}</td>
|
||||
<td>{{ item.reg.agent_version or "—" }}</td>
|
||||
<td>{{ item.reg.distro or "—" }}</td>
|
||||
<td>{{ item.reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") if item.reg.last_seen_at else "never" }}</td>
|
||||
<td class="td-actions">
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/rotate-token"
|
||||
style="display:inline;"
|
||||
data-confirm="Rotate the token for this host? The existing agent will stop working until reinstalled with the new token.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Rotate token</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
|
||||
style="display:inline;"
|
||||
data-confirm="Delete this host registration? The agent will be unable to report metrics until re-registered.">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="empty">No hosts registered. Add one above.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,16 +16,14 @@
|
||||
toggle that re-requests this widget without entering edit mode. #}
|
||||
<div style="flex:0 0 auto;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;font-size:0.75rem;color:var(--text-muted);margin-bottom:0.3rem;">
|
||||
{% if session.user_id %}
|
||||
<a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a>
|
||||
<a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a>
|
||||
{% else %}
|
||||
<strong style="color:var(--text);">{{ host.name }}</strong>
|
||||
{% endif %}
|
||||
<span class="hist-range" style="margin-left:auto;display:inline-flex;border:1px solid var(--border-mid);border-radius:5px;overflow:hidden;">
|
||||
<span class="hist-range" style="margin-left:auto;display:inline-flex;gap:0.25rem;">
|
||||
{% for opt in [1, 6, 24] %}
|
||||
<button type="button" onclick="histRange({{ wid|int }}, {{ opt|int }})"
|
||||
style="border:0;cursor:pointer;padding:0.1rem 0.45rem;font-size:0.72rem;
|
||||
background:{% if opt == hours %}var(--accent){% else %}transparent{% endif %};
|
||||
color:{% if opt == hours %}#fff{% else %}var(--text-muted){% endif %};">{{ opt }}h</button>
|
||||
class="range-btn{% if opt == hours %} active{% endif %}">{{ opt }}h</button>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -57,9 +55,9 @@ window.histRange = function (wid, hours) {
|
||||
return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2);
|
||||
};
|
||||
var ds = [
|
||||
{ key: "cpu_pct", label: "CPU %", color: "#c8a840" },
|
||||
{ key: "mem_used_pct", label: "Mem %", color: "#4aa86a" },
|
||||
{ key: "disk_root", label: "Disk / %", color: "#c87840" },
|
||||
{ key: "cpu_pct", label: "CPU %", color: stewardColor("--gold") },
|
||||
{ key: "mem_used_pct", label: "Mem %", color: stewardColor("--green") },
|
||||
{ key: "disk_root", label: "Disk / %", color: stewardColor("--yellow") },
|
||||
];
|
||||
new Chart(el.getContext("2d"), {
|
||||
type: "line",
|
||||
@@ -75,14 +73,14 @@ window.histRange = function (wid, hours) {
|
||||
interaction: { mode: "index", intersect: false },
|
||||
elements: { point: { radius: 0 } },
|
||||
scales: {
|
||||
x: { type: "linear", ticks: { callback: function (v) { return fmtTime(v); }, maxTicksLimit: 6, color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
||||
x: { type: "linear", ticks: { callback: function (v) { return fmtTime(v); }, maxTicksLimit: 6, font: { size: 10 } } },
|
||||
// Fixed 0–{{ y_max }} ceiling (server snaps the data peak up to the next
|
||||
// 10% band). Steady across refreshes — auto-scaling made the zoom jump on
|
||||
// every poll — while a banded ceiling still keeps the lines filling the panel.
|
||||
y: { min: 0, max: {{ y_max }}, ticks: { color: "#8a8a92", font: { size: 10 } }, grid: { color: "#30303a" } },
|
||||
y: { min: 0, max: {{ y_max }}, ticks: { font: { size: 10 } } },
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { color: "#b8b8b0", boxWidth: 10, font: { size: 11 } } },
|
||||
legend: { labels: { boxWidth: 10, font: { size: 11 } } },
|
||||
tooltip: { callbacks: { title: function (items) { return items.length ? fmtTime(items[0].parsed.x) : ""; } } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% macro metric_cell(label, value, fmt, svg, style="", title="") %}
|
||||
<div style="min-width:62px;flex:0 0 auto;" title="{{ title }}">
|
||||
<div style="font-size:0.62rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.03em;">{{ label }}</div>
|
||||
<div style="font-weight:600;font-size:0.8rem;font-variant-numeric:tabular-nums;{{ style }}">
|
||||
<div style="font-weight:500;font-size:0.8rem;font-variant-numeric:tabular-nums;{{ style }}">
|
||||
{% if value is not none %}{{ fmt|format(value) }}{% else %}—{% endif %}
|
||||
</div>
|
||||
<div style="line-height:0;height:16px;">{{ svg | safe }}</div>
|
||||
@@ -34,9 +34,9 @@
|
||||
title="{% if stale %}stale / no recent data{% else %}warns at CPU/memory ≥90% or any disk mount ≥90% (the 'disk /' figure shows root only){% endif %}"></span>
|
||||
<span style="min-width:0;">
|
||||
{% if session.user_id %}
|
||||
<a href="/hosts/{{ r.host.id }}" style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</a>
|
||||
<a href="/hosts/{{ r.host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</a>
|
||||
{% else %}
|
||||
<span style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span>
|
||||
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ r.host.name }}</span>
|
||||
{% endif %}
|
||||
<span style="display:block;font-size:0.7rem;color:var(--text-dim);">
|
||||
{{ r.reg.last_seen_at.strftime("%H:%M:%S") if r.reg.last_seen_at else "never" }}
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
|
||||
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
||||
{% for h in [1, 6, 24, 168] %}
|
||||
<a href="?hours={{ h }}"
|
||||
class="btn btn-ghost"
|
||||
style="font-size:0.78rem;padding:0.2rem 0.6rem;{% if hours == h %}background:var(--accent);color:#fff;border-color:var(--accent);{% endif %}">
|
||||
<a href="?hours={{ h }}" class="range-btn{% if hours == h %} active{% endif %}"{% if hours == h %} aria-current="true"{% endif %}>
|
||||
{% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
@@ -27,7 +25,8 @@
|
||||
(function(){
|
||||
const raw = {{ history_json | safe }};
|
||||
const labels = {{ oid_labels | tojson }};
|
||||
const palette = ["#6060c0","#e07040","#40b080","#c040c0","#4080e0","#e0c040","#c08040"];
|
||||
// Series colours from the tokens, in an order that keeps neighbours apart.
|
||||
const palette = ["--blue", "--yellow", "--green", "--red", "--gold", "--orange", "--text-muted"].map(stewardColor);
|
||||
|
||||
// Collect all unique timestamps, sorted
|
||||
const tsSet = new Set();
|
||||
@@ -66,12 +65,10 @@
|
||||
responsive: true,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { position: "top", labels: { color: "#aaa", boxWidth: 12 } },
|
||||
tooltip: { backgroundColor: "#1e1e2e", titleColor: "#ccc", bodyColor: "#aaa" },
|
||||
legend: { position: "top", labels: { boxWidth: 12 } },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: "#888", maxTicksLimit: 12 }, grid: { color: "#2a2a3a" } },
|
||||
y: { ticks: { color: "#888" }, grid: { color: "#2a2a3a" } },
|
||||
x: { ticks: { maxTicksLimit: 12 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% for device in devices %}
|
||||
<div style="{% if not loop.last %}margin-bottom:1rem;{% endif %}">
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:0.92rem;">{{ device.name }}</span>
|
||||
<span style="font-weight:500;font-size:0.92rem;">{{ device.name }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-dim);font-family:ui-monospace,monospace;">{{ device.host }}</span>
|
||||
{% if device.bound %}
|
||||
<span title="Explicitly bound to this host (host_id / steward_host)"
|
||||
@@ -32,7 +32,7 @@
|
||||
<div style="background:var(--bg-elevated);border-radius:5px;padding:0.5rem 0.7rem;border:1px solid var(--border);">
|
||||
<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</div>
|
||||
{% if val is not none %}
|
||||
<div style="font-size:1rem;font-weight:600;font-variant-numeric:tabular-nums;">
|
||||
<div style="font-size:1rem;font-weight:500;font-variant-numeric:tabular-nums;">
|
||||
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.72rem;color:var(--text-muted);">M</span>
|
||||
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.72rem;color:var(--text-muted);">K</span>
|
||||
{% else %}{{ "%.4g"|format(val) }}{% endif %}
|
||||
|
||||
@@ -1,56 +1,39 @@
|
||||
{# plugins/snmp/templates/snmp/index.html #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
|
||||
{% block title %}SNMP — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin:0;">SNMP Devices</h1>
|
||||
<span style="font-size:0.8rem;color:var(--text-dim);">{{ devices|length }} device(s) configured</span>
|
||||
</div>
|
||||
{{ page_header("SNMP devices", devices|length ~ " device" ~ ("s" if devices|length != 1 else "") ~ " configured") }}
|
||||
|
||||
{% if not devices %}
|
||||
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;">
|
||||
No SNMP devices configured. Add devices to <code>plugin.yaml</code>.
|
||||
</div>
|
||||
<div class="card">{{ empty_state("No SNMP devices configured. Add them to the snmp plugin's plugin.yaml.") }}</div>
|
||||
{% else %}
|
||||
|
||||
{% for device in devices %}
|
||||
<div class="card" style="margin-bottom:1rem;">
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;">
|
||||
<h2 style="margin:0;font-size:1rem;font-weight:600;">{{ device.name }}</h2>
|
||||
<span style="font-size:0.78rem;color:var(--text-dim);">{{ device.host }}</span>
|
||||
{% if device.readings %}
|
||||
<span style="font-size:0.75rem;color:var(--green);margin-left:auto;">● reachable</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.75rem;color:var(--text-dim);margin-left:auto;">○ no data yet</span>
|
||||
{% endif %}
|
||||
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-ghost" style="font-size:0.78rem;padding:0.2rem 0.6rem;">History</a>
|
||||
<section class="card" aria-labelledby="snmp-{{ loop.index }}">
|
||||
<div class="host-health-head">
|
||||
{{ status_badge('up', 'Reachable') if device.readings else status_badge('pending', 'No data yet') }}
|
||||
<h2 id="snmp-{{ loop.index }}" class="section-title" style="margin:0;">{{ device.name }}</h2>
|
||||
<span class="field-hint mono">{{ device.host }}</span>
|
||||
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-sm btn-ghost" style="margin-left:auto;">History</a>
|
||||
</div>
|
||||
|
||||
{% if device.readings %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.75rem;">
|
||||
<div class="stat-strip" style="margin-bottom:0;">
|
||||
{% for oid_cfg in device.oids %}
|
||||
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
|
||||
{% set val = device.readings.get(label) %}
|
||||
<div style="background:var(--bg-elevated);border-radius:6px;padding:0.6rem 0.9rem;">
|
||||
<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:monospace;">{{ label }}</div>
|
||||
{% if val is not none %}
|
||||
<div style="font-size:1.1rem;font-weight:600;font-variant-numeric:tabular-nums;">
|
||||
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.75rem;color:var(--text-muted);">M</span>
|
||||
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.75rem;color:var(--text-muted);">K</span>
|
||||
{% else %}{{ "%.4g"|format(val) }}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="font-size:0.85rem;color:var(--text-dim);">—</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ stat_tile(label,
|
||||
"—" if val is none else
|
||||
("%.2fM"|format(val / 1000000)) if val >= 1000000 else
|
||||
("%.1fK"|format(val / 1000)) if val >= 1000 else
|
||||
"%.4g"|format(val)) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin:0;">
|
||||
No readings yet — waiting for next poll cycle.
|
||||
</p>
|
||||
<p class="field-hint">No readings yet; waiting for the next poll.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.models.users import UserRole
|
||||
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds, subsample
|
||||
from steward.core.formatting import status_class
|
||||
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric
|
||||
|
||||
traefik_bp = Blueprint("traefik", __name__, template_folder="templates")
|
||||
@@ -36,6 +37,36 @@ def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
|
||||
)
|
||||
|
||||
|
||||
# A router serving any real share of 5xx is failing; a 4xx share above this
|
||||
# is worth a look (clients hitting missing routes, auth failures).
|
||||
ERROR_5XX_PCT = 0.1
|
||||
ERROR_4XX_PCT = 1.0
|
||||
_GRADE_ORDER = {"crit": 0, "warn": 1, "ok": 2}
|
||||
|
||||
|
||||
def router_state(latest, thresholds: dict) -> tuple[str, str | None]:
|
||||
"""(grade, why) for a router's latest scrape: crit while it serves 5xx,
|
||||
warn on a p95 past the latency critical cutoff (Settings → Thresholds)
|
||||
or a high 4xx share, otherwise ok with nothing to say."""
|
||||
if (latest.error_rate_5xx_pct or 0) > ERROR_5XX_PCT:
|
||||
return "crit", f"{latest.error_rate_5xx_pct:.1f}% of responses are 5xx"
|
||||
p95 = latest.latency_p95_ms
|
||||
if p95 is not None and status_class(p95, "latency", thresholds) == "crit":
|
||||
return "warn", f"p95 latency is {p95:.0f} ms"
|
||||
if (latest.error_rate_4xx_pct or 0) > ERROR_4XX_PCT:
|
||||
return "warn", f"{latest.error_rate_4xx_pct:.1f}% of responses are 4xx"
|
||||
return "ok", None
|
||||
|
||||
|
||||
def split_routers(routers: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
"""(problems, healthy): failing routers before slow or noisy ones, each
|
||||
group by name; healthy routers by name."""
|
||||
problems = sorted((r for r in routers if r["state"] != "ok"),
|
||||
key=lambda r: (_GRADE_ORDER[r["state"]], r["name"].lower()))
|
||||
healthy = sorted((r for r in routers if r["state"] == "ok"), key=lambda r: r["name"].lower())
|
||||
return problems, healthy
|
||||
|
||||
|
||||
@traefik_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
@@ -59,6 +90,7 @@ async def rows():
|
||||
"""HTMX fragment: service cards with sparklines scoped to selected time range."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
now = datetime.now(timezone.utc)
|
||||
thresholds = current_app.config.get("THRESHOLDS", {})
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
# All known routers
|
||||
@@ -103,9 +135,12 @@ async def rows():
|
||||
if not latest:
|
||||
continue
|
||||
|
||||
state, why = router_state(latest, thresholds)
|
||||
router_data.append({
|
||||
"name": router,
|
||||
"latest": latest,
|
||||
"state": state,
|
||||
"why": why,
|
||||
"sparkline_req": _sparkline([r.request_rate for r in history]),
|
||||
"sparkline_p95": _sparkline([r.latency_p95_ms for r in history]),
|
||||
"sparkline_5xx": _sparkline([r.error_rate_5xx_pct for r in history]),
|
||||
@@ -136,9 +171,13 @@ async def rows():
|
||||
"days_remaining": days,
|
||||
})
|
||||
|
||||
problems, healthy = split_routers(router_data)
|
||||
return await render_template(
|
||||
"traefik/rows.html",
|
||||
router_data=router_data,
|
||||
problems=problems,
|
||||
healthy=healthy,
|
||||
error_4xx=ERROR_4XX_PCT,
|
||||
error_5xx=ERROR_5XX_PCT,
|
||||
global_stat=global_stat,
|
||||
certs=certs,
|
||||
range_key=range_key,
|
||||
|
||||
@@ -1,124 +1,104 @@
|
||||
{# plugins/traefik/templates/traefik/rows.html #}
|
||||
|
||||
{# ── Global stats bar ──────────────────────────────────────────────────────── #}
|
||||
{% if global_stat %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;">
|
||||
|
||||
{% if global_stat.open_conns_total is not none %}
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">Open conns</span>
|
||||
<span style="margin-left:0.4rem;font-weight:600;">{{ global_stat.open_conns_total | int }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{# traefik/rows.html — HTMX fragment for the Traefik page: routers that need
|
||||
attention first (routes.router_state), the proxy's own counts, the healthy
|
||||
routers folded into one table, then certificates by soonest expiry. #}
|
||||
{% from "_macros.html" import stat_tile, status_badge %}
|
||||
|
||||
{# ── Counts ───────────────────────────────────────────────────────────────── #}
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("Needs attention", problems|length, state="crit" if problems else "inactive") }}
|
||||
{{ stat_tile("Healthy routers", healthy|length, state="up" if healthy else "inactive") }}
|
||||
{% if global_stat %}
|
||||
{% if global_stat.config_last_reload_success is not none %}
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">Config</span>
|
||||
{% if global_stat.config_last_reload_success == 1.0 %}
|
||||
<span style="margin-left:0.4rem;color:var(--green);">✓ OK</span>
|
||||
{% else %}
|
||||
<span style="margin-left:0.4rem;color:var(--red);">✗ failed</span>
|
||||
{% endif %}
|
||||
{% if global_stat.config_reloads_total is not none %}
|
||||
<span style="color:var(--text-dim);margin-left:0.3rem;">({{ global_stat.config_reloads_total | int }} reloads)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{{ stat_tile("Config", "OK" if global_stat.config_last_reload_success == 1.0 else "Reload failed",
|
||||
state="up" if global_stat.config_last_reload_success == 1.0 else "crit",
|
||||
hint=(global_stat.config_reloads_total|int ~ " reloads") if global_stat.config_reloads_total is not none else none) }}
|
||||
{% endif %}
|
||||
|
||||
{% if global_stat.process_memory_bytes is not none %}
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">Memory</span>
|
||||
<span style="margin-left:0.4rem;">
|
||||
{{ global_stat.process_memory_bytes|bytes }}
|
||||
</span>
|
||||
</span>
|
||||
{% if global_stat.open_conns_total is not none %}{{ stat_tile("Open connections", global_stat.open_conns_total|int) }}{% endif %}
|
||||
{% if global_stat.process_memory_bytes is not none %}{{ stat_tile("Memory", global_stat.process_memory_bytes|bytes) }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<span style="margin-left:auto;color:var(--text-dim);font-size:0.78rem;">
|
||||
{{ global_stat.scraped_at.strftime("%H:%M:%S") }} UTC
|
||||
</span>
|
||||
{% macro router_card(r) %}
|
||||
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="host-health-head">
|
||||
{{ status_badge(r.state, "Failing" if r.state == 'crit' else "Warning") }}
|
||||
<h3 class="section-title" style="margin:0;word-break:break-all;">{{ r.name }}</h3>
|
||||
</div>
|
||||
<p class="problem-detail" style="margin-bottom:0.5rem;">{{ r.why }}</p>
|
||||
<table class="table" style="font-size:0.85rem;">
|
||||
<tr><td class="field-hint">Req/s</td><td>{{ "%.2f"|format(r.latest.request_rate) }}</td><td>{{ r.sparkline_req|safe }}</td></tr>
|
||||
<tr><td class="field-hint">Bandwidth</td><td>{{ r.latest.response_bytes_rate|rate }}</td><td></td></tr>
|
||||
<tr><td class="field-hint">p95</td><td class="tone-{{ status_info(status_class(r.latest.latency_p95_ms, 'latency'))[0] }}">{{ "%.0f"|format(r.latest.latency_p95_ms) }} ms</td><td>{{ r.sparkline_p95|safe }}</td></tr>
|
||||
<tr><td class="field-hint">p99</td><td>{{ "%.0f"|format(r.latest.latency_p99_ms) }} ms</td><td></td></tr>
|
||||
<tr><td class="field-hint">4xx</td><td class="{{ 'tone-warn' if r.latest.error_rate_4xx_pct > error_4xx else '' }}">{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%</td><td></td></tr>
|
||||
<tr><td class="field-hint">5xx</td><td class="{{ 'tone-crit' if r.latest.error_rate_5xx_pct > error_5xx else '' }}">{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%</td><td>{{ r.sparkline_5xx|safe }}</td></tr>
|
||||
</table>
|
||||
<div class="field-hint" style="margin-top:0.5rem;">Last scraped {{ r.latest.scraped_at|ago }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% if problems or healthy or certs %}
|
||||
{# ── Routers that need attention ─────────────────────────────────────────── #}
|
||||
{% if problems %}
|
||||
<div style="column-width:340px;column-gap:1rem;">
|
||||
{% for r in problems %}{{ router_card(r) }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Per-service cards + cert card in shared columns layout ───────────────── #}
|
||||
{% if router_data or certs %}
|
||||
<div style="column-width:340px;column-count:3;column-gap:1rem;">
|
||||
|
||||
{% for r in router_data %}
|
||||
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<h3 class="section-title" style="margin-bottom:0.75rem;word-break:break-all;">{{ r.name }}</h3>
|
||||
<table class="table" style="font-size:0.85rem;">
|
||||
<tr>
|
||||
<td style="color:var(--text-muted);padding:0.2rem 0;">Req/s</td>
|
||||
<td style="padding:0.2rem 0.5rem;">{{ "%.2f"|format(r.latest.request_rate) }}</td>
|
||||
<td style="padding:0.2rem 0;">{{ r.sparkline_req | safe }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:var(--text-muted);padding:0.2rem 0;">Bandwidth</td>
|
||||
<td style="padding:0.2rem 0.5rem;">
|
||||
{% set bps = r.latest.response_bytes_rate %}
|
||||
{{ bps|rate }}
|
||||
</td>
|
||||
<td style="padding:0.2rem 0;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:var(--text-muted);padding:0.2rem 0;">p95 ms</td>
|
||||
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p95_ms) }}</td>
|
||||
<td style="padding:0.2rem 0;">{{ r.sparkline_p95 | safe }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:var(--text-muted);padding:0.2rem 0;">p99 ms</td>
|
||||
<td style="padding:0.2rem 0.5rem;">{{ "%.1f"|format(r.latest.latency_p99_ms) }}</td>
|
||||
<td style="padding:0.2rem 0;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:var(--text-muted);padding:0.2rem 0;">4xx %</td>
|
||||
<td style="color:{% if r.latest.error_rate_4xx_pct > 1 %}var(--yellow){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
|
||||
{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%
|
||||
</td>
|
||||
<td style="padding:0.2rem 0;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:var(--text-muted);padding:0.2rem 0;">5xx %</td>
|
||||
<td style="color:{% if r.latest.error_rate_5xx_pct > 0.1 %}var(--red){% else %}var(--green){% endif %};padding:0.2rem 0.5rem;">
|
||||
{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%
|
||||
</td>
|
||||
<td style="padding:0.2rem 0;">{{ r.sparkline_5xx | safe }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="color:var(--text-dim);font-size:0.75rem;margin-top:0.5rem;">
|
||||
Last scraped {{ r.latest.scraped_at.strftime("%H:%M:%S") }} UTC
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if certs %}
|
||||
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">TLS Certificates</div>
|
||||
<table class="table" style="font-size:0.83rem;width:100%;">
|
||||
{% for cert in certs %}
|
||||
{# ── Healthy routers, folded ─────────────────────────────────────────────── #}
|
||||
{% if healthy %}
|
||||
<details class="card" id="traefik-healthy" style="margin-bottom:1rem;">
|
||||
<summary class="section-title" style="cursor:pointer;">{{ healthy|length }} healthy router{{ 's' if healthy|length != 1 }}</summary>
|
||||
<div class="table-wrap" style="margin-top:0.75rem;">
|
||||
<table class="table" style="font-size:0.85rem;">
|
||||
<thead><tr><th>Router</th><th>Req/s</th><th>Bandwidth</th><th>p95</th><th>4xx</th><th>5xx</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in healthy %}
|
||||
<tr>
|
||||
<td style="padding:0.2rem 0 0.2rem 0;word-break:break-all;">{{ cert.cn }}</td>
|
||||
<td style="text-align:right;padding:0.2rem 0;white-space:nowrap;font-weight:600;
|
||||
{% if cert.days_remaining < 7 %}color:var(--red);
|
||||
{% elif cert.days_remaining < 30 %}color:var(--yellow);
|
||||
{% else %}color:var(--green);{% endif %}">
|
||||
{{ cert.days_remaining | int }}d
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding:0 0 0.4rem 0;color:var(--text-dim);font-size:0.75rem;">
|
||||
{{ cert.not_after.strftime("%Y-%m-%d") }}
|
||||
</td>
|
||||
<td style="word-break:break-all;">{{ r.name }}</td>
|
||||
<td>{{ "%.2f"|format(r.latest.request_rate) }}</td>
|
||||
<td>{{ r.latest.response_bytes_rate|rate }}</td>
|
||||
<td class="tone-{{ status_info(status_class(r.latest.latency_p95_ms, 'latency'))[0] }}">{{ "%.0f"|format(r.latest.latency_p95_ms) }} ms</td>
|
||||
<td>{{ "%.1f"|format(r.latest.error_rate_4xx_pct) }}%</td>
|
||||
<td>{{ "%.1f"|format(r.latest.error_rate_5xx_pct) }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
<script>
|
||||
// The fragment is re-polled; keep the healthy list as the reader left it.
|
||||
(function () {
|
||||
var el = document.getElementById("traefik-healthy");
|
||||
if (!el) return;
|
||||
el.open = !!window.__traefikHealthyOpen;
|
||||
el.addEventListener("toggle", function () { window.__traefikHealthyOpen = el.open; });
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{# ── Certificates, soonest expiry first ──────────────────────────────────── #}
|
||||
{% if certs %}
|
||||
<div class="card">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">TLS certificates</div>
|
||||
<table class="table" style="font-size:0.85rem;">
|
||||
<thead><tr><th>Name</th><th>Expires</th><th style="text-align:right;">Left</th></tr></thead>
|
||||
<tbody>
|
||||
{% for cert in certs %}
|
||||
<tr>
|
||||
<td style="word-break:break-all;">{{ cert.cn }}</td>
|
||||
<td class="field-hint">{{ cert.not_after.strftime("%Y-%m-%d") }}</td>
|
||||
<td style="text-align:right;white-space:nowrap;" class="tone-{{ status_info(status_class(cert.days_remaining, 'tls_days'))[0] }}">{{ cert.days_remaining|int }} days</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p style="color:var(--text-dim);">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
|
||||
<p class="field-hint">No Traefik metrics yet. Configure <code>metrics_url</code> in <code>config.yaml</code> under <code>plugins.traefik</code>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">Total</span>
|
||||
<span style="margin-left:0.4rem;font-weight:600;">{{ summary.total }}</span>
|
||||
<span style="margin-left:0.4rem;font-weight:500;">{{ summary.total }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">Internal</span>
|
||||
<span style="margin-left:0.4rem;color:var(--green);font-weight:600;">{{ summary.internal }}</span>
|
||||
<span style="margin-left:0.4rem;color:var(--green);font-weight:500;">{{ summary.internal }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">External</span>
|
||||
<span style="margin-left:0.4rem;color:{% if summary.external_pct > 50 %}var(--yellow){% else %}var(--green){% endif %};font-weight:600;">
|
||||
<span style="margin-left:0.4rem;color:{% if summary.external_pct > 50 %}var(--yellow){% else %}var(--green){% endif %};font-weight:500;">
|
||||
{{ summary.external }} ({{ "%.1f"|format(summary.external_pct) }}%)
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ summary.total | int }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;">{{ summary.total | int }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total req</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--orange);">
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--orange);">
|
||||
{{ "%.1f" | format(summary.external_pct) }}%
|
||||
</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">external</span>
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
{
|
||||
label: "Req/s",
|
||||
data: {{ req_rate_json | safe }},
|
||||
borderColor: "#7c5ef4",
|
||||
backgroundColor: "rgba(124,94,244,0.08)",
|
||||
borderColor: stewardColor("--blue"),
|
||||
backgroundColor: stewardColor("--blue-dim"),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
label: "5xx %",
|
||||
data: {{ error_rate_json | safe }},
|
||||
borderColor: "#e83848",
|
||||
borderColor: stewardColor("--red"),
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
@@ -45,24 +45,23 @@
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: "#6858a0", boxWidth: 10, font: { size: 10 } }
|
||||
labels: { boxWidth: 10, font: { size: 10 } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: {
|
||||
min: 0,
|
||||
grid: { color: "rgba(30,30,62,0.8)" },
|
||||
ticks: { color: "#6858a0", font: { size: 10 } },
|
||||
title: { display: true, text: "req/s", color: "#6858a0", font: { size: 9 } },
|
||||
ticks: { font: { size: 10 } },
|
||||
title: { display: true, text: "req/s", font: { size: 9 } },
|
||||
},
|
||||
y2: {
|
||||
position: "right",
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { color: "#e83848", font: { size: 10 } },
|
||||
title: { display: true, text: "5xx%", color: "#e83848", font: { size: 9 } },
|
||||
ticks: { color: stewardColor("--red"), font: { size: 10 } },
|
||||
title: { display: true, text: "5xx%", color: stewardColor("--red"), font: { size: 9 } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,7 @@
|
||||
{{ ev.event_time.strftime("%Y-%m-%d %H:%M:%S") }}
|
||||
</td>
|
||||
<td style="padding:0.25rem 0.5rem;">
|
||||
<span style="font-size:0.75rem;padding:0.1rem 0.4rem;border-radius:3px;background:
|
||||
{% if ev.category == 'wlan' %}rgba(96,96,192,0.25)
|
||||
{% elif ev.category == 'wan' %}rgba(96,160,96,0.25)
|
||||
{% elif ev.category == 'ids' %}rgba(192,64,64,0.25)
|
||||
{% else %}rgba(128,128,128,0.15){% endif %};">
|
||||
{{ ev.category }}
|
||||
</span>
|
||||
<span class="badge badge-{{ {'wlan': 'info', 'wan': 'ok', 'ids': 'crit'}.get(ev.category, 'dim') }}">{{ ev.category }}</span>
|
||||
</td>
|
||||
<td style="padding:0.25rem 0.5rem;color:var(--text-dim);font-family:monospace;font-size:0.75rem;">
|
||||
{{ ev.event_key.replace("EVT_", "") if ev.event_key else "" }}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
{% if rule.ruleset != ns.current_rs %}
|
||||
{% set ns.current_rs = rule.ruleset %}
|
||||
<tr>
|
||||
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:600;letter-spacing:0.04em;text-transform:uppercase;">
|
||||
<td colspan="3" style="padding:0.4rem 0 0.2rem;color:var(--text-muted);font-size:0.75rem;font-weight:500;letter-spacing:0.04em;text-transform:uppercase;">
|
||||
{{ rule.ruleset }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -73,12 +73,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:0.2rem 0.3rem;white-space:nowrap;">
|
||||
<span style="font-size:0.75rem;padding:0.1rem 0.35rem;border-radius:3px;background:
|
||||
{% if rule.action == 'accept' %}rgba(64,160,64,0.2)
|
||||
{% elif rule.action == 'drop' %}rgba(192,64,64,0.2)
|
||||
{% else %}rgba(192,128,64,0.2){% endif %};">
|
||||
{{ rule.action }}
|
||||
</span>
|
||||
<span class="badge badge-{{ {'accept': 'ok', 'drop': 'crit'}.get(rule.action, 'warn') }}">{{ rule.action }}</span>
|
||||
</td>
|
||||
<td style="padding:0.2rem 0;color:var(--text-dim);font-size:0.76rem;">
|
||||
{% if rule.protocol %}{{ rule.protocol }}{% endif %}
|
||||
|
||||
@@ -1,53 +1,20 @@
|
||||
{# plugins/unifi/templates/unifi/rows.html #}
|
||||
|
||||
{# ── WAN health bar ───────────────────────────────────────────────────────── #}
|
||||
{% from "_macros.html" import stat_tile, status_badge, status_dot %}
|
||||
{# ── WAN health ───────────────────────────────────────────────────────────── #}
|
||||
{% if latest_wan %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1.5rem;margin-bottom:1.25rem;padding:0.75rem 1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;font-size:0.85rem;font-variant-numeric:tabular-nums;align-items:center;">
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">WAN</span>
|
||||
<span style="margin-left:0.4rem;font-weight:600;color:{% if latest_wan.is_up %}var(--green){% else %}var(--red){% endif %};">
|
||||
{% if latest_wan.is_up %}✓ Up{% else %}✗ Down{% endif %}
|
||||
</span>
|
||||
{% if latest_wan.wan_ip %}
|
||||
<span style="color:var(--text-dim);margin-left:0.4rem;font-family:monospace;font-size:0.8rem;">{{ latest_wan.wan_ip }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("WAN", "Up" if latest_wan.is_up else "Down", state="up" if latest_wan.is_up else "down",
|
||||
hint=latest_wan.wan_ip) }}
|
||||
{% if latest_wan.latency_ms is not none %}
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">Latency</span>
|
||||
<span style="margin-left:0.4rem;
|
||||
{% if latest_wan.latency_ms > 100 %}color:var(--red){% elif latest_wan.latency_ms > 50 %}color:var(--yellow){% else %}color:var(--green){% endif %};">
|
||||
{{ "%.1f"|format(latest_wan.latency_ms) }}ms
|
||||
</span>
|
||||
<span style="margin-left:0.3rem;">{{ sparkline_latency | safe }}</span>
|
||||
</span>
|
||||
{{ stat_tile("Latency", "%.0f ms"|format(latest_wan.latency_ms), state=status_class(latest_wan.latency_ms, 'latency'),
|
||||
hint=sparkline_latency|safe) }}
|
||||
{% endif %}
|
||||
|
||||
{% if latest_wan.rx_bytes_rate is not none %}
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">↓ RX</span>
|
||||
<span style="margin-left:0.3rem;">
|
||||
{% set bps = latest_wan.rx_bytes_rate %}
|
||||
{{ bps|rate }}
|
||||
</span>
|
||||
<span style="margin-left:0.3rem;">{{ sparkline_rx | safe }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style="color:var(--text-muted);">↑ TX</span>
|
||||
<span style="margin-left:0.3rem;">
|
||||
{% set bps = latest_wan.tx_bytes_rate %}
|
||||
{{ bps|rate }}
|
||||
</span>
|
||||
<span style="margin-left:0.3rem;">{{ sparkline_tx | safe }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if latest_wan.uptime_seconds is not none %}
|
||||
<span style="color:var(--text-dim);font-size:0.8rem;">
|
||||
up {{ latest_wan.uptime_seconds|duration }}
|
||||
</span>
|
||||
{{ stat_tile("Download", latest_wan.rx_bytes_rate|rate, hint=sparkline_rx|safe) }}
|
||||
{{ stat_tile("Upload", latest_wan.tx_bytes_rate|rate, hint=sparkline_tx|safe) }}
|
||||
{% endif %}
|
||||
{% if latest_wan.uptime_seconds is not none %}{{ stat_tile("WAN uptime", latest_wan.uptime_seconds|duration) }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -63,7 +30,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:1.5rem;font-size:0.9rem;margin-bottom:0.75rem;font-variant-numeric:tabular-nums;">
|
||||
<span>
|
||||
<span style="font-size:1.4rem;font-weight:700;color:var(--text);">{{ latest_clients.total_clients }}</span>
|
||||
<span style="font-size:1.4rem;color:var(--text);">{{ latest_clients.total_clients }}</span>
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;margin-left:0.2rem;">total</span>
|
||||
</span>
|
||||
<span style="color:var(--text-muted);font-size:0.85rem;align-self:flex-end;">
|
||||
@@ -78,12 +45,12 @@
|
||||
{# Infrastructure devices card #}
|
||||
{% if devices %}
|
||||
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Infrastructure Devices</div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Infrastructure devices</div>
|
||||
<table class="table" style="font-size:0.83rem;width:100%;">
|
||||
{% for d in devices %}
|
||||
<tr>
|
||||
<td style="padding:0.2rem 0.5rem 0.2rem 0;">
|
||||
<span style="color:{% if d.state == 1 %}var(--green){% else %}var(--red){% endif %};">●</span>
|
||||
{{ status_dot('up' if d.state == 1 else 'down') }}
|
||||
<span style="margin-left:0.3rem;">{{ d.name or d.mac }}</span>
|
||||
</td>
|
||||
<td style="padding:0.2rem 0.3rem;color:var(--text-muted);font-size:0.78rem;">{{ d.model or d.device_type or "" }}</td>
|
||||
@@ -104,7 +71,7 @@
|
||||
{# Top talkers card #}
|
||||
{% if top_clients %}
|
||||
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Top Talkers</div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Top talkers</div>
|
||||
<table class="table" style="font-size:0.82rem;width:100%;">
|
||||
{% for c in top_clients %}
|
||||
<tr>
|
||||
@@ -130,7 +97,7 @@
|
||||
{# WLAN SSIDs card #}
|
||||
{% if wlan_latest %}
|
||||
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Wireless Networks</div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Wireless networks</div>
|
||||
<table class="table" style="font-size:0.83rem;width:100%;">
|
||||
<tr style="color:var(--text-muted);font-size:0.78rem;">
|
||||
<th style="text-align:left;font-weight:normal;padding:0 0.5rem 0.3rem 0;">SSID</th>
|
||||
@@ -141,10 +108,7 @@
|
||||
<tr>
|
||||
<td style="padding:0.2rem 0.5rem 0.2rem 0;">{{ w.ssid }}</td>
|
||||
<td style="text-align:right;padding:0.2rem 0.3rem;">{{ w.num_sta }}</td>
|
||||
<td style="text-align:right;padding:0.2rem 0;
|
||||
{% if w.satisfaction is not none %}
|
||||
{% if w.satisfaction >= 80 %}color:var(--green){% elif w.satisfaction >= 60 %}color:var(--yellow){% else %}color:var(--red){% endif %}
|
||||
{% endif %};">
|
||||
<td style="text-align:right;padding:0.2rem 0;" class="{% if w.satisfaction is not none %}tone-{{ 'ok' if w.satisfaction >= 80 else 'warn' if w.satisfaction >= 60 else 'crit' }}{% endif %}">
|
||||
{% if w.satisfaction is not none %}{{ w.satisfaction }}%{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -156,19 +120,19 @@
|
||||
{# Speedtest card #}
|
||||
{% if speedtest %}
|
||||
<div class="card" style="break-inside:avoid;margin-bottom:1rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Last Speed Test</div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Last speed test</div>
|
||||
<div style="display:flex;gap:1.5rem;font-size:0.88rem;font-variant-numeric:tabular-nums;">
|
||||
{% if speedtest.download_mbps is not none %}
|
||||
<span>
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">↓ DL</span>
|
||||
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.download_mbps) }}</span>
|
||||
<span style="margin-left:0.3rem;">{{ "%.1f"|format(speedtest.download_mbps) }}</span>
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if speedtest.upload_mbps is not none %}
|
||||
<span>
|
||||
<span style="color:var(--text-muted);font-size:0.8rem;">↑ UL</span>
|
||||
<span style="font-weight:600;margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
|
||||
<span style="margin-left:0.3rem;">{{ "%.1f"|format(speedtest.upload_mbps) }}</span>
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;">Mbps</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">
|
||||
Active Alarms
|
||||
{% if active_alarms %}
|
||||
<span style="float:right;background:var(--red);color:#fff;font-size:0.72rem;padding:0.1rem 0.45rem;border-radius:10px;font-weight:600;">{{ active_alarms|length }}</span>
|
||||
<span class="count-badge" style="float:right;">{{ active_alarms|length }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not active_alarms %}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<span class="ping-name" style="font-size:0.8rem;">Clients</span>
|
||||
</div>
|
||||
<div style="flex:1;display:flex;gap:1rem;font-size:0.82rem;">
|
||||
<span style="font-weight:600;">{{ latest_clients.total_clients }}</span>
|
||||
<span style="font-weight:500;">{{ latest_clients.total_clients }}</span>
|
||||
<span style="color:var(--text-muted);">{{ latest_clients.wireless_clients }}w / {{ latest_clients.wired_clients }}e</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;">{{ snapshot.total_clients }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;">{{ snapshot.total_clients }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">total</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--accent);">{{ snapshot.wireless_clients }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--accent);">{{ snapshot.wireless_clients }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wireless</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-muted);">{{ snapshot.wired_clients }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--text-muted);">{{ snapshot.wired_clients }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">wired</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.75rem;">
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ online | length }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ online | length }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">online</span>
|
||||
</div>
|
||||
{% if offline %}
|
||||
<div>
|
||||
<span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ offline | length }}</span>
|
||||
<span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ offline | length }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">offline</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
+16
-10
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from quart import Blueprint, render_template, request, current_app
|
||||
from sqlalchemy import select
|
||||
from steward.audit.service import entity_labels
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.models.audit import AuditEvent
|
||||
from steward.models.users import UserRole
|
||||
@@ -23,16 +24,21 @@ async def list_events():
|
||||
result = await db.execute(stmt)
|
||||
events = result.scalars().all()
|
||||
|
||||
# Parse detail_json for display
|
||||
parsed = []
|
||||
for e in events:
|
||||
detail = {}
|
||||
if e.detail_json:
|
||||
try:
|
||||
detail = json.loads(e.detail_json)
|
||||
except (ValueError, TypeError):
|
||||
detail = {"raw": e.detail_json}
|
||||
parsed.append({"event": e, "detail": detail})
|
||||
# Parse detail_json for display
|
||||
parsed = []
|
||||
for e in events:
|
||||
detail = {}
|
||||
if e.detail_json:
|
||||
try:
|
||||
detail = json.loads(e.detail_json)
|
||||
except (ValueError, TypeError):
|
||||
detail = {"raw": e.detail_json}
|
||||
parsed.append({"event": e, "detail": detail})
|
||||
labels = await entity_labels(db, parsed)
|
||||
|
||||
for item in parsed:
|
||||
e = item["event"]
|
||||
item["entity"] = labels.get((e.entity_type, str(e.entity_id))) if e.entity_id else None
|
||||
|
||||
return await render_template(
|
||||
"audit/list.html",
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
})();
|
||||
@@ -86,8 +86,8 @@ code { font-family: var(--fs-font-mono); font-size: 0.875em; background: var(--b
|
||||
color: var(--gold); border-left-color: var(--gold);
|
||||
background: color-mix(in srgb, var(--gold) 8%, transparent);
|
||||
}
|
||||
/* How many alerts need attention, beside the Alerts link. */
|
||||
.nav-count {
|
||||
/* A small count that needs attention: alerts beside the Alerts link, UniFi alarms. */
|
||||
.count-badge {
|
||||
margin-left: auto; padding: 0 0.45em; border-radius: var(--fs-radius-sm);
|
||||
background: var(--red-dim); color: var(--red); font-size: 0.75rem; font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
</div>
|
||||
|
||||
<details open>
|
||||
<summary style="cursor:pointer;font-weight:600;">Claude Code</summary>
|
||||
<summary style="cursor:pointer;font-weight:500;">Claude Code</summary>
|
||||
<pre style="{{ pre }}">claude mcp add --transport http steward {{ mcp_url }} \
|
||||
--header "Authorization: Bearer {{ tok }}"</pre>
|
||||
</details>
|
||||
|
||||
<details style="margin-top:0.75rem;">
|
||||
<summary style="cursor:pointer;font-weight:600;">Claude Desktop</summary>
|
||||
<summary style="cursor:pointer;font-weight:500;">Claude Desktop</summary>
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;line-height:1.5;">
|
||||
Add to <code>claude_desktop_config.json</code>. It bridges through
|
||||
<code>mcp-remote</code>, which needs Node.js.
|
||||
@@ -40,7 +40,7 @@
|
||||
</details>
|
||||
|
||||
<details style="margin-top:0.75rem;">
|
||||
<summary style="cursor:pointer;font-weight:600;">Open WebUI and other clients</summary>
|
||||
<summary style="cursor:pointer;font-weight:500;">Open WebUI and other clients</summary>
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;line-height:1.5;">
|
||||
Add an MCP server using the <strong>Streamable HTTP</strong> transport, with the URL
|
||||
above and bearer authentication set to the token. In Open WebUI this is an external
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="page-title">Token created</h1>
|
||||
|
||||
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
|
||||
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
|
||||
<div style="font-size:0.8rem;font-weight:500;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
|
||||
Copy this token now. It can't be shown again.
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{% set t = row.token %}
|
||||
<tr {% if row.state != 'active' %}style="opacity:0.55;"{% endif %}>
|
||||
<td>
|
||||
<div style="font-weight:600;">{{ t.name }}</div>
|
||||
<div style="font-weight:500;">{{ t.name }}</div>
|
||||
<div style="font-family:ui-monospace,monospace;font-size:0.75rem;color:var(--text-muted);">{{ t.prefix }}…</div>
|
||||
</td>
|
||||
<td><span class="badge badge-dim">{{ t.role.value }}</span></td>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if variables %}
|
||||
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
|
||||
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:500;">
|
||||
Playbook variables
|
||||
</div>
|
||||
<p style="color:var(--text-dim);font-size:0.76rem;margin:0 0 0.6rem;">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<span style="color:var(--red);" title="{{ s.last_error }}">launch error</span>
|
||||
{% elif row.last_run %}
|
||||
{% set sc = {"queued":"var(--text-dim)","running":"var(--yellow)","success":"var(--green)","failed":"var(--red)","interrupted":"var(--orange)","cancelled":"var(--orange)"}.get(row.last_run.status.value, "var(--text-muted)") %}
|
||||
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:600;">{{ row.last_run.status.value }}</a>
|
||||
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:500;">{{ row.last_run.status.value }}</a>
|
||||
<span style="color:var(--text-dim);">· {{ s.last_run_at.strftime("%m-%d %H:%M") }}</span>
|
||||
{% else %}
|
||||
<span style="color:var(--text-dim);">never</span>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log — Steward{% endblock %}
|
||||
{% from "_macros.html" import page_header, empty_state %}
|
||||
{% block title %}Audit log — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Audit Log</h1>
|
||||
{% call page_header("Audit log", "Who changed what, newest first.") %}
|
||||
<form method="get" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" name="action" value="{{ action_filter }}"
|
||||
placeholder="Filter by action…"
|
||||
placeholder="Filter by action…" aria-label="Filter by action"
|
||||
style="width:200px;font-size:0.85rem;">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Filter</button>
|
||||
{% if action_filter %}
|
||||
<a href="/audit/" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{% if events %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width:160px;">Time (UTC)</th>
|
||||
<th>When</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Entity</th>
|
||||
@@ -31,30 +31,21 @@
|
||||
{% set e = item.event %}
|
||||
{% set d = item.detail %}
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.8rem;white-space:nowrap;color:var(--text-muted);">
|
||||
{{ e.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}
|
||||
</td>
|
||||
<td style="white-space:nowrap;" title="{{ e.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ e.timestamp|ago }}</td>
|
||||
<td style="font-size:0.85rem;">{{ e.username }}</td>
|
||||
<td><span class="mono">{{ e.action }}</span></td>
|
||||
<td>
|
||||
{% set cat = e.action.split('.')[0] %}
|
||||
<span style="
|
||||
font-size:0.78rem;font-family:ui-monospace,monospace;
|
||||
padding:0.15rem 0.4rem;border-radius:3px;
|
||||
background:{% if cat == 'auth' %}var(--bg-elevated){% elif cat == 'plugin' %}var(--gold-dim){% elif cat == 'alert' %}var(--red-dim){% elif cat == 'host' %}var(--green-dim){% elif cat == 'settings' %}var(--bg-elevated){% else %}var(--bg-elevated){% endif %};
|
||||
color:{% if cat == 'auth' %}var(--accent){% elif cat == 'plugin' %}var(--gold){% elif cat == 'alert' %}var(--red){% elif cat == 'host' %}var(--green){% elif cat == 'settings' %}var(--text-muted){% else %}var(--text-muted){% endif %};">
|
||||
{{ e.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);">
|
||||
{% if e.entity_type %}
|
||||
<span style="color:var(--text-dim);">{{ e.entity_type }}/</span>{{ e.entity_id or "" }}
|
||||
{% endif %}
|
||||
{% if item.entity %}
|
||||
<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>
|
||||
{% if item.entity.url %}<a href="{{ item.entity.url }}">{{ item.entity.label }}</a>{% else %}{{ item.entity.label }}{% endif %}
|
||||
{% if item.entity.gone %}<span class="field-hint">(deleted)</span>{% endif %}
|
||||
{% elif e.entity_type %}<span class="field-hint">{{ e.entity_type|replace('_', ' ') }}</span>{% endif %}
|
||||
</td>
|
||||
<td style="font-size:0.8rem;color:var(--text-muted);max-width:340px;">
|
||||
{% if d %}
|
||||
{% for k, v in d.items() %}
|
||||
<span style="color:var(--text-dim);">{{ k }}:</span>
|
||||
<span style="font-family:ui-monospace,monospace;">{{ v }}</span>
|
||||
<span class="mono">{{ v }}</span>
|
||||
{% if not loop.last %} · {% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -71,8 +62,6 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<p style="color:var(--text-muted);">No audit events recorded yet.</p>
|
||||
</div>
|
||||
<div class="card">{{ empty_state("No audit events recorded yet.") }}</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
|
||||
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -62,7 +63,7 @@
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Monitoring</div>
|
||||
<a href="/monitors/" {% if _p.startswith('/monitors') %}class="active" aria-current="page"{% endif %}>Monitors</a>
|
||||
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts{% if active_alert_count %} <span class="nav-count" title="{{ active_alert_count }} alert{{ 's' if active_alert_count != 1 }} need{{ 's' if active_alert_count == 1 }} attention">{{ active_alert_count }}</span>{% endif %}</a>
|
||||
<a href="/alerts/" {% if _p.startswith('/alerts') %}class="active" aria-current="page"{% endif %}>Alerts{% if active_alert_count %} <span class="count-badge" title="{{ active_alert_count }} alert{{ 's' if active_alert_count != 1 }} need{{ 's' if active_alert_count == 1 }} attention">{{ active_alert_count }}</span>{% endif %}</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Automation</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<summary style="display:flex;align-items:center;justify-content:space-between;gap:0.6rem;
|
||||
padding:0.7rem 0.85rem;cursor:pointer;list-style:none;user-select:none;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.85rem;">{{ w.label }}</div>
|
||||
<div style="font-weight:500;font-size:0.85rem;">{{ w.label }}</div>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);margin-top:0.1rem;">{{ w.description }}</div>
|
||||
</div>
|
||||
<span class="btn btn-sm" style="flex-shrink:0;pointer-events:none;">Add ▾</span>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:500;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
{% if dash.is_default %}
|
||||
<span class="badge badge-gold" style="font-size:0.68rem;">Your Default</span>
|
||||
{% endif %}
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<details style="position:relative;">
|
||||
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
|
||||
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
|
||||
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:var(--fs-shadow-3);">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">{{ csrf_field() }}
|
||||
<input type="text" name="name" value="{{ dash.name }}"
|
||||
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
|
||||
@@ -83,7 +83,7 @@
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:600;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
<a href="/d/{{ dash.id }}" style="font-weight:500;font-size:0.9rem;">{{ dash.name }}</a>
|
||||
{% if dash.owner_id is none %}
|
||||
<span class="badge badge-dim" style="font-size:0.68rem;">System</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="card" style="margin-bottom:0;padding:0.85rem 1rem;{% if expired %}opacity:0.5;{% endif %}">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.875rem;">
|
||||
<div style="font-weight:500;font-size:0.875rem;">
|
||||
{{ token.label or "Unnamed link" }}
|
||||
{% if expired %}<span class="badge badge-red" style="font-size:0.68rem;">Expired</span>{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="page-title">Share Link Created</h1>
|
||||
|
||||
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
|
||||
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
|
||||
<div style="font-size:0.8rem;font-weight:500;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
|
||||
Copy this link now — it cannot be shown again
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fonts.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='steward.css') }}">
|
||||
<script src="{{ url_for('static', filename='chart-colors.js') }}"></script>
|
||||
{# The palette, type and widget classes come from steward.css like every
|
||||
other page; only the read-only page's own frame and its grid live here. #}
|
||||
<style>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
{% set hs = host_status.get(host.id) %}
|
||||
{% set ut = uptime.get(host.id, {}) %}
|
||||
<tr>
|
||||
<td><a href="/hosts/{{ host.id }}" style="font-weight:600;">{{ host.name }}</a></td>
|
||||
<td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td>
|
||||
<td style="color:var(--text-muted);">{{ host.address }}</td>
|
||||
<td style="text-align:center;">
|
||||
{% if not hs %}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
style="flex-shrink:0;"
|
||||
title="{% if not hs %}no monitors{% else %}{{ hs.state }}{% endif %}"></span>
|
||||
{% if session.user_id %}
|
||||
<a href="/hosts/{{ host.id }}" style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
|
||||
<a href="/hosts/{{ host.id }}" style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</a>
|
||||
{% else %}
|
||||
<span style="font-weight:600;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</span>
|
||||
<span style="font-weight:500;font-size:0.85rem;overflow-wrap:anywhere;">{{ host.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Line 2 — monitors + agent metrics; wraps instead of truncating #}
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:0.65rem;flex-wrap:wrap;">
|
||||
{% if up %}
|
||||
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--green);">{{ up }}</span>
|
||||
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--green);">{{ up }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">up</span></div>
|
||||
{% endif %}
|
||||
{% if down %}
|
||||
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--red);">{{ down }}</span>
|
||||
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--red);">{{ down }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">down</span></div>
|
||||
{% endif %}
|
||||
{% if pending %}
|
||||
<div><span style="font-size:1.4rem;font-weight:700;line-height:1;color:var(--text-dim);">{{ pending }}</span>
|
||||
<div><span style="font-size:1.4rem;font-weight:500;line-height:1;color:var(--text-dim);">{{ pending }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);margin-left:0.25rem;">pending</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div id="ansible-source-view-{{ idx }}"
|
||||
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
|
||||
<span style="flex-shrink:0;font-size:0.7rem;font-weight:600;text-transform:uppercase;
|
||||
<span style="flex-shrink:0;font-size:0.7rem;font-weight:500;text-transform:uppercase;
|
||||
letter-spacing:0.06em;padding:0.15em 0.5em;border-radius:3px;
|
||||
background:{% if src.type == 'git' %}var(--fs-accent-faint){% else %}var(--bg-elevated){% endif %};
|
||||
color:{% if src.type == 'git' %}var(--accent){% else %}var(--text-muted){% endif %};">
|
||||
@@ -20,7 +20,7 @@
|
||||
</span>
|
||||
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.9rem;">{{ src.name }}</div>
|
||||
<div style="font-weight:500;font-size:0.9rem;">{{ src.name }}</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{% if src.type == 'git' %}
|
||||
@@ -136,7 +136,7 @@
|
||||
<details class="card" style="padding:0;overflow:hidden;">
|
||||
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
|
||||
cursor:pointer;list-style:none;user-select:none;">
|
||||
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Source</span>
|
||||
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Source</span>
|
||||
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
|
||||
</summary>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div style="display:flex;align-items:flex-start;gap:1rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
|
||||
<span style="font-weight:500;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
|
||||
|
||||
{% if update_available %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div id="plugin-repo-view-{{ idx }}"
|
||||
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
|
||||
<div style="font-weight:500;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@
|
||||
<details class="card" style="padding:0;overflow:hidden;">
|
||||
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
|
||||
cursor:pointer;list-style:none;user-select:none;">
|
||||
<span style="flex:1;font-weight:600;font-size:0.875rem;color:var(--text);">Add Repository</span>
|
||||
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Repository</span>
|
||||
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
|
||||
</summary>
|
||||
<form hx-post="/settings/plugins/repos/add"
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
name="plugin.{{ plugin._dir }}.enabled"
|
||||
id="plugin-{{ plugin._dir }}-enabled"
|
||||
{% if plugin._enabled %}checked{% endif %}>
|
||||
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:600;cursor:pointer;">
|
||||
<label for="plugin-{{ plugin._dir }}-enabled" style="margin:0;font-weight:500;cursor:pointer;">
|
||||
Enable this plugin
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% endif %}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
|
||||
<span style="font-weight:500;font-size:0.9rem;color:var(--text);">{{ plugin.get('name', plugin._dir) }}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.get('version', '?') }}</span>
|
||||
{% if fail_reason %}
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
@@ -109,7 +109,7 @@
|
||||
title="{{ orphan.reason or 'Configured but not installed' }}"></span>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.5rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:600;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
|
||||
<span style="font-weight:500;font-size:0.9rem;color:var(--text);">{{ orphan.name }}</span>
|
||||
{% if orphan.reason %}
|
||||
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
|
||||
background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Failed to load</span>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<label>{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">({{ unit }})</span></label>
|
||||
<div style="display:flex;gap:1.25rem;flex-wrap:wrap;margin-top:0.25rem;">
|
||||
<span style="display:flex;align-items:center;gap:0.4rem;">
|
||||
<span style="color:var(--yellow);font-size:0.8rem;font-weight:600;">warn</span>
|
||||
<span style="color:var(--yellow);font-size:0.8rem;font-weight:500;">warn</span>
|
||||
<input type="number" name="{{ warn_field }}" step="{{ step }}"
|
||||
value="{{ settings[warn_key] }}" style="max-width:110px;">
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:0.4rem;">
|
||||
<span style="color:var(--red);font-size:0.8rem;font-weight:600;">crit</span>
|
||||
<span style="color:var(--red);font-size:0.8rem;font-weight:500;">crit</span>
|
||||
<input type="number" name="{{ crit_field }}" step="{{ step }}"
|
||||
value="{{ settings[crit_key] }}" style="max-width:110px;">
|
||||
</span>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<span title="{{ e.name }}" style="{{ nm_style }}">{{ e.name }}</span>
|
||||
{% endif %}
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span>
|
||||
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:600;">{{ e.status }}</span>
|
||||
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:500;">{{ e.status }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if problems|length > 8 %}
|
||||
|
||||
@@ -121,7 +121,7 @@ async def test_the_sidebar_counts_alerts_needing_attention(app, monkeypatch):
|
||||
async with app.test_request_context("/alerts/"):
|
||||
session["user_id"], session["user_role"] = "u1", "viewer"
|
||||
page = await render_template("alerts/list.html", active=[], rules=[], operators=[])
|
||||
assert '<span class="nav-count" title="3 alerts need attention">3</span>' in page
|
||||
assert '<span class="count-badge" title="3 alerts need attention">3</span>' in page
|
||||
|
||||
|
||||
async def test_no_count_for_a_fragment_or_a_logged_out_page(app, monkeypatch):
|
||||
@@ -131,4 +131,4 @@ async def test_no_count_for_a_fragment_or_a_logged_out_page(app, monkeypatch):
|
||||
fragment = await render_template("alerts/list.html", active=[], rules=[], operators=[])
|
||||
async with app.test_request_context("/alerts/"):
|
||||
logged_out = await render_template("alerts/list.html", active=[], rules=[], operators=[])
|
||||
assert "nav-count" not in fragment and "nav-count" not in logged_out
|
||||
assert "count-badge" not in fragment and "count-badge" not in logged_out
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -22,7 +22,7 @@ _USED = re.compile(r"var\(\s*(--[\w-]+)")
|
||||
# string -- but not an HTML attribute's value (href="#abc" is a link to an id).
|
||||
_HEX = re.compile(r"""(?:[:(,]\s*|(?<![\w-]=)['"])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-zA-Z_-])""")
|
||||
|
||||
HEX_CEILING = 47 # 75 at #5092, 58 at #5095, 56 at #5121, 54 at #5122, 47 with Ansible runs on tokens (#5125); only lower it
|
||||
HEX_CEILING = 0 # 75 at #5092, 58 at #5095, 56 at #5121, 54 at #5122, 47 at #5125, 0 with the charts on tokens (#5126)
|
||||
|
||||
|
||||
def _sheets():
|
||||
@@ -45,11 +45,17 @@ def hex_colours(text: str) -> list[str]:
|
||||
return _HEX.findall(_COMMENTS.sub("", text))
|
||||
|
||||
|
||||
def heavy_weights(text: str) -> list[str]:
|
||||
"""Weights past the house pair (400, 500), outside comments."""
|
||||
return re.findall(r"font-weight\s*:\s*(?:[6-9]00|bold)", _COMMENTS.sub("", text))
|
||||
|
||||
|
||||
def test_the_guards_can_fail():
|
||||
assert undefined_vars([":root { --a: 1; } .x { color: var(--a); background: var(--b); }"]) == {"--b"}
|
||||
page = '<div style="color:#fff;background: rgb(0 0 0)" hx-target="#add-form"></div><script>c = "#4aa86a"</script>'
|
||||
assert len(hex_colours(page)) == 2
|
||||
assert hex_colours("{# color:#fff #}<a href=\"#abc\">") == []
|
||||
assert heavy_weights('<b style="font-weight: 600">{# font-weight:700 #}</b>') == ["font-weight: 600"]
|
||||
|
||||
|
||||
def test_every_css_variable_used_is_defined():
|
||||
@@ -63,6 +69,12 @@ def test_the_base_stylesheet_states_no_colour_or_heavy_weight_of_its_own():
|
||||
assert not re.findall(r"font-weight\s*:\s*(?:[6-9]00|bold)", css)
|
||||
|
||||
|
||||
def test_templates_use_the_two_house_weights():
|
||||
"""The house style has two weights, 400 and 500 (design system 1)."""
|
||||
heavy = {p.name: len(found) for p in _templates() if (found := heavy_weights(p.read_text()))}
|
||||
assert not heavy, heavy
|
||||
|
||||
|
||||
def test_hex_colours_in_templates_only_go_down():
|
||||
count = sum(len(hex_colours(p.read_text())) for p in _templates())
|
||||
assert count <= HEX_CEILING, (
|
||||
|
||||
Reference in New Issue
Block a user