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
+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():