feat(timeline): one record of what happened to the infrastructure, newest first (#6348)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m31s
CI / integration (push) Successful in 1m41s
CI / publish (push) Skipped

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>
This commit is contained in:
2026-10-24 17:03:05 -04:00
co-authored by Claude Opus 5.5
parent 8e2f17d198
commit 4ea735d87c
36 changed files with 1882 additions and 25 deletions
+1
View File
@@ -42,6 +42,7 @@
<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>
+5
View File
@@ -202,6 +202,11 @@
{% endif %}
</div>{# Ansible card #}
{# ── History: what happened to this host lately (timeline) ─────────────────── #}
<div hx-get="/timeline/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading history…</span></div>
</div>
{# ── Docker (docker plugin fragment; renders nothing if the host has none).
After Ansible: the container list can run long, and Ansible beneath it was
easy to miss (#5214). ─────────────────────────────────────────────────────── #}
+1
View File
@@ -8,6 +8,7 @@
("notifications", "Notifications", "/settings/notifications/"),
("reports", "Reports", "/settings/reports/"),
("backups", "Backups", "/settings/backups/"),
("timeline", "Timeline", "/settings/timeline/"),
("auth", "Authentication", "/settings/auth/"),
("users", "Users", "/settings/users/"),
("ansible", "Ansible", "/settings/ansible/"),
+76
View File
@@ -0,0 +1,76 @@
{# settings/timeline.html — the timeline's areas, and for each type of entry
the area it sorts under and whether it is shown. Applied when the timeline
is read, so a change re-sorts entries already written. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Settings — Timeline — Steward{% endblock %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block section_tabs %}{{ settings_tabs("timeline") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Timeline", "")]) }}{% endblock %}
{% block content %}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" action="/settings/timeline/">{{ csrf_field() }}
<div class="card" style="max-width:760px;">
<h2 class="section-title">Areas</h2>
<p class="field-hint" style="margin-bottom:0.75rem;">What part of the infrastructure an entry is about.</p>
<table class="table">
<thead><tr><th>Name</th><th>Remove, moving its entries to</th></tr></thead>
<tbody>
{% for key, label in cfg.areas %}
<tr>
<td><input type="text" name="area_label_{{ key }}" value="{{ label }}" aria-label="Name of {{ label }}" maxlength="40"></td>
<td>
<select name="area_move_{{ key }}" aria-label="Remove {{ label }}">
<option value="">Keep it</option>
{% for other, other_label in cfg.areas if other != key %}<option value="{{ other }}">{{ other_label }}</option>{% endfor %}
</select>
</td>
</tr>
{% endfor %}
<tr>
<td><input type="text" name="new_area" value="" placeholder="Add an area" aria-label="Add an area" maxlength="40"></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="card" style="max-width:760px;margin-top:1.25rem;">
<h2 class="section-title">What the timeline shows</h2>
<p class="field-hint" style="margin-bottom:0.75rem;">A hidden type is still recorded; showing it again brings its entries back.</p>
<table class="table">
<thead><tr><th>Type</th><th>Area</th><th>Shown</th></tr></thead>
{% for kind, types in by_kind.items() %}
<tbody>
<tr class="table-divider"><th colspan="3" scope="rowgroup">{{ kinds[kind] }}</th></tr>
{% for t in types %}
{% set override = cfg.types.get(t.key, {}) %}
<tr>
<td>{{ t.label }}<div class="field-hint">{{ t.about }}</div></td>
<td>
<select name="type_area_{{ t.key }}" aria-label="Area for {{ t.label }}">
<option value="">As recorded</option>
{% for key, label in cfg.areas %}<option value="{{ key }}"{% if override.get('area') == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</td>
<td><input type="checkbox" name="type_shown_{{ t.key }}" aria-label="Show {{ t.label }}"{% if cfg.shown(t.key) %} checked{% endif %}></td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
<p class="field-hint">“As recorded” sorts each entry under the area it was written with: an alert by what its rule watches, a change by the area its author picked.</p>
</div>
<div class="card" style="max-width:760px;margin-top:1.25rem;">
<h2 class="section-title">Keeping it</h2>
<div class="form-group">
<label for="retention_days">Keep for <span class="field-hint">(days, 0 keeps everything)</span></label>
<input type="number" id="retention_days" name="retention_days" min="0" value="{{ retention }}" style="max-width:120px;">
</div>
</div>
<div class="form-actions" style="max-width:760px;"><button type="submit" class="btn">Save</button></div>
</form>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{# One timeline entry's parts, shared by the Timeline page and a host's card.
`item` is a timeline.service.view() row. #}
{# What happened: its kind, the sentence, a note, and the full record while it is kept. #}
{% macro entry_what(item) -%}
{%- set e = item.entry -%}
<div>
{%- if item.kind_label %}<span class="tone-dim">{{ item.kind_label }} · </span>{% endif -%}
{{ e.summary }}
</div>
{%- if item.note %}<div class="field-hint">{{ item.note }}</div>{% endif %}
{%- if item.url %}<div class="field-hint"><a href="{{ item.url }}">Details</a></div>
{%- elif item.gone %}<div class="field-hint">Details no longer kept</div>{% endif %}
{%- endmacro %}
{# Where: the hosts it happened on, each linked to its timeline, and what it happened to. #}
{% macro entry_where(e) -%}
{%- for h in e.hosts %}<a href="/timeline/?host={{ h|urlencode }}">{{ h }}</a>{{ ", " if not loop.last }}{% endfor -%}
{%- if e.subject and e.subject not in e.hosts %}{% if e.hosts %}<br>{% endif %}<span class="field-hint">{{ e.subject }}</span>{% endif -%}
{%- if not e.hosts and not e.subject %}<span class="tone-dim">—</span>{% endif -%}
{%- endmacro %}
@@ -0,0 +1,25 @@
{# A host's newest timeline entries, loaded into its page: what happened to it
lately, with the whole of it a click away. #}
{% from "_macros.html" import empty_state %}
{% from "timeline/_entry.html" import entry_what %}
<section class="card" aria-labelledby="host-history">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;">
<h3 id="host-history" class="section-title">History</h3>
<a class="field-hint" href="/timeline/?host={{ host.name|urlencode }}">All of {{ host.name }}'s history</a>
</div>
{% if items %}
<table class="table">
<tbody>
{% for item in items %}
<tr>
<td class="nowrap" style="width:1%;">{{ item.entry.at|when }}</td>
<td>{{ entry_what(item) }}</td>
<td class="nowrap" style="width:1%;">{{ item.entry.actor }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{{ empty_state("Nothing has happened to " ~ host.name ~ " that the timeline records yet.") }}
{% endif %}
</section>
+21
View File
@@ -0,0 +1,21 @@
{# timeline/_rows.html — one page of the timeline's rows, then the sentinel
that loads the next page as the reader nears it (core.paging). A divider
row opens each recency group: Today, Yesterday, Last 7 days, Older. #}
{% from "_macros.html" import scroll_more, scroll_end %}
{% from "timeline/_entry.html" import entry_what, entry_where %}
{% for item in items %}
{% set e = item.entry %}
{% if item.opens_group %}<tr class="table-divider"><th colspan="5" scope="rowgroup">{{ item.group }}</th></tr>{% endif %}
<tr>
<td class="nowrap">{{ e.at|when }}</td>
<td>{{ entry_what(item) }}</td>
<td>{{ entry_where(e) }}</td>
<td class="nowrap">{{ e.actor }}</td>
<td class="nowrap"><span class="chip">{{ item.area_label }}</span></td>
</tr>
{% endfor %}
{% if after %}
{{ scroll_more("/timeline/?before=" ~ (after|urlencode) ~ "&" ~ (keep|urlencode) ~ "&group=" ~ (last_group|urlencode), cols=5) }}
{% else %}
{{ scroll_end(cols=5) }}
{% endif %}
+54
View File
@@ -0,0 +1,54 @@
{# timeline/list.html — everything that happened to the infrastructure, newest
first, one list: alerts, playbook runs, changes to Steward's settings.
Filters are by host, area (what was affected) and kind (how it came about);
Settings → Timeline decides each type's area and whether it is shown, and
"Include hidden" shows the types it hides. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, empty_state %}
{% block title %}Timeline — Steward{% endblock %}
{% block content %}
{% call page_header("Timeline", "What happened, newest first: alerts, playbook runs and changes, on every host.") %}
{% if session.user_role == 'admin' %}<a class="btn btn-ghost btn-sm" href="/settings/timeline/">Areas and types</a>{% endif %}
{% endcall %}
<form class="filter-bar" method="get" action="/timeline/" role="search">
<select name="host" aria-label="Host" onchange="this.form.submit()">
<option value="">All hosts</option>
{% for h in hosts %}<option value="{{ h }}"{% if filters.host == h %} selected{% endif %}>{{ h }}</option>{% endfor %}
</select>
<select name="area" aria-label="Area" onchange="this.form.submit()">
<option value="">All areas</option>
{% for key, label in areas %}<option value="{{ key }}"{% if filters.area == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<select name="kind" aria-label="Kind" onchange="this.form.submit()">
<option value="">All kinds</option>
{% for key, label in kinds.items() %}<option value="{{ key }}"{% if filters.kind == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<label class="field-hint" style="display:flex;align-items:center;gap:0.35rem;">
<input type="checkbox" name="hidden" value="1"{% if filters.hidden %} checked{% endif %} onchange="this.form.submit()">
Include hidden types
</label>
<noscript><button type="submit" class="btn btn-ghost btn-sm">Filter</button></noscript>
</form>
{% if items %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>When ({{ tz_label() }})</th>
<th>What happened</th>
<th>Where</th>
<th>Who</th>
<th>Area</th>
</tr>
</thead>
<tbody>
{% include "timeline/_rows.html" %}
</tbody>
</table>
</div>
{% else %}
<div class="card">{{ empty_state("Nothing on the timeline matches these filters." if keep else "Nothing has been recorded on the timeline yet.") }}</div>
{% endif %}
{% endblock %}