Files
FabledSteward/steward/templates/base.html
T
bvandeusenandClaude Opus 5.5 4ea735d87c
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m31s
CI / integration (push) Successful in 1m41s
CI / publish (push) Skipped
feat(timeline): one record of what happened to the infrastructure, newest first (#6348)
A stored log, timeline_entries, with one row per thing that happened. Each
source writes its row in the same transaction as its own record, so the
history outlives the 30/90/365-day retention of the tables it points to:

- alert events (core/alerts._add_event): fired, resolved, acknowledged,
  went silent, readings back;
- finished Ansible runs (executor, and the startup sweep of interrupted
  runs): changed something, changed nothing, failed, dry run;
- audit rows (core/audit.log_audit): Steward's settings, users and access,
  sign-ins, backups. Actions another writer records are left to it.

Entries carry a kind (fixed) and an area (Service, Host OS, Hardware,
Storage, Network, Security, Monitoring). Settings → Timeline edits the
areas and each type's area and visibility; both are applied when the
timeline is read, so a change re-sorts past entries and a hidden type
comes back when shown. Kept forever unless a retention is set.

The first start fills the timeline from the last 90 days of records once,
skipping any already covered. A host rename carries its entries.

UI: Timeline in the sidebar (filter by host, area, kind; scrolls on), a
History card on each host page. MCP: get_timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:03:05 -04:00

206 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>
<a href="/timeline/" {% if _p.startswith('/timeline') %}class="active" aria-current="page"{% endif %}>Timeline</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>