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,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 %}
|
||||
Reference in New Issue
Block a user