Files
FabledSteward/steward/templates/base.html
T
bvandeusenandClaude Opus 5.5 521d84f318
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m37s
CI / publish (push) Successful in 1m9s
feat(syslog): lines beside the readings: around an anomaly, in get_anomaly, and marked on host and SNMP charts (#6002)
get_anomaly's circumstances gain the host's or SNMP device's syslog
warnings and worse (mcp_events / mcp_resource_events). An anomaly row opens
"Syslog around it", read only when opened. A shared Chart.js plugin
(chart-marks.js) draws a dashed line at each syslog error on the host
metrics charts and an SNMP device's charts, with a line saying how many.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 06:36:31 -04:00

205 lines
10 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.
<!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;
});
</script>
<title>{% block title %}Steward{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='logo/steward-mark.svg') }}">
{# 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>
<script src="{{ url_for('static', filename='chart-marks.js') }}"></script>
<script src="{{ url_for('static', filename='scroll-more.js') }}" defer></script>
{# A form with data-confirm="…" asks in #confirm-dialog below before it submits (#5314). #}
<script src="{{ url_for('static', filename='confirm.js') }}" defer></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">
<img src="{{ url_for('static', filename='logo/steward-mark.svg') }}" width="22" height="22" alt="">
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>
</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">
<a href="/account/" title="Your account and time zone"{% if _p.startswith('/account') %} aria-current="page"{% endif %}>{{ session.username }}</a>
<form method="post" action="/logout">{{ csrf_field() }}
<button type="submit" title="Log out" aria-label="Log out">⏻</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>
{% if session.user_id and not session.tz %}
<script>
// An account with no time zone yet takes this browser's, once (milestone 490);
// the page then reloads so its times are written in it.
(function () {
var tz;
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (e) { return; }
if (!tz) return;
fetch('/account/time-zone/adopt', {
method: 'POST',
headers: {'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content},
body: JSON.stringify({time_zone: tz})
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.adopted) location.reload(); })
.catch(function () {});
})();
</script>
{% endif %}
<main{% block main_class %}{% endblock %}>
{% 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 %}
{#- A section's tabs sit above its trail, so they hold still from page to page. #}
{% block section_tabs %}{% endblock %}
{% 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 %}
{# Steward's own confirm dialog (#5314): static/confirm.js fills it from the
data-confirm attributes of the form being submitted. Cancel has the focus. #}
<dialog id="confirm-dialog" class="modal modal-confirm" aria-labelledby="confirm-dialog-title" aria-describedby="confirm-dialog-detail">
<form method="dialog">
<h2 id="confirm-dialog-title" class="modal-title" data-part="title"></h2>
<p id="confirm-dialog-detail" class="confirm-detail" data-part="detail"></p>
<div class="modal-actions">
<button type="submit" class="btn btn-sm btn-ghost" value="back" data-part="back" autofocus>Cancel</button>
<button type="submit" class="btn btn-sm btn-danger" value="go" data-part="go">Continue</button>
</div>
</form>
</dialog>
{% block extra_scripts %}{% endblock %}
</body>
</html>