The operator's concept, redrawn as a single-colour SVG in the accent gold: a key whose bow is a cloverleaf holding three connected nodes, hanging on a loop threaded through a keyring and swinging into the ring's middle, with a small sparkle. - static/logo/steward-logo.svg: the full mark (node graph in the bow). - static/logo/steward-mark.svg: the small mark for 16-32 px (heavier lines, one centre node, two teeth); the node graph turns to mush below ~40 px. Used in the sidebar brand (was an inline crown) and as the favicon on every page and on the public share view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
8.5 KiB
HTML
174 lines
8.5 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>
|
||
<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>
|
||
{% 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>
|
||
<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>
|