Files
FabledSteward/steward/templates/base.html
T
bvandeusenandClaude Opus 5.5 955450d33f
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m22s
CI / publish (push) Successful in 1m36s
fix(ui): section tabs above the breadcrumb, so the Ansible tab strip holds still (#5214)
The tabs were drawn in each page's content block, which base.html renders
after the breadcrumb, so on every nested Ansible page the strip moved down a
line. base.html now has a section_tabs block above the breadcrumb, and every
Ansible page fills it. A guard in test_breadcrumbs.py fails any page that calls
a tabs macro outside that block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:28:02 -04:00

184 lines
9.0 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='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">
<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{% 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>