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>
This commit is contained in:
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user