feat(timeline): record a change by hand from the Timeline, a host's page or MCP log_change (#6350)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m20s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped

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:
2026-10-24 21:26:02 -04:00
co-authored by Claude Opus 5.5
parent 2b97c36fd4
commit 329aa15e3d
15 changed files with 703 additions and 38 deletions
+2 -1
View File
@@ -78,7 +78,7 @@ Each role includes the tools of the roles above it in this list.
| Metric anomalies | `list_anomalies` (metrics that left their normal for the hour, to review first), `get_anomaly` (one anomaly with each occurrence against what is usual at that hour, the metric around it, what else happened on its host — alerts, Ansible runs, container deaths and starts, maintenance, reboots, other anomalies — and how earlier ones on the same metric were judged), `get_metric_normal` (the learned normal slot by slot, and the level that would be flagged), `get_anomaly_settings` (the review window and each floor) |
| Automation | `list_playbooks`, `get_playbook`, `list_inventory`, `list_ansible_runs`, `get_ansible_run`, `wait_for_run`, `list_schedules` |
| Audit | `list_audit_events` |
| Timeline | `get_timeline` (everything that happened, newest first, in one list: alerts firing and clearing, playbook runs and what they changed, changes to Steward's settings; by host, area or kind, and kept after the records it came from age out) |
| Timeline | `get_timeline` (everything that happened, newest first, in one list: alerts firing and clearing, playbook runs and what they changed, maintenance, reboots, deploys and crash-loops, reviewed anomalies, changes recorded by hand, changes to Steward's settings; by host, area or kind, and kept after the records it came from age out) |
| Docker plugin | `list_containers`, `get_container`, `list_swarm_services`, `search_container_logs`, `get_docker_disk_usage` |
| Host agent (built in, always on) | `list_agents`, `list_maintenance_plans` (each plan's group, weekly slot, next run and latest run: how far it got, who it skipped and why), `get_maintenance_job` (a host's update jobs or one job: state, packages updated, whether it rebooted, why it failed), `get_host_senders` (who sent or received a host's network traffic over a window: its containers and NFS mounts ranked at the host's peak, and what none of them accounts for) |
| Traefik plugin | `list_routers` (each router graded as the Traefik page grades it, with the status codes behind a problem, what they mean and the swarm service behind it), `get_router` (one router's history, codes, normal and anomalies), `list_certificates` (served certificates, days left, the routers they cover), `get_traffic` (the access log's clients, countries and routers, and Traefik's own figures) |
@@ -95,6 +95,7 @@ Each role includes the tools of the roles above it in this list.
| Monitors | `create_monitor`, `update_monitor`, `set_monitor_enabled` |
| Alerts | `create_alert_rule`, `update_alert_rule`, `acknowledge_alert`, `send_test_alert`, `create_maintenance_window`, `end_maintenance_window` |
| Metric anomalies | `review_anomaly` (mark one or several normal, so their hours are learned, or real, with a note saying why) |
| Timeline | `log_change` (record a change Steward can't see happen — a config edited by hand, a disk swapped — on the hosts it was done on, under an area; shown as by "<you> via MCP") |
### Admin: destructive and remediation
+10 -3
View File
@@ -1,6 +1,6 @@
# Timeline
The timeline is one list of everything that happened to the infrastructure, newest first: alerts firing and clearing, playbook runs and what they changed, maintenance runs and reboots, deploys and crash-loops, anomalies once reviewed, and changes to Steward's own settings. Open it from **Timeline** in the sidebar, from a host's **History** card, or through the `get_timeline` MCP tool.
The timeline is one list of everything that happened to the infrastructure, newest first: alerts firing and clearing, playbook runs and what they changed, maintenance runs and reboots, deploys and crash-loops, anomalies once reviewed, changes recorded by hand, and changes to Steward's own settings. Open it from **Timeline** in the sidebar, from a host's **History** card, or through the `get_timeline` MCP tool.
Each entry stands on its own. An alert event, an Ansible run, a maintenance job or an audit row is pruned after its own retention, but the entry for it stays. Its link then reads "Details no longer kept".
@@ -22,24 +22,31 @@ Every source writes its entry in the same transaction as its own record, through
| Maintenance jobs, when they end (and again if the host comes back late) | host agent: `maintenance`, `cadence` | `maintenance.done`, `maintenance.failed` |
| A host away: a reboot, a crash, lost contact, the agent restarting; kept up to date until it ends | host agent: `downtime.record_events` | `host.rebooted`, `host.lost_contact`, `host.agent_restarted` |
| Container snapshots: a new image, a recreated container, a restart or death | Docker: `ingest.persist_host_docker` → `timeline.from_snapshot` | `docker.deployed`, `docker.crash_loop`, `docker.restarted` |
| A change recorded by hand, from the Timeline page, a host's History card or MCP `log_change` | `timeline/changes.py` | `change.recorded` |
A source whose record changes after it is written keeps one entry for it (`record.record_or_update`): a downtime gets its length when it ends, an anomaly changes type when it is reviewed.
Docker's units are swarm services where a container has one, so a service's tasks, replaced on every deploy and crash, read as one subject. A swarm task moving to another host is not a deploy. A crash loop uses the same rule as the Docker pages: Settings → Thresholds' deaths within an hour. The death that reaches it opens one `docker.crash_loop` entry, and each later one extends it until the container has been quiet for an hour. The deaths before it stay as hidden `docker.restarted` entries.
Some audit actions are left to another writer, because the event itself is recorded there: starting a run, acknowledging an alert, and starting maintenance. A person's own preferences (time zone, email settings) are not recorded. `catalog.audit_type` holds that mapping.
Some audit actions are left to another writer, because the event itself is recorded there: starting a run, acknowledging an alert, starting maintenance, and recording a change by hand. A person's own preferences (time zone, email settings) are not recorded. `catalog.audit_type` holds that mapping.
On its first start, Steward fills the timeline from the last 90 days of records that predate it (`service.backfill`): core's alert events, finished runs and audit rows, and each loaded plugin's own through its `timeline_backfill(session, since)` (the host agent's maintenance jobs and downtime). It fills from each source once and records which in `timeline.filled`, so a plugin loaded later fills on the next daily turn. An entry for a record that is already covered is skipped. Docker has no fill: its events can't tell a deploy from a restart after the fact.
`get_anomaly`'s "what else was going on" reads the timeline for the host or resource, hidden types included, beside each occurrence.
## Recording a change by hand
Steward can't see every change happen: a config edited on a host, a disk swapped, a service moved. Whoever made it records it, with **Record a change** on the Timeline page or a host's History card, or the `log_change` MCP tool. It says what was done, the area it is about, when, the hosts it was done on (none for a change not on any one host), optionally what it was done to (a container, a file) and a longer detail.
An operator or admin can record one. Its author can edit or delete it from its **Edit** link, and so can an admin. One recorded through MCP is shown as by "<user> via MCP" and is editable by that token's user. Recording, editing and deleting are audited (`timeline.change_recorded`, `…_edited`, `…_deleted`), and those audit rows stay off the timeline, which holds the change itself.
## Areas and kinds
An entry has a **kind**, which says how it came about: Change, Automation, Alert, Anomaly, Ticket, Observed (Steward saw it happen with no rule or person behind it: a reboot, a crash-loop) or Steward. Kinds are fixed in `catalog.KINDS`.
An entry also has an **area**, which says what part of the infrastructure it is about. The areas start as Service, Host OS, Hardware, Storage, Network, Security and Monitoring.
Each entry is written with an area of its own. An alert's area comes from what its rule watches (`catalog.area_of_metric`): drive health is Hardware, a mount is Storage, an HTTP monitor is Service. Other entries use their type's area.
Each entry is written with an area of its own. An alert's area comes from what its rule watches (`catalog.area_of_metric`): drive health is Hardware, a mount is Storage, an HTTP monitor is Service. A change recorded by hand has the area its author picked. Other entries use their type's area.
## Settings → Timeline
+10 -1
View File
@@ -94,6 +94,12 @@ async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]:
labels[(etype, r.id)] = {
"label": run_title(r.playbook_path, scopes.get(r.inventory_scope, r.inventory_scope)),
"url": f"/ansible/runs/{r.id}", "gone": False}
elif etype == "timeline_entry":
from steward.models.timeline import TimelineEntry
for row_id, summary in (await db.execute(
select(TimelineEntry.id, TimelineEntry.summary)
.where(TimelineEntry.id.in_(list(wanted))))).all():
labels[(etype, row_id)] = {"label": summary, "url": None, "gone": False}
elif etype in _SELF_NAMED:
for i in wanted:
labels[(etype, i)] = {"label": i, "url": _SELF_NAMED[etype], "gone": False}
@@ -206,6 +212,9 @@ ACTIONS = {
"syslog.pattern_added": "added syslog pattern",
"syslog.pattern_updated": "changed syslog pattern",
"syslog.pattern_removed": "removed syslog pattern",
"timeline.change_recorded": "recorded a change:",
"timeline.change_edited": "edited the recorded change",
"timeline.change_deleted": "deleted the recorded change",
"backup.made": "sent a backup",
"backup.failed": "tried to send a backup, which failed:",
"backup.restored": "restored Steward from a backup",
@@ -218,7 +227,7 @@ AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("user.", "Users"), ("
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
("docker.", "Docker"), ("snmp.", "SNMP"), ("prometheus.", "Prometheus"),
("syslog.", "Syslog"), ("backup.", "Backups"))
("syslog.", "Syslog"), ("backup.", "Backups"), ("timeline.", "Timeline"))
def action_text(action: str) -> str:
+91 -21
View File
@@ -1,13 +1,39 @@
"""The timeline: everything that happened to the infrastructure, in one list."""
"""The timeline: everything that happened to the infrastructure, in one
list, and changes recorded on it by hand."""
from __future__ import annotations
from datetime import datetime, timezone
from steward.mcp._context import current_caller
from steward.models.timeline import TimelineEntry
from steward.models.users import UserRole
from steward.timeline import catalog, config as tl_config, service as tl
from steward.timeline import catalog, changes, config as tl_config, service as tl
from ._audit import audit_mcp, mcp_username
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
def _entry(v: dict) -> dict:
e = v["entry"]
return {
"entry_id": e.id,
"at": iso(e.at),
"age_s": age_s(e.at),
"until": iso(e.until),
"type": e.type,
"kind": v["kind"],
"area": v["area"],
"summary": e.summary,
"note": v["note"],
"hosts": e.hosts,
"subject": e.subject,
"by": e.actor,
"url": v["url"],
"details_kept": not v["gone"],
"ref": {"type": e.ref_type, "id": e.ref_id} if e.ref_type else None,
}
async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str = "",
range: str = "7d", include_hidden: bool = False, limit: int = 50) -> dict:
"""What happened to the infrastructure, newest first, from every source in
@@ -48,25 +74,7 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
rows = (await db.execute(stmt.order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc())
.limit(limit + 1))).scalars().all()
items = await tl.views(db, cfg, rows)
entries = [
{
"at": iso(v["entry"].at),
"age_s": age_s(v["entry"].at),
"until": iso(v["entry"].until),
"type": v["entry"].type,
"kind": v["kind"],
"area": v["area"],
"summary": v["entry"].summary,
"note": v["note"],
"hosts": v["entry"].hosts,
"subject": v["entry"].subject,
"by": v["entry"].actor,
"url": v["url"],
"details_kept": not v["gone"],
"ref": {"type": v["entry"].ref_type, "id": v["entry"].ref_id} if v["entry"].ref_type else None,
}
for v in items
]
entries = [_entry(v) for v in items]
out = {"range": key, **bounded(entries, limit, key="entries"),
"areas": dict(cfg.labels), "types": sorted(catalog.TYPES)}
if out["truncated"]:
@@ -76,5 +84,67 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
return out
def _area(cfg, area: str) -> str:
"""An area by its key or its label, as Settings → Timeline names it."""
want = (area or "").strip().lower()
for key, label in cfg.areas:
if want in (key, label.lower()):
return key
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).")
async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "",
detail: str = "", at: str = "") -> dict:
"""Record on the timeline a change Steward can't see happen: a config
edited by hand, a package installed, a disk swapped, a service moved, a
setting changed in another system. Record one whenever you, or the person
you work with, change the infrastructure outside Steward's own tools, so
"what changed on this host lately?" has the answer later. Playbook runs,
maintenance, deploys and Steward's own settings are recorded already.
summary: what was done, one line (up to 300 characters), e.g. "Moved
Postgres data to the new SSD".
area: what part of the infrastructure it is about, by key or label:
service, host_os, hardware, storage, network, security, monitoring, as
Settings → Timeline names them (get_timeline's `areas`).
hosts: the host(s) it was done on, by exact name (list_hosts); none for a
change not on any one host.
subject: what it was done to, when that is not a host: a container, a
service, a file ("postgres", "/etc/fstab").
detail: why and how, and anything worth knowing if it goes wrong (up to
4000 characters).
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
Shown as by "<you> via MCP"; the person whose token this is can edit or
delete it from the Timeline page. Recorded in the audit log.
"""
if isinstance(hosts, str):
hosts = hosts.split(",")
when = None
if (at or "").strip():
try:
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
caller = current_caller()
async with db_session() as db:
async with db.begin():
cfg = await tl_config.load(db)
try:
f = await changes.fields(db, cfg, summary=summary, area=_area(cfg, area), hosts=hosts or [],
subject=subject, note=detail, at=when)
except ValueError as exc:
if "No host is named" in str(exc):
raise ValueError(f"{exc} list_hosts gives the names.") from None
raise
entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username))
[v] = await tl.views(db, cfg, [entry])
await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id,
{"name": entry.summary, "hosts": entry.hosts, "area": entry.area})
return _entry(v)
def register(register_tool) -> None:
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
register_tool(log_change, role=UserRole.operator)
+6 -3
View File
@@ -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 -2
View File
@@ -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 }}&amp;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 %}
+1 -1
View File
@@ -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 %}
+1
View File
@@ -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 %}
+4
View File
@@ -75,6 +75,8 @@ for _t in (
"What an alert rule watches stopped reporting."),
("alert.resumed", "Readings resumed", "alert", "monitoring", True,
"What an alert rule watches reported again after a silence."),
("change.recorded", "Change recorded", "change", "service", True,
"A change someone recorded by hand, on the Timeline or a host's page, or through MCP."),
("ansible.changed", "Playbook changed something", "automation", "host_os", True,
"A playbook run finished and changed at least one host."),
("ansible.unchanged", "Playbook changed nothing", "automation", "host_os", False,
@@ -145,6 +147,8 @@ _AUDIT_TYPES = (
("account.time_zone", None), ("account.email", None),
("host_agent.maintenance_plan", "steward.config"), ("host_agent.maintenance", None),
("docker.prune.", None),
# A change recorded by hand is on the timeline as itself (timeline.changes).
("timeline.change", None),
("auth.login", "steward.signin"), ("auth.setup", "steward.config"),
("account.", "steward.access"), ("user.", "steward.access"), ("api_token.", "steward.access"),
("backup.", "steward.backup"),
+87
View File
@@ -0,0 +1,87 @@
"""Changes a person or an agent records by hand: a config edited on a host, a
disk swapped, a service moved. Steward can't see these happen, so whoever
made the change writes it down, from the Timeline page, a host's page, or
the log_change MCP tool.
Each is one entry of type change.recorded, sorted under the area its author
picked. Its author can edit or delete it, and so can an admin; the entry
keeps the author's user id in its detail for that. Writing, editing and
deleting are audited, but the audit rows stay off the timeline, which holds
the change itself (catalog.audit_type).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from .config import Config
from .record import record
from steward.models.timeline import TimelineEntry
TYPE = "change.recorded"
SUMMARY_MAX = 300
NOTE_MAX = 4000
SUBJECT_MAX = 255
# A change is recorded once it is made; a clock a little ahead is allowed for.
_AHEAD = timedelta(minutes=5)
async def fields(db, cfg: Config, *, summary: str, area: str, hosts=(), subject: str = "",
note: str = "", at: datetime | None = None, now: datetime | None = None) -> dict:
"""The entry's fields, checked: a summary, an area Settings → Timeline
lists, hosts Steward knows by name, and a time not in the future. Raises
ValueError saying what to fix."""
now = now or datetime.now(timezone.utc)
summary = " ".join((summary or "").split())
if not summary:
raise ValueError("Say what was done.")
if len(summary) > SUMMARY_MAX:
raise ValueError(f"Keep what was done to {SUMMARY_MAX} characters; put the rest in the detail.")
if area not in cfg.labels:
raise ValueError(f"Pick an area: {', '.join(cfg.labels.values())}.")
from steward.models.hosts import Host
wanted = sorted({h.strip() for h in hosts if h and h.strip()})
known = set((await db.execute(select(Host.name).where(Host.name.in_(wanted)))).scalars()) if wanted else set()
unknown = [h for h in wanted if h not in known]
if unknown:
raise ValueError(f"No host is named {', '.join(unknown)}.")
note = (note or "").strip()
if len(note) > NOTE_MAX:
raise ValueError(f"Keep the detail to {NOTE_MAX} characters.")
at = at or now
if at > now + _AHEAD:
raise ValueError("A change is recorded once it is made; pick a time that has passed.")
return {"summary": summary, "area": area, "hosts": wanted,
"subject": (subject or "").strip()[:SUBJECT_MAX] or None, "note": note or None, "at": at}
def log(db, f: dict, *, author_id: str | None, actor: str) -> TimelineEntry:
"""Add the entry `fields` checked, written by `actor` ("<user>" or
"<user> via MCP")."""
return record(db, type=TYPE, at=f["at"], summary=f["summary"], area=f["area"], hosts=f["hosts"],
subject=f["subject"], actor=actor, detail=_detail(f, author_id))
def edit(entry: TimelineEntry, f: dict) -> None:
author_id = (entry.detail or {}).get("author_id")
entry.at, entry.summary, entry.area = f["at"], f["summary"], f["area"]
entry.hosts, entry.subject = f["hosts"], f["subject"]
entry.detail = _detail(f, author_id)
def _detail(f: dict, author_id: str | None) -> dict:
return {"note": f["note"], "author_id": author_id}
def can_edit(entry: TimelineEntry, user_id: str | None, role: str | None) -> bool:
"""Whether this reader may edit or delete `entry`: a recorded change,
by its author or an admin."""
if entry.type != TYPE or not user_id:
return False
return role == "admin" or (entry.detail or {}).get("author_id") == user_id
async def get(db, entry_id: str) -> TimelineEntry | None:
entry = await db.get(TimelineEntry, entry_id)
return entry if entry is not None and entry.type == TYPE else None
+149 -6
View File
@@ -1,17 +1,20 @@
from __future__ import annotations
from quart import Blueprint, current_app, redirect, render_template, request, session, url_for
from datetime import datetime, timezone
from urllib.parse import urlencode
from quart import Blueprint, abort, current_app, redirect, render_template, request, session, url_for
from sqlalchemy import select
from steward.audit.service import mark_day_groups
from steward.auth.middleware import require_role
from steward.auth.middleware import require_role, safe_next_url
from steward.core import formatting as fmt
from steward.core.audit import audit_request
from steward.core.paging import keyset, next_page
from steward.models.hosts import Host
from steward.models.timeline import TimelineEntry
from steward.models.users import UserRole
from steward.timeline import catalog, config as tl_config, service as tl
from steward.timeline import catalog, changes, config as tl_config, service as tl
timeline_bp = Blueprint("timeline", __name__)
@@ -34,14 +37,15 @@ async def timeline():
include_hidden=f["hidden"])
stmt = keyset(stmt, TimelineEntry.at, TimelineEntry.id, request.args.get("before"))
rows, after = next_page((await db.execute(stmt)).scalars().all(), lambda e: (e.at, e.id))
items = await tl.views(db, cfg, rows)
items = _editable(await tl.views(db, cfg, rows))
hosts = [] if request.args.get("before") else \
list((await db.execute(select(Host.name).order_by(Host.name))).scalars())
last_group = mark_day_groups(items, fmt.zone(session.get("tz")), request.args.get("group", ""),
at=lambda item: item["entry"].at)
keep = {k: v for k, v in (("host", f["host"]), ("area", f["area"]), ("kind", f["kind"]),
("hidden", "1" if f["hidden"] else "")) if v}
ctx = {"items": items, "after": after, "last_group": last_group, "filters": f, "keep": keep,
back = "/timeline/" + ("?" + urlencode(keep) if keep else "")
ctx = {"items": items, "after": after, "last_group": last_group, "filters": f, "keep": keep, "back": back,
"areas": cfg.areas, "kinds": catalog.KINDS,
"hosts": hosts if not f["host"] or f["host"] in hosts else [f["host"], *hosts]}
if request.args.get("before"):
@@ -58,10 +62,149 @@ async def host_card(host_id: str):
if host is None:
return "", 404
cfg = await tl_config.load(db)
items = await tl.recent_for_host(db, cfg, host.name)
items = _editable(await tl.recent_for_host(db, cfg, host.name))
return await render_template("timeline/_host_card.html", host=host, items=items)
def _editable(items: list[dict]) -> list[dict]:
"""Mark the entries this reader may edit: changes they recorded, or any as an admin."""
for item in items:
item["editable"] = changes.can_edit(item["entry"], session.get("user_id"), session.get("user_role"))
return items
# ── Changes recorded by hand ────────────────────────────────────────────────
def _local(value: str | None) -> datetime | None:
"""A datetime-local field, in the reader's own zone."""
value = (value or "").strip()
if not value:
return None
try:
local = datetime.fromisoformat(value)
except ValueError:
raise ValueError(f"{value!r} is not a date and time.") from None
return local.replace(tzinfo=fmt.zone(session.get("tz"))).astimezone(timezone.utc)
def _as_local(when: datetime) -> str:
return when.astimezone(fmt.zone(session.get("tz"))).strftime("%Y-%m-%dT%H:%M")
async def _change_page(db, cfg, *, values: dict, entry=None, back: str, error: str | None = None,
status: int = 200):
from steward.models.hosts import Host
hosts = list((await db.execute(select(Host.name).order_by(Host.name))).scalars())
# A host named by an entry and since removed stays ticked, so a save keeps it.
hosts += [h for h in values["hosts"] if h not in hosts]
return await render_template("timeline/change_form.html", values=values, entry=entry, back=back,
error=error, hosts=hosts, areas=cfg.areas), status
def _back(raw: str | None) -> str:
"""Where saving returns: the page the form was opened from, else the timeline."""
return safe_next_url(raw) or "/timeline/"
async def _checked(db, cfg) -> tuple[dict | None, dict, str, str | None]:
"""(fields, values, back, error) from the posted form: the fields
changes.fields checked, or None and why not, with the form's own values
to show again."""
form = await request.form
values = {"summary": form.get("summary", ""), "area": form.get("area", ""),
"hosts": form.getlist("hosts"), "subject": form.get("subject", ""),
"note": form.get("note", ""), "at": form.get("at", "")}
back = _back(form.get("back"))
try:
f = await changes.fields(db, cfg, summary=values["summary"], area=values["area"],
hosts=values["hosts"], subject=values["subject"],
note=values["note"], at=_local(values["at"]))
except ValueError as exc:
return None, values, back, str(exc)
return f, values, back, None
@timeline_bp.get("/timeline/changes/new")
@require_role(UserRole.operator)
async def new_change():
"""The form for recording a change; ?host= ticks a host, ?back= is where saving returns."""
host = request.args.get("host", "").strip()
values = {"summary": "", "area": "", "hosts": [host] if host else [], "subject": "", "note": "",
"at": _as_local(datetime.now(timezone.utc))}
async with current_app.db_sessionmaker() as db:
cfg = await tl_config.load(db)
return await _change_page(db, cfg, values=values, back=_back(request.args.get("back")))
@timeline_bp.post("/timeline/changes")
@require_role(UserRole.operator)
async def record_change():
async with current_app.db_sessionmaker() as db:
async with db.begin():
cfg = await tl_config.load(db)
f, values, back, error = await _checked(db, cfg)
if error:
return await _change_page(db, cfg, values=values, back=back, error=error, status=400)
entry = changes.log(db, f, author_id=session.get("user_id"), actor=session.get("username", ""))
await audit_request("timeline.change_recorded", entity_type="timeline_entry", entity_id=entry.id,
detail={"name": entry.summary, "hosts": entry.hosts, "area": entry.area})
return redirect(back)
async def _own(db, entry_id: str):
"""The recorded change `entry_id`, when this reader may edit it; else 404 or 403."""
entry = await changes.get(db, entry_id)
if entry is None:
abort(404)
if not changes.can_edit(entry, session.get("user_id"), session.get("user_role")):
abort(403)
return entry
@timeline_bp.get("/timeline/changes/<entry_id>/edit")
@require_role(UserRole.operator)
async def edit_change(entry_id: str):
async with current_app.db_sessionmaker() as db:
entry = await _own(db, entry_id)
cfg = await tl_config.load(db)
values = {"summary": entry.summary, "area": entry.area, "hosts": list(entry.hosts),
"subject": entry.subject or "", "note": (entry.detail or {}).get("note") or "",
"at": _as_local(entry.at)}
return await _change_page(db, cfg, values=values, entry=entry,
back=_back(request.args.get("back")))
@timeline_bp.post("/timeline/changes/<entry_id>")
@require_role(UserRole.operator)
async def save_change(entry_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
entry = await _own(db, entry_id)
cfg = await tl_config.load(db)
f, values, back, error = await _checked(db, cfg)
if error:
return await _change_page(db, cfg, values=values, entry=entry, back=back,
error=error, status=400)
changes.edit(entry, f)
await audit_request("timeline.change_edited", entity_type="timeline_entry", entity_id=entry_id,
detail={"name": f["summary"]})
return redirect(back)
@timeline_bp.post("/timeline/changes/<entry_id>/delete")
@require_role(UserRole.operator)
async def delete_change(entry_id: str):
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
entry = await _own(db, entry_id)
summary = entry.summary
await db.delete(entry)
await audit_request("timeline.change_deleted", entity_type="timeline_entry", entity_id=entry_id,
detail={"name": summary})
return redirect(_back(form.get("back")))
@timeline_bp.get("/settings/timeline/")
@require_role(UserRole.admin)
async def settings_page():
+81
View File
@@ -268,3 +268,84 @@ async def test_settings_lists_each_type_under_its_kind(app):
assert 'name="type_shown_ansible.check" aria-label="Show Playbook dry run">' in page
assert '<option value="monitoring" selected>Monitoring</option>' in page
assert 'aria-current="page">Timeline</a>' in page
# ── Changes recorded by hand ────────────────────────────────────────────────
def test_a_recorded_change_is_its_own_type_and_its_audit_rows_stay_off():
t = catalog.TYPES["change.recorded"]
assert (t.kind, t.shown) == ("change", True)
for action in ("timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"):
assert catalog.audit_type(action) is None
async def test_a_change_is_checked_before_it_is_recorded():
from datetime import timedelta
from steward.timeline import changes
cfg = _cfg()
f = await changes.fields(None, cfg, summary=" Swapped the PSU ", area="hardware", subject=" psu ",
note="", now=NOW)
assert f == {"summary": "Swapped the PSU", "area": "hardware", "hosts": [], "subject": "psu",
"note": None, "at": NOW}
for kw, said in ((dict(summary=" ", area="hardware"), "Say what was done"),
(dict(summary="x" * 301, area="hardware"), "300 characters"),
(dict(summary="x", area="plumbing"), "Pick an area: Service, Host OS"),
(dict(summary="x", area="hardware", note="n" * 4001), "4000 characters"),
(dict(summary="x", area="hardware", at=NOW + timedelta(minutes=10)), "has passed")):
with pytest.raises(ValueError, match=said):
await changes.fields(None, cfg, now=NOW, **kw)
# A clock a little ahead is allowed for.
assert (await changes.fields(None, cfg, summary="x", area="hardware", at=NOW + timedelta(minutes=2),
now=NOW))["at"] == NOW + timedelta(minutes=2)
def test_only_its_author_or_an_admin_edits_a_recorded_change():
from steward.timeline import changes
mine = _entry(type_="change.recorded", detail={"author_id": "u1"})
assert changes.can_edit(mine, "u1", "operator")
assert not changes.can_edit(mine, "u2", "operator")
assert changes.can_edit(mine, "u2", "admin")
assert not changes.can_edit(mine, None, "admin") # a share link
assert not changes.can_edit(_entry(), "u1", "admin") # only recorded changes
async def test_a_recorded_change_the_reader_may_edit_has_an_edit_link(app):
mine = _item(_entry(id="c1", type_="change.recorded", area="storage", hosts=["web-1"], subject=None,
summary="Moved Postgres to the SSD", detail={"note": "old disk\nis spare",
"author_id": "u1"}),
editable=True)
theirs = _item(_entry(id="c2", type_="change.recorded", summary="Theirs", detail={"author_id": "u2"}),
editable=False, opens_group=False)
async with app.test_request_context("/timeline/"):
rows = await render_template("timeline/_rows.html", items=[mine, theirs], last_group="Today",
after=None, keep={"host": "web-1"}, back="/timeline/?host=web-1")
assert '<a href="/timeline/changes/c1/edit?back=/timeline/%3Fhost%3Dweb-1">Edit</a>' in rows
assert "/timeline/changes/c2/edit" not in rows
assert "Change · " in rows and "old disk\nis spare" in rows
async def test_an_operator_records_a_change_from_a_host_s_card(app):
host = NS(id="h1", name="web-1")
async with app.test_request_context("/timeline/host/h1"):
from quart import session
session["user_role"] = "operator"
card = await render_template("timeline/_host_card.html", host=host, items=[])
session["user_role"] = "viewer"
viewer = await render_template("timeline/_host_card.html", host=host, items=[])
assert 'href="/timeline/changes/new?host=web-1&amp;back=/hosts/h1">Record a change</a>' in card
assert "Record a change" not in viewer
async def test_the_change_form_names_its_fields_and_offers_delete_only_when_editing(app):
values = {"summary": "", "area": "", "hosts": ["web-1"], "subject": "", "note": "", "at": "2026-10-24T08:00"}
entry = NS(id="c1", actor="bryan", summary="Swapped the PSU")
async with app.test_request_context("/timeline/changes/new"):
new = await render_template("timeline/change_form.html", values=values, entry=None, back="/hosts/h1",
error=None, hosts=["db-1", "web-1"], areas=AREAS)
edit = await render_template("timeline/change_form.html", values=values, entry=entry,
back="/timeline/", error="Say what was done.", hosts=["web-1"], areas=AREAS)
assert 'action="/timeline/changes"' in new and 'value="2026-10-24T08:00"' in new
assert 'value="web-1" checked' in new and 'value="db-1">' in new
assert '<a href="/hosts/h1" class="btn btn-ghost">Cancel</a>' in new and 'id="chg-delete"' not in new
assert 'action="/timeline/changes/c1"' in edit and 'action="/timeline/changes/c1/delete"' in edit
assert "Say what was done." in edit
+165
View File
@@ -0,0 +1,165 @@
"""Integration: a change recorded by hand (#6350), from the page and through
MCP log_change. The form reads its time in the reader's own zone; only the
author or an admin edits or deletes one; each step is audited and the audit
rows stay off the timeline. Requires STEWARD_DATABASE_URL.
"""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _user(app, name: str, role: str, tz: str | None = None):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time(),
**({"tz": tz} if tz else {}))
return client, uid
async def _host(app, name: str):
from steward.models.hosts import Host
async with app.db_sessionmaker() as s:
async with s.begin():
host = Host(name=name, address="10.8.8.8")
s.add(host)
return host
async def _changes(app, host: str):
from sqlalchemy import select
from steward.models.timeline import TimelineEntry
async with app.db_sessionmaker() as s:
return (await s.execute(select(TimelineEntry).where(TimelineEntry.hosts.contains([host]))
.order_by(TimelineEntry.at))).scalars().all()
async def _audited(app, entry_id: str) -> list[str]:
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
return list((await s.execute(select(AuditEvent.action).where(AuditEvent.entity_id == entry_id)
.order_by(AuditEvent.timestamp))).scalars())
def test_a_change_recorded_on_the_page_is_edited_by_its_author_and_deleted_by_an_admin(app):
async def _go():
tag = uuid.uuid4().hex[:8]
name = f"tl-ch-{tag}"
host = await _host(app, name)
author, author_id = await _user(app, f"ch-a-{tag}", "operator", tz="America/New_York")
other, _ = await _user(app, f"ch-o-{tag}", "operator")
admin, _ = await _user(app, f"ch-ad-{tag}", "admin")
viewer, _ = await _user(app, f"ch-v-{tag}", "viewer")
form = {"csrf_token": "test-token", "summary": "Moved Postgres data to the new SSD", "area": "storage",
"hosts": name, "subject": "postgres", "note": "Old disk kept as a spare.",
"at": "2026-10-20T08:00", "back": f"/hosts/{host.id}"}
# The form opens from the host's card with the host ticked; a viewer can't open it.
page = await (await author.get(f"/timeline/changes/new?host={name}&back=/hosts/{host.id}")).get_data(as_text=True)
assert f'value="{name}" checked' in page and f'value="/hosts/{host.id}"' in page
assert (await viewer.get("/timeline/changes/new")).status_code == 403
# Refused, with the form kept: an unknown host writes nothing.
bad = await author.post("/timeline/changes", form={**form, "hosts": "no-such-host"})
assert bad.status_code == 400
text = await bad.get_data(as_text=True)
assert "No host is named no-such-host" in text and "Moved Postgres data" in text
res = await author.post("/timeline/changes", form=form)
assert res.status_code == 302 and res.headers["Location"] == f"/hosts/{host.id}"
[entry] = await _changes(app, name)
assert (entry.type, entry.area, entry.subject, entry.actor) == (
"change.recorded", "storage", "postgres", f"ch-a-{tag}")
assert entry.at == datetime(2026, 10, 20, 12, 0, tzinfo=timezone.utc) # 08:00 in New York
assert entry.detail == {"note": "Old disk kept as a spare.", "author_id": author_id}
# Its author sees Edit on the timeline; another operator does not, and can't edit it.
mine = await (await author.get(f"/timeline/?host={name}")).get_data(as_text=True)
assert f"/timeline/changes/{entry.id}/edit" in mine
theirs = await (await other.get(f"/timeline/?host={name}")).get_data(as_text=True)
assert f"/timeline/changes/{entry.id}/edit" not in theirs
assert (await other.get(f"/timeline/changes/{entry.id}/edit")).status_code == 403
assert (await other.post(f"/timeline/changes/{entry.id}", form=form)).status_code == 403
edit = await (await author.get(f"/timeline/changes/{entry.id}/edit")).get_data(as_text=True)
assert 'value="2026-10-20T08:00"' in edit # shown in their zone
await author.post(f"/timeline/changes/{entry.id}",
form={**form, "summary": "Moved Postgres data to the SSD", "area": "hardware"})
[entry] = await _changes(app, name)
assert (entry.summary, entry.area) == ("Moved Postgres data to the SSD", "hardware")
assert entry.detail["author_id"] == author_id # still theirs
res = await admin.post(f"/timeline/changes/{entry.id}/delete",
form={"csrf_token": "test-token", "back": "/timeline/"})
assert res.status_code == 302 and await _changes(app, name) == []
assert await _audited(app, entry.id) == [
"timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"]
# The audit rows are not on the timeline: the change was, as itself.
from sqlalchemy import select
from steward.models.timeline import TimelineEntry
async with app.db_sessionmaker() as s:
leaked = (await s.execute(select(TimelineEntry).where(
TimelineEntry.summary.contains("Moved Postgres data"),
TimelineEntry.actor.in_([f"ch-a-{tag}", f"ch-ad-{tag}"])))).scalars().all()
assert leaked == []
asyncio.run(_go())
def test_log_change_records_a_change_through_mcp(app):
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools.timeline import get_timeline, log_change
from steward.models.users import UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
name = f"tl-mc-{tag}"
await _host(app, name)
_, uid = await _user(app, f"mc-{tag}", "operator")
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=uid, username=f"mc-{tag}", role=UserRole.operator, token_id=7))
when = (datetime.now(timezone.utc) - timedelta(days=1)).replace(microsecond=0)
out = await log_change("Raised the swap to 8 GiB", "Host OS", hosts=name, subject="/swapfile",
detail="It ran out during the backup.",
at=when.isoformat().replace("+00:00", "Z"))
assert out["type"] == "change.recorded" and out["kind"] == "change" and out["area"] == "host_os"
assert out["by"] == f"mc-{tag} via MCP" and out["hosts"] == [name]
assert out["at"] == when.isoformat().replace("+00:00", "Z") and out["note"] == "It ran out during the backup."
[entry] = await _changes(app, name)
assert entry.detail["author_id"] == uid # the token's user can edit it
assert await _audited(app, entry.id) == ["timeline.change_recorded"]
seen = await get_timeline(host=name, range="30d")
assert [e["entry_id"] for e in seen["entries"]] == [entry.id]
with pytest.raises(ValueError, match="list_hosts"):
await log_change("x", "storage", hosts=["nope"])
with pytest.raises(ValueError, match="area 'plumbing'"):
await log_change("x", "plumbing")
with pytest.raises(ValueError, match="ISO-8601"):
await log_change("x", "storage", at="yesterday")
asyncio.run(_go())
+8
View File
@@ -152,3 +152,11 @@ def test_a_quick_restart_is_shown_though_it_never_broke_the_minute():
[ev] = _events.gaps_in([NOW, slow], NOW, NOW + timedelta(hours=1), [(slow, 300.0)])
assert "restarted" in ev["what"]
assert _events.gaps_in(ticks, NOW + timedelta(minutes=2), NOW + timedelta(hours=1), [(restart, 35.0)]) == []
def test_the_timeline_tools_are_registered_with_their_roles(app):
mcp = app.mcp_server
for name, role, read_only in (("get_timeline", UserRole.viewer, True),
("log_change", UserRole.operator, False)):
assert mcp.tool_roles[name] is role, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name