Files
bvandeusen a35c369dd4
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 45s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 59s
polish(ui): sidebar nav a11y + scroll/focus refinements (nav slice 3)
- Only the nav list scrolls when long (min-height:0 + overflow on .side-nav);
  the brand and user/logout stay pinned top/bottom.
- Keyboard focus rings (:focus-visible) on every interactive sidebar element
  (brand, nav links, user/logout) and the mobile ☰ toggle.
- Mobile toggle is now a real toggle: aria-controls/aria-expanded kept in sync,
  Escape closes the off-canvas sidebar, scrim close stays in sync too.
- prefers-reduced-motion disables the sidebar slide + link transitions.
- Confirmed no dead top-nav CSS/markup remnants from the old top bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-20 19:14:49 -04:00

422 lines
21 KiB
HTML
Raw Permalink 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">
<title>{% block title %}Steward{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #131315;
--bg-card: #1d1d20;
--bg-elevated: #24242a;
--border: #30303a;
--border-mid: #3a3a44;
--text: #d4d0c0;
--text-muted: #b8b8b0;
--text-dim: #8a8a92;
--gold: #c8a840;
--gold-hover: #d8b850;
--gold-dim: #2a2410;
--pewter: #8a8a92;
--accent: var(--gold);
--accent-hover: var(--gold-hover);
--green: #4aa86a;
--green-dim: #162a1c;
--yellow: #d4a840;
--yellow-dim: #2a2410;
--orange: #c87840;
--orange-dim: #2a1a10;
--red: #c84048;
--red-dim: #2a1014;
--font-serif: 'EB Garamond', Georgia, serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var(--bg-elevated); padding: 0.1em 0.35em; border-radius: 3px; }
#candle-glow {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(200, 168, 64, 0.045) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 20% 80%, rgba(200, 168, 64, 0.025) 0%, transparent 65%);
}
/* ── App shell: persistent left sidebar + content column ───────────────────── */
.app-shell { display: flex; min-height: 100vh; position: relative; z-index: 1; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--bg-card);
border-right: 1px solid var(--border-mid);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh; z-index: 20;
}
.sidebar .brand {
font-family: var(--font-serif); font-weight: 700; font-size: 1.15rem;
letter-spacing: 0.01em; color: var(--gold); text-decoration: none;
display: flex; align-items: center; gap: 0.5rem;
padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--border);
}
.sidebar .brand svg { flex-shrink: 0; }
/* Only the nav list scrolls when long — the brand and user/logout stay pinned. */
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 0.75rem 0; }
.nav-group { margin-bottom: 0.9rem; }
.nav-group-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.09em;
color: var(--text-dim); font-weight: 600; padding: 0 1.25rem; margin-bottom: 0.3rem;
}
.side-nav a {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 1.25rem; font-size: 0.9rem; color: var(--text-muted);
border-left: 2px solid transparent; text-decoration: none;
transition: color .12s, background .12s, border-color .12s;
}
.side-nav a:hover { color: var(--text); background: var(--bg-elevated); }
.side-nav a.active {
color: var(--gold); border-left-color: var(--gold);
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
.side-user a {
color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
display: flex; align-items: center; gap: 0.4rem;
}
.side-user a:hover { color: var(--text); }
/* Keyboard focus: a clear inset ring on every interactive sidebar element. */
.sidebar .brand:focus-visible,
.side-nav a:focus-visible,
.side-user a:focus-visible {
outline: 2px solid var(--accent); outline-offset: -2px; color: var(--text);
}
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar { display: none; } /* slim mobile bar; shown only under 900px */
.nav-scrim { display: none; }
/* Layout */
main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; width: 100%; box-sizing: border-box; position: relative; z-index: 1; }
/* Responsive: sidebar slides off-canvas under 900px, toggled by ☰ */
@media (max-width: 900px) {
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; height: 100%;
transform: translateX(-100%); transition: transform .2s ease;
box-shadow: 0 0 30px rgba(0,0,0,0.5);
}
body.nav-open .sidebar { transform: translateX(0); }
.topbar {
display: flex; align-items: center; gap: 0.75rem; height: 48px; padding: 0 1rem;
background: var(--bg-card); border-bottom: 1px solid var(--border-mid);
position: sticky; top: 0; z-index: 15;
}
.sidebar-toggle {
background: none; border: none; color: var(--text-muted); font-size: 1.3rem;
cursor: pointer; line-height: 1; padding: 0.25rem; border-radius: 4px;
}
.sidebar-toggle:hover { color: var(--text); }
.sidebar-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.topbar .brand-sm {
font-family: var(--font-serif); color: var(--gold); font-weight: 700;
font-size: 1.05rem; text-decoration: none;
}
body.nav-open .nav-scrim {
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 18;
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar { transition: none; }
.side-nav a { transition: none; }
}
/* Alerts */
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.alert-error { background: var(--red-dim); border: 1px solid #6a1820; color: #ffb0b8; }
.alert-success { background: var(--green-dim); border: 1px solid #1a5a28; color: #90f0b0; }
/* Cards */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
.card-flush { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 1rem; }
/* Typography */
.page-title { font-family: var(--font-serif); font-size: 1.6rem; font-weight: 600; color: #c8b4f8; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
.section-title { font-family: var(--font-serif); font-size: 0.82rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
h1, h2, h3 { font-family: var(--font-serif); }
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th { text-align: left; padding: 0.6rem 1rem; color: var(--text-muted); font-weight: 500; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-mid); }
.table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.table tbody tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: var(--bg-elevated); }
.table .td-actions { text-align: right; white-space: nowrap; }
/* Forms */
.form-group { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.3rem; font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-serif); }
input[type=text], input[type=email], input[type=password], input[type=number], input[type=url], select, textarea {
width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); border: 1px solid var(--border-mid);
border-radius: 5px; color: var(--text); font-size: 0.9rem; font-family: inherit;
transition: border-color .15s;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
select { cursor: pointer; }
textarea { resize: vertical; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; background: var(--accent); color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 0.875rem; font-family: inherit; font-weight: 500; transition: background .15s; text-decoration: none; }
.btn:hover { background: var(--accent-hover); color: #fff; }
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-mid); }
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text); }
.btn-danger { background: #7a1820; color: #ffb0b0; }
.btn-danger:hover { background: #9a2030; }
.btn-warn { background: var(--yellow-dim); color: var(--yellow); border: 1px solid #6a5000; }
.btn-warn:hover { background: #4a4000; }
/* Badges / Status */
.badge { display: inline-block; padding: 0.2em 0.55em; border-radius: 4px; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.03em; }
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: #ff8090; }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-orange { background: var(--orange-dim); color: var(--orange); }
.badge-gold { background: var(--gold-dim); color: var(--gold); }
.badge-dim { background: var(--bg-elevated); color: var(--text-muted); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-up { background: var(--green); box-shadow: 0 0 4px var(--green); }
.dot-down { background: var(--red); }
.dot-warn { background: var(--yellow); }
.dot-dim { background: var(--text-dim); }
/* Empty state */
.empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; }
/* Breadcrumbs */
/* Breadcrumb renders as a kicker line tucked directly above the page header
(.page-title), so nested and top-level views share one consistent header. */
.breadcrumb { display:flex; align-items:center; flex-wrap:wrap; gap:0.35rem; font-size:0.78rem; color:var(--text-dim); margin:0 0 0.45rem; }
.breadcrumb a { color:var(--text-muted); }
.breadcrumb a:hover { color:var(--text); }
.breadcrumb-sep { color:var(--border-mid); }
.breadcrumb-cur { color:var(--text-muted); }
/* Stat cards (dashboard) */
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-left: 0.3rem; }
/* Ping pills */
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
.ping-name { font-size:0.875rem; font-weight:500; display:block; overflow-wrap:anywhere; }
a.ping-name:hover { text-decoration:underline; }
/* Dashboard widget grid — Gridstack-rendered. Static (positioned but inert) in
view mode; the dashboard itself becomes the edit surface when Edit is toggled,
so drag-resize happens over live widget data. The bottom drawer is a fixed
overlay, so the dashboard width is identical entering/leaving edit mode. */
.grid-stack-item-content {
background:var(--bg-card); border:1px solid var(--border-mid); border-radius:8px;
padding:0.8rem 1rem; display:flex; flex-direction:column; overflow:hidden;
}
.grid-stack-item-content .dash-cell-body { flex:1; min-height:0; overflow:auto; }
/* The widget body is a query container, so fragments can restyle to their OWN
panel width (not the viewport) — widgets adapt as you resize them. */
.dash-cell-body { container-type:inline-size; }
/* Host-list widgets flow into columns as the panel widens: use the width, drop
the vertical deadspace; stay single-column (and untruncated) when narrow. */
.host-blocks { display:grid; grid-template-columns:1fr; gap:0 1.5rem; }
@container (min-width:520px) { .host-blocks { grid-template-columns:1fr 1fr; } }
@container (min-width:900px) { .host-blocks { grid-template-columns:1fr 1fr 1fr; } }
.panel-chrome { display:none; }
.panel-drag { cursor:grab; color:var(--text-dim); user-select:none; }
.panel-drag:active { cursor:grabbing; }
body.dash-editing .panel-chrome { display:inline-flex; align-items:center; }
body.dash-editing .grid-stack-item-content { outline:1px dashed var(--border-mid); outline-offset:-1px; }
body.dash-editing main { padding-bottom:46vh; }
.widget-drawer {
position:fixed; left:0; right:0; bottom:0; transform:translateY(100%);
transition:transform .25s ease; max-height:44vh; overflow-y:auto;
background:var(--bg-card); border-top:1px solid var(--border-mid);
box-shadow:0 -10px 30px rgba(0,0,0,0.45); z-index:60; padding:1rem 1.5rem 1.5rem;
}
body.dash-editing .widget-drawer { transform:translateY(0); }
.ping-addr { display:block; color:var(--text-muted); font-size:0.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ping-pills { display:flex; gap:2px; flex:1; min-width:0; overflow:hidden; align-items:center; }
.pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; }
.pill:hover { opacity:.75; }
.pill-empty { background:var(--bg-elevated); }
.ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; }
.ping-cur-good { color:var(--green); }
.ping-cur-warn { color:var(--yellow); }
.ping-cur-bad { color:var(--orange); }
.ping-cur-down { color:var(--red); font-weight:bold; }
.ping-cur-nd { color:var(--text-dim); }
/* Widget headers */
.widget-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem; }
.widget-title { font-family:var(--font-serif); font-size:0.8rem; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; }
.widget-link { font-size:0.8rem; color:var(--text-muted); }
.widget-link:hover { color:var(--text); }
/* Time range selector */
.range-btn { background:none; border:1px solid var(--border-mid); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
.range-btn:hover { background:var(--bg-elevated); color:var(--text); border-color:var(--accent); }
.range-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; }
</style>
{% 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</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>
</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="/logout" title="Log out">⏻ {{ session.username }}</a>
</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 }}
cant 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>