Files
FabledSteward/plugins/traefik/templates/traefik/rows.html
T
bvandeusenandClaude Opus 5.5 414c942cbf
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m5s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 57s
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>
2026-10-06 18:25:09 -04:00

105 lines
5.8 KiB
HTML

{# traefik/rows.html — HTMX fragment for the Traefik page: routers that need
attention first (routes.router_state), the proxy's own counts, the healthy
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 %}
{{ 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.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>
{% 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 %}
{# ── 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="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 %}
{% else %}
<div class="card">
<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 %}