Files
FabledSteward/plugins/traefik/templates/traefik/rows.html
T
bvandeusenandClaude Opus 5.5 0bac1d2068
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m27s
CI / unit (push) Failing after 1m38s
CI / publish (push) Skipped
feat(traefik): the page leads with its verdict and the request chart (#5710)
The Traefik page now reads state first: a verdict naming each router that
needs a look, why, and when it started (from its history buckets over the
page's range), then the request chart following the page's time range, then
the problem cards (which say since when too), the proxy's own figures, and
the healthy rest. The verdict is filled out of band by /rows each poll; the
"Needs attention" and "Healthy routers" tiles it replaces are gone.

The request chart takes ?range= from the page; past a day its times carry the
day, and its clock is 24-hour (hourCycle h23).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:50:09 -04:00

159 lines
8.8 KiB
HTML

{# traefik/rows.html — HTMX fragment for the Traefik page: the verdict (swapped
into #traefik-verdict above the request chart, #5710), routers that need
attention (routes.router_state), the proxy's own figures, the healthy
routers folded into one table, then certificates by soonest expiry. #}
{% from "_macros.html" import stat_tile, status_badge %}
{# ── Verdict: what needs a look and since when, then the counts ─────────── #}
{% set shown = 3 %}
<p class="page-verdict" id="traefik-verdict" hx-swap-oob="true">
{%- if problems %}<strong class="tone-{{ problems[0].state }}">{{ problems|length }} router{{ "s" if problems|length != 1 }} need{{ "s" if problems|length == 1 }} a look</strong>
{%- for r in problems[:shown] %}{{ ":" if loop.first else ";" }} <span style="word-break:break-all;">{{ r.name }}</span>, {{ r.why }}
{%- if r.since_before %}, all of the last {{ current_range }}{% elif r.since %}, started {{ r.since|ago }}{% endif %}
{%- endfor %}
{%- if problems|length > shown %}; {{ problems|length - shown }} more below{% endif %}.
{%- if healthy %} {{ healthy|length }} healthy.{% endif %}
{%- elif healthy %}<strong class="tone-ok">All {{ healthy|length }} router{{ "s" if healthy|length != 1 }} healthy</strong>
{%- else %}<span class="tone-muted">No routers reported yet.</span>
{%- endif %}
{%- if gone %} <span class="tone-muted">{{ gone|length }} no longer reported.</span>{% endif %}
</p>
{% macro router_card(r) %}
<div class="card">
<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 }}
{%- if r.since_before %}, all of the last {{ current_range }}{% elif r.since %}, started {{ r.since|ago }}{% endif %}</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>{{ r.sparkline_bytes|safe }}</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>{{ r.sparkline_4xx|safe }}</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 gone or certs or old_certs %}
{# ── Routers that need attention ─────────────────────────────────────────── #}
{% if problems %}
{# A grid, not CSS columns: a column box drops its last card's bottom margin,
which left the cards touching the section below (#5214). #}
<div class="node-grid" style="margin-bottom:1rem;">
{% for r in problems %}{{ router_card(r) }}{% endfor %}
</div>
{% endif %}
{# ── The proxy's own figures ─────────────────────────────────────────────── #}
{% if global_stat and (global_stat.config_last_reload_at or global_stat.open_conns_total is not none
or global_stat.process_memory_bytes is not none) %}
<div class="stat-strip">
{% if global_stat.config_last_reload_at %}
{{ stat_tile("Config reloaded", global_stat.config_last_reload_at|ago,
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 %}
</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>
{% endif %}
{# ── Routers Traefik no longer reports: their last numbers are history, not a
state, so they are listed by when they were last seen and nothing else. ── #}
{% if gone %}
<details class="card" style="margin-bottom:1rem;">
<summary class="section-title" style="cursor:pointer;">{{ gone|length }} router{{ 's' if gone|length != 1 }} no longer reported</summary>
<p class="field-hint" style="margin:0.5rem 0;">Traefik stopped reporting these, usually because the service behind them was removed.</p>
<ul class="problem-list">
{% for r in gone %}
<li><span style="word-break:break-all;">{{ r.name }}</span> <span class="problem-detail">last seen {{ r.latest.scraped_at|ago }}</span></li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if healthy %}
<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 Traefik still serves, soonest expiry first; then the ones
it no longer reports (renewed or retired), kept here but off the
dashboard (#5254). #}
{% if certs or old_certs %}
<div class="card">
<div class="section-title" style="margin-bottom:0.75rem;">TLS certificates</div>
{% if certs %}
<table class="table" style="font-size:0.85rem;">
<thead><tr><th>Name</th><th>Routers</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 }}{% if cert.sans %}<div class="field-hint">also {{ cert.sans|join(", ") }}</div>{% endif %}</td>
<td class="field-hint">{% if cert.routers is none %}—{% elif cert.routers %}{{ cert.routers|join(", ") }}{% else %}not in use{% endif %}</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_left(cert.days_remaining) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if old_certs %}
<details style="margin-top:0.75rem;">
<summary class="field-hint">No longer served ({{ old_certs|length }}) — renewed or retired; Traefik stopped reporting them</summary>
<table class="table" style="font-size:0.85rem;margin-top:0.5rem;">
<thead><tr><th>Name</th><th>Expires</th></tr></thead>
<tbody>
{% for cert in old_certs %}
<tr style="color:var(--text-muted);">
<td style="word-break:break-all;">{{ cert.cn }}</td>
<td class="field-hint">{{ cert.not_after.strftime("%Y-%m-%d") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
</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 %}