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 %}
|
||||
|
||||
Reference in New Issue
Block a user