feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
A change Steward can't see happen (a config edited, a disk swapped) is recorded by whoever made it: what was done, its area, when (in the reader's own zone), the hosts, what it was done to and a detail. Its author or an admin edits or deletes it; recording, editing and deleting are audited and those audit rows stay off the timeline. log_change records one as "<user> via MCP", editable by the token's user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
{# 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) -%}
|
||||
{# What happened: its kind, the sentence, a note, and the full record while it
|
||||
is kept; a change the reader recorded (or any, for an admin) can be edited,
|
||||
returning to `back`. #}
|
||||
{% macro entry_what(item, back="/timeline/") -%}
|
||||
{%- 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.note %}<div class="field-hint" style="white-space:pre-line;">{{ 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 %}
|
||||
{%- if item.editable %}<div class="field-hint"><a href="/timeline/changes/{{ e.id }}/edit?back={{ back|urlencode }}">Edit</a></div>{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{# Where: the hosts it happened on, each linked to its timeline, and what it happened to. #}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<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>
|
||||
<span class="field-hint">
|
||||
{%- if session.user_role in ('operator', 'admin') %}<a href="/timeline/changes/new?host={{ host.name|urlencode }}&back={{ ('/hosts/' ~ host.id)|urlencode }}">Record a change</a> · {% endif -%}
|
||||
<a href="/timeline/?host={{ host.name|urlencode }}">All of {{ host.name }}'s history</a>
|
||||
</span>
|
||||
</div>
|
||||
{% if items %}
|
||||
<table class="table">
|
||||
@@ -13,7 +16,7 @@
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td class="nowrap" style="width:1%;">{{ item.entry.at|when }}</td>
|
||||
<td>{{ entry_what(item) }}</td>
|
||||
<td>{{ entry_what(item, '/hosts/' ~ host.id) }}</td>
|
||||
<td class="nowrap" style="width:1%;">{{ item.entry.actor }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% 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_what(item, back) }}</td>
|
||||
<td>{{ entry_where(e) }}</td>
|
||||
<td class="nowrap">{{ e.actor }}</td>
|
||||
<td class="nowrap"><span class="chip">{{ item.area_label }}</span></td>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{# timeline/change_form.html — record a change by hand, or edit one (timeline.changes):
|
||||
what was done, the area it is about, the hosts, when, and any detail. Saving
|
||||
returns to `back`, the page the form was opened from. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% set title = "Edit a recorded change" if entry else "Record a change" %}
|
||||
{% block title %}{{ title }} — Timeline — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Timeline", "/timeline/"), (title, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ page_header(title, "Something Steward can't see happen: a config edited, a disk swapped, a service moved.") }}
|
||||
|
||||
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
|
||||
|
||||
<form method="post" action="{{ '/timeline/changes/' ~ entry.id if entry else '/timeline/changes' }}" class="form-page">{{ csrf_field() }}
|
||||
<input type="hidden" name="back" value="{{ back }}">
|
||||
<section class="card form-section" aria-labelledby="chg-what">
|
||||
<h2 id="chg-what" class="section-title">The change</h2>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-summary">What was done</label>
|
||||
<input class="input" id="chg-summary" type="text" name="summary" value="{{ values.summary }}"
|
||||
maxlength="300" required autofocus placeholder="e.g. Moved Postgres data to the new SSD">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-area">Area</label>
|
||||
<select class="input" id="chg-area" name="area" required>
|
||||
<option value="">— pick one —</option>
|
||||
{% for key, label in areas %}<option value="{{ key }}"{% if values.area == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<span class="field-hint">What part of the infrastructure it is about.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-at">When ({{ tz_label() }})</label>
|
||||
<input class="input" id="chg-at" type="datetime-local" name="at" value="{{ values.at }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-subject">What it was done to <span class="tone-dim">(optional)</span></label>
|
||||
<input class="input" id="chg-subject" type="text" name="subject" value="{{ values.subject }}"
|
||||
maxlength="255" placeholder="e.g. postgres, the core switch, /etc/fstab">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="chg-note">Detail <span class="tone-dim">(optional)</span></label>
|
||||
<textarea class="input" id="chg-note" name="note" rows="4" maxlength="4000"
|
||||
placeholder="Why, how, and anything to know if it goes wrong.">{{ values.note }}</textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card form-section" aria-labelledby="chg-hosts">
|
||||
<h2 id="chg-hosts" class="section-title">Hosts</h2>
|
||||
<p class="field-hint">The change shows in each ticked host's history. Tick none for a change not on any one host.</p>
|
||||
{% if hosts %}
|
||||
<div class="check-list">
|
||||
{% for h in hosts %}
|
||||
<label class="field-check">
|
||||
<input type="checkbox" name="hosts" value="{{ h }}"{% if h in values.hosts %} checked{% endif %}>
|
||||
<span>{{ h }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tone-muted">No hosts yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">{{ "Save" if entry else "Record the change" }}</button>
|
||||
<a href="{{ back }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if entry %}
|
||||
<section class="card danger-zone" aria-labelledby="chg-delete">
|
||||
<div>
|
||||
<h2 id="chg-delete" class="section-title">Delete this change</h2>
|
||||
<p class="field-hint">Recorded by {{ entry.actor }}. Deleting takes it off the timeline; the audit log keeps that it was.</p>
|
||||
</div>
|
||||
<form method="post" action="/timeline/changes/{{ entry.id }}/delete"
|
||||
data-confirm="Delete “{{ entry.summary }}” from the timeline?">{{ csrf_field() }}
|
||||
<input type="hidden" name="back" value="{{ back }}">
|
||||
<button class="btn btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@
|
||||
{% 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 in ('operator', 'admin') %}<a class="btn btn-sm" href="/timeline/changes/new?{{ ({'host': filters.host, 'back': back} if filters.host else {'back': back})|urlencode }}">Record a change</a>{% endif %}
|
||||
{% if session.user_role == 'admin' %}<a class="btn btn-ghost btn-sm" href="/settings/timeline/">Areas and types</a>{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user