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>
180 lines
8.8 KiB
HTML
180 lines
8.8 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
<script>
|
||
// Every HTMX request carries this session's forged-request token; fetch()
|
||
// callers read the same meta tag.
|
||
document.addEventListener('htmx:configRequest', function (ev) {
|
||
ev.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
|
||
});
|
||
// A form (or its submit button) with data-confirm="…" asks before it
|
||
// submits. The prompt is attribute text read through dataset, never
|
||
// parsed as JavaScript, so a name inside it cannot run as script (#5099).
|
||
document.addEventListener('submit', function (ev) {
|
||
var ask = (ev.submitter && ev.submitter.dataset.confirm) || ev.target.dataset.confirm;
|
||
if (ask && !window.confirm(ask)) { ev.preventDefault(); ev.stopPropagation(); }
|
||
}, true);
|
||
</script>
|
||
<title>{% block title %}Steward{% endblock %}</title>
|
||
{# Every asset is served by Steward itself: it runs with no outbound network (rule 164). #}
|
||
<script src="{{ url_for('static', filename='vendor/htmx-2.0.4.min.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='vendor/chart-4.4.4.umd.min.js') }}"></script>
|
||
<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>
|
||
|
||
<div id="candle-glow"></div>
|
||
|
||
{% set _p = request.path %}
|
||
<div class="app-shell">
|
||
{% if session.user_id is defined %}
|
||
<aside class="sidebar" id="sidebar">
|
||
<a href="/" class="brand">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="10.5" fill="none" stroke="#c8a840" stroke-width="1.5"/>
|
||
<circle cx="12" cy="12" r="9.3" fill="#8a8a92" opacity="0.1"/>
|
||
<path d="M7 10 L7 5.8 L9 7.8 L10.5 5 L12 7.2 L13.5 5 L15 7.8 L17 5.8 L17 10 Z" fill="#c8a840"/>
|
||
<ellipse cx="12" cy="15.5" rx="5.2" ry="1" fill="#c8a840"/>
|
||
<rect x="9.6" y="15.5" width="1" height="3.8" fill="#c8a840"/>
|
||
<rect x="13.4" y="15.5" width="1" height="3.8" fill="#c8a840"/>
|
||
</svg>
|
||
Steward
|
||
</a>
|
||
<nav class="side-nav" aria-label="Primary">
|
||
<div class="nav-group">
|
||
<div class="nav-group-label">Overview</div>
|
||
<a href="/" {% if _p == '/' %}class="active" aria-current="page"{% endif %}>Dashboard</a>
|
||
<a href="/status" {% if _p.startswith('/status') %}class="active" aria-current="page"{% endif %}>Status</a>
|
||
</div>
|
||
<div class="nav-group">
|
||
<div class="nav-group-label">Infrastructure</div>
|
||
<a href="/hosts/" {% if _p.startswith('/hosts') %}class="active" aria-current="page"{% endif %}>Hosts</a>
|
||
{% for item in plugin_nav %}
|
||
<a href="{{ item.href }}" {% if _p.startswith(item.href.rstrip('/')) %}class="active" aria-current="page"{% endif %}>{{ item.label }}</a>
|
||
{% endfor %}
|
||
</div>
|
||
<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="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>
|
||
<a href="/ansible/" {% if _p.startswith('/ansible') %}class="active" aria-current="page"{% endif %}>Ansible</a>
|
||
<a href="/ai-access/" {% if _p.startswith('/ai-access') %}class="active" aria-current="page"{% endif %}>AI access</a>
|
||
</div>
|
||
{% if session.user_role == 'admin' %}
|
||
<div class="nav-group">
|
||
<div class="nav-group-label">Admin</div>
|
||
<a href="/settings/" {% if _p.startswith('/settings') %}class="active" aria-current="page"{% endif %}>Settings</a>
|
||
<a href="/audit/" {% if _p.startswith('/audit') %}class="active" aria-current="page"{% endif %}>Audit</a>
|
||
</div>
|
||
{% endif %}
|
||
</nav>
|
||
<div class="side-user">
|
||
<form method="post" action="/logout">{{ csrf_field() }}
|
||
<button type="submit" title="Log out">⏻ {{ session.username }}</button>
|
||
</form>
|
||
</div>
|
||
</aside>
|
||
{% endif %}
|
||
<div class="app-main">
|
||
{% if session.user_id is defined %}
|
||
<div class="topbar">
|
||
<button class="sidebar-toggle" type="button" aria-label="Toggle navigation"
|
||
aria-controls="sidebar" aria-expanded="false" onclick="toggleNav(this)">☰</button>
|
||
<a href="/" class="brand-sm">Steward</a>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>
|
||
function setTimeRange(val) {
|
||
var input = document.getElementById('time-range');
|
||
if (input) input.value = val;
|
||
document.querySelectorAll('.range-btn').forEach(function(b) {
|
||
b.classList.toggle('active', b.dataset.range === val);
|
||
});
|
||
var url = new URL(window.location);
|
||
url.searchParams.set('range', val);
|
||
history.replaceState({}, '', url);
|
||
document.body.dispatchEvent(new CustomEvent('rangeChange'));
|
||
}
|
||
// Mobile sidebar (off-canvas). Keep aria-expanded in sync; Escape closes it.
|
||
function setNavOpen(open) {
|
||
document.body.classList.toggle('nav-open', open);
|
||
var btn = document.querySelector('.sidebar-toggle');
|
||
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
}
|
||
function toggleNav(btn) { setNavOpen(!document.body.classList.contains('nav-open')); }
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNavOpen(false);
|
||
});
|
||
</script>
|
||
|
||
<main>
|
||
{% if plugin_failures and session.user_role == 'admin' %}
|
||
<div style="background:color-mix(in srgb,var(--yellow) 12%,var(--bg-elevated));
|
||
border:1px solid color-mix(in srgb,var(--yellow) 35%,var(--border));
|
||
border-radius:6px;padding:0.6rem 1rem;margin-bottom:1rem;
|
||
font-size:0.84rem;display:flex;align-items:flex-start;gap:0.6rem;">
|
||
<span style="color:var(--yellow);flex-shrink:0;">⚠</span>
|
||
<span>
|
||
<strong style="color:var(--text);">
|
||
{{ plugin_failures | length }} plugin{{ 's' if plugin_failures | length != 1 }} failed to load.
|
||
</strong>
|
||
<a href="/settings/plugins/" style="color:var(--accent);margin-left:0.4rem;">View details →</a>
|
||
</span>
|
||
</div>
|
||
{% endif %}
|
||
{% if undecryptable_secrets and session.user_role == 'admin' %}
|
||
{% set _sec_tab = {
|
||
'smtp.password': '/settings/notifications/',
|
||
'oidc.client_secret': '/settings/auth/',
|
||
'ldap.bind_password': '/settings/auth/',
|
||
'ansible.ssh_private_key': '/settings/ansible/',
|
||
'ansible.become_password': '/settings/ansible/',
|
||
'ansible.vault_password': '/settings/ansible/',
|
||
} %}
|
||
<div style="background:color-mix(in srgb,var(--red) 12%,var(--bg-elevated));
|
||
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));
|
||
border-radius:6px;padding:0.6rem 1rem;margin-bottom:1rem;
|
||
font-size:0.84rem;display:flex;align-items:flex-start;gap:0.6rem;">
|
||
<span style="color:var(--red);flex-shrink:0;">⚠</span>
|
||
<span>
|
||
<strong style="color:var(--text);">
|
||
{{ undecryptable_secrets | length }} stored secret{{ 's' if undecryptable_secrets | length != 1 }}
|
||
can’t be decrypted
|
||
</strong>
|
||
— the app secret key changed, so {{ 'they' if undecryptable_secrets | length != 1 else 'it' }}
|
||
must be re-entered:
|
||
{% for k in undecryptable_secrets -%}
|
||
{%- if _sec_tab.get(k) %}<a href="{{ _sec_tab[k] }}" style="color:var(--accent);">{{ k }}</a>
|
||
{%- else %}<code>{{ k }}</code>{% endif %}{% if not loop.last %}, {% endif %}
|
||
{%- endfor %}.
|
||
</span>
|
||
</div>
|
||
{% endif %}
|
||
{% if error %}
|
||
<div class="alert alert-error">{{ error }}</div>
|
||
{% endif %}
|
||
{% block breadcrumb %}{% endblock %}
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
</div>{# .app-main #}
|
||
</div>{# .app-shell #}
|
||
{% if session.user_id is defined %}
|
||
<div class="nav-scrim" onclick="setNavOpen(false)"></div>
|
||
{% endif %}
|
||
|
||
{% block extra_scripts %}{% endblock %}
|
||
|
||
</body>
|
||
</html>
|