Files
FabledSteward/plugins/traefik/templates/traefik/rows.html
T
bvandeusenandClaude Opus 5.5 685850e681
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped
feat(traefik): each router problem says what it means, whether it's real, and what to do (#5712)
#5712 "Each router problem says what it means, whether it's real, and what
to do". A problem card now names its kind and reads its codes:
- Server errors: 500 means the app is failing; 502/503/504 means Traefik
  couldn't reach it or it timed out.
- Client errors: 404 scanners, a 401/403 login wall, 429 rate limiting, or
  499 clients giving up on a slow app.
- Slow: p95 against p50.
Each card says whether the problem is likely real, and ends with one next
step. A fault links to the swarm service behind the router, matched by
name, and traffic links to Settings → Thresholds. The wording lives in
plugins/traefik/meaning.py.

The plugin manager now hands out a loaded plugin's submodule
(loaded_plugin_submodule), so Traefik and host_agent reach Docker's
models the same way (#5775).

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

194 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# 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), their metric anomalies, the proxy's own
figures, the healthy routers folded into one table (one over a cutoff but
within its normal says so, #5741), 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>
{# ── The proxy's own figures: shown even before any router reports ───────── #}
{% macro proxy_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 %}
{% endmacro %}
{# A router that has answered nothing since Steward started has no latency yet. #}
{% macro ms(v) %}{% if v is none %}—{% else %}{{ "%.0f"|format(v) }} ms{% endif %}{% endmacro %}
{% macro latency_cell(v) %}<td{% if v is not none %} class="tone-{{ status_info(status_class(v, 'latency'))[0] }}"{% endif %}>{{ ms(v) }}</td>{% endmacro %}
{% 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;">{% if r.meaning %}<strong>{{ r.meaning.label }}</strong>: {% endif %}{{ r.why }}
{%- if r.since_before %}, all of the last {{ current_range }}{% elif r.since %}, started {{ r.since|ago }}{% endif %}</p>
{% if r.codes %}
<p class="field-hint" style="margin:0 0 0.5rem;">Last 15 min:
{% for code, n in r.codes[:5] %}<span class="tone-{{ 'crit' if code[:1] == '5' else 'warn' }}">{{ code }}</span> ×{{ "{:,}".format(n) }}{{ ", " if not loop.last }}{% endfor %}
{%- if r.codes|length > 5 %}, {{ r.codes|length - 5 }} more code{{ "s" if r.codes|length - 5 != 1 }}{% endif %}</p>
{% elif codes_known %}
<p class="field-hint" style="margin:0 0 0.5rem;">No 4xx or 5xx responses in the last 15 min.</p>
{% else %}
<p class="field-hint" style="margin:0 0 0.5rem;">Status codes not recorded yet: Steward keeps them from this version on.</p>
{% endif %}
{% if r.meaning %}
{# What it means and the one next step (#5712; plugins/traefik/meaning.py). #}
<p style="margin:0 0 0.3rem;font-size:0.85rem;">{{ r.meaning.meaning }} <span class="tone-muted">{{ r.meaning.likely }}</span></p>
<p class="field-hint" style="margin:0 0 0.5rem;">Next:
{%- if r.meaning.action == 'act' %}
{%- if r.service %} <a href="/plugins/docker/service/{{ r.service|urlencode }}">{{ r.service }}: its tasks and logs →</a>
{%- else %} look at the app's container and logs under <a href="/plugins/docker/swarm">Docker → Swarm</a>; Steward can't tell which service serves this router.
{%- endif %}
{%- else %} if this is normal for this router, raise the 4xx cutoff in <a href="/settings/thresholds/#traefik">Settings → Thresholds</a>. It is one cutoff for every router
{%- if not r.normed %}; once Steward has learned this router's normal for the hour, it flags only departures from it{% endif %}.
{%- endif %}</p>
{% endif %}
<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>{{ latency_cell(r.latest.latency_p95_ms) }}<td>{{ r.sparkline_p95|safe }}</td></tr>
<tr><td class="field-hint">p99</td><td>{{ ms(r.latest.latency_p99_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 %}
{# A router's 4xx share or p95 out of its own normal (#5741), and the last
30 days' anomalies, to review here. #}
{% include "_anomaly_card.html" %}
{{ proxy_figures() }}
{# ── 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 }}{% if r.why %}<div class="field-hint">{{ r.why }}</div>{% endif %}</td>
<td>{{ "%.2f"|format(r.latest.request_rate) }}</td>
<td>{{ r.latest.response_bytes_rate|rate }}</td>
{{ latency_cell(r.latest.latency_p95_ms) }}
<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 %}
{{ proxy_figures() }}
<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 %}