The Alerts page's active list now says each alert in one line around the resource it watches, e.g. "cpu_pct on web-1 is 94% (> 90%), firing for 12m", with web-1 linking to its host, monitor or container page (or the plugin's page for Traefik, UniFi and SNMP). Values take their unit from the metric name through formatting.metric_value. A silent series reads "No cpu_pct from web-1 for 20m"; an up/down metric says the word. Actions follow the routes' roles: acknowledge, edit, toggle and New rule for operators and admins, Delete for admins only, nothing for viewers. The sidebar shows how many alerts need attention beside Alerts, from an async context processor that skips htmx fragments, logged-out pages and the database-down page, and renders without the count if the query fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
8.8 KiB
HTML
179 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') }}">
|
||
{% 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="nav-count" 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>
|