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>
352 lines
18 KiB
Python
352 lines
18 KiB
Python
"""The timeline: what each source becomes, how Settings → Timeline sorts and
|
|
hides it when it is read, and the pages that show it."""
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace as NS
|
|
|
|
import pytest
|
|
from quart import render_template
|
|
from werkzeug.datastructures import MultiDict
|
|
|
|
from steward.models.alerts import AlertOperator
|
|
from steward.timeline import catalog, record, service
|
|
from steward.timeline.config import Config, edited
|
|
|
|
NOW = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
|
|
AREAS = [list(a) for a in catalog.DEFAULT_AREAS]
|
|
|
|
|
|
def _cfg(**kw) -> Config:
|
|
return Config(areas=[list(a) for a in kw.pop("areas", AREAS)], **kw)
|
|
|
|
|
|
def _entry(type_="alert.fired", area="hardware", **kw):
|
|
base = dict(id="e1", at=NOW, until=None, type=type_, area=area, hosts=["web-1"],
|
|
subject="web-1:sda", summary="Disk hot fired", detail={}, actor="system",
|
|
ref_type=None, ref_id=None)
|
|
return NS(**{**base, **kw})
|
|
|
|
|
|
# ── What a source becomes ───────────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("action,type_", [
|
|
("monitor.disabled", "steward.config"),
|
|
("settings.saved", "steward.config"),
|
|
("host_agent.maintenance_plan_created", "steward.config"),
|
|
("user.role_changed", "steward.access"),
|
|
("api_token.create", "steward.access"),
|
|
("account.password_changed", "steward.access"),
|
|
("auth.login", "steward.signin"),
|
|
("auth.login_failed", "steward.signin"),
|
|
("backup.made", "steward.backup"),
|
|
# Another writer records the event itself, or it is one person's preference.
|
|
("ansible.run.started", None),
|
|
("ansible.host_run", None),
|
|
("alert_rule.acknowledged", None),
|
|
("host_agent.maintenance", None),
|
|
("docker.prune.started", None),
|
|
("account.time_zone", None),
|
|
])
|
|
def test_each_audit_action_is_entered_as_its_type_or_left_out(action, type_):
|
|
assert catalog.audit_type(action) == type_
|
|
|
|
|
|
@pytest.mark.parametrize("source,metric,area", [
|
|
("host_agent", "temp_c_max", "hardware"),
|
|
("host_agent", "drive_failing", "hardware"),
|
|
("host_agent", "disk_used_pct", "storage"),
|
|
("host_agent", "mounts_stalled", "storage"),
|
|
("host_agent", "nfs_rtt_ms", "storage"),
|
|
("host_agent", "net_rx_bps", "network"),
|
|
("host_agent", "updates_security", "security"),
|
|
("host_agent", "cpu_pct", "host_os"),
|
|
("host_agent", "updates_pending", "host_os"),
|
|
("http", "is_up", "service"),
|
|
("icmp", "is_up", "network"),
|
|
("docker", "mem_used_pct", "service"),
|
|
("traefik", "error_rate_5xx_pct", "network"),
|
|
("steward", "db_roundtrip_ms", "monitoring"),
|
|
("snmp", "volume_used_pct", "storage"),
|
|
("snmp", "ups_battery_pct", "hardware"),
|
|
])
|
|
def test_an_alert_is_written_under_the_area_its_rule_watches(source, metric, area):
|
|
assert catalog.area_of_metric(source, metric) == area
|
|
|
|
|
|
@pytest.mark.parametrize("from_state,to_state,type_", [
|
|
("pending", "firing", "alert.fired"),
|
|
("no_data", "firing", "alert.fired"),
|
|
("firing", "resolved", "alert.resolved"),
|
|
("firing", "acknowledged", "alert.acknowledged"),
|
|
("inactive", "no_data", "alert.silent"),
|
|
("no_data", "inactive", "alert.resumed"),
|
|
("inactive", "pending", None),
|
|
])
|
|
def test_an_alert_event_becomes_one_entry_type(from_state, to_state, type_):
|
|
assert record._alert_type(from_state, to_state) == type_
|
|
|
|
|
|
def test_an_alert_entry_reads_as_a_sentence():
|
|
rule = NS(name="High CPU", metric_name="cpu_pct", resource_name="web-1",
|
|
operator=AlertOperator.gt, threshold=90.0)
|
|
assert record.alert_summary("alert.fired", rule, 94.0) == "High CPU fired: CPU on web-1 is 94% (> 90%)"
|
|
assert record.alert_summary("alert.resolved", rule, 40.0) == "High CPU resolved: CPU on web-1 is 40%"
|
|
assert record.alert_summary("alert.resolved", rule, None) == "High CPU resolved"
|
|
assert record.alert_summary("alert.silent", rule, None) == "High CPU: no CPU reading from web-1"
|
|
up = NS(name="API up", metric_name="is_up", resource_name="api", operator=AlertOperator.lt, threshold=1.0)
|
|
assert record.alert_summary("alert.fired", up, 0.0) == "API up fired: api is down"
|
|
|
|
|
|
def test_an_entry_takes_its_type_s_area_unless_the_writer_names_one():
|
|
added = []
|
|
session = NS(add=added.append)
|
|
e = record.record(session, type="ansible.changed", at=NOW, summary="x", hosts=["b", "a", "a", ""])
|
|
assert e.area == "host_os" and e.hosts == ["a", "b"] and added == [e]
|
|
assert record.record(session, type="ansible.changed", at=NOW, summary="x", area="service").area == "service"
|
|
assert record.record(session, type="plugin.gone", at=NOW, summary="x").area == "other"
|
|
|
|
|
|
# ── Settings → Timeline, applied when read ──────────────────────────────────
|
|
|
|
def test_an_entry_sorts_under_its_own_area_until_its_type_is_given_one():
|
|
e = _entry(area="hardware")
|
|
assert _cfg().area_of(e) == "hardware"
|
|
assert _cfg(types={"alert.fired": {"area": "monitoring"}}).area_of(e) == "monitoring"
|
|
|
|
|
|
def test_a_removed_area_s_entries_follow_where_it_was_moved():
|
|
cfg = _cfg(areas=[a for a in AREAS if a[0] not in ("hardware", "storage")],
|
|
moved={"hardware": "storage", "storage": "host_os"})
|
|
assert cfg.area_of(_entry(area="hardware")) == "host_os"
|
|
# An area nobody lists any more, and a loop, end in Other rather than hang.
|
|
assert cfg.area_of(_entry(area="from_a_plugin")) == "other"
|
|
assert _cfg(areas=AREAS[:1], moved={"a": "b", "b": "a"}).follow("a") == "other"
|
|
assert cfg.area_label("other") == "Other"
|
|
|
|
|
|
def test_a_type_is_shown_as_the_catalog_says_until_settings_say_otherwise():
|
|
cfg = _cfg()
|
|
assert cfg.shown("alert.fired") and not cfg.shown("ansible.check")
|
|
assert {"ansible.check", "ansible.unchanged", "steward.signin"} <= cfg.hidden_types()
|
|
cfg = _cfg(types={"ansible.check": {"shown": True}, "alert.fired": {"shown": False}})
|
|
assert cfg.shown("ansible.check") and not cfg.shown("alert.fired")
|
|
assert "alert.fired" in cfg.hidden_types() and "ansible.check" not in cfg.hidden_types()
|
|
|
|
|
|
def _form(cfg: Config, **extra) -> MultiDict:
|
|
"""The form as the page posts it unchanged: every type at its current
|
|
visibility, every area kept, then `extra`."""
|
|
data = {f"type_shown_{k}": "on" for k in catalog.TYPES if cfg.shown(k)}
|
|
data.update({f"area_label_{k}": label for k, label in cfg.areas})
|
|
data.update(extra)
|
|
return MultiDict({k: v for k, v in data.items() if v is not None})
|
|
|
|
|
|
def test_saving_the_page_unchanged_stores_no_overrides():
|
|
cfg = _cfg()
|
|
new, error = edited(cfg, _form(cfg))
|
|
assert error is None and new.types == {} and new.moved == {} and new.areas == AREAS
|
|
|
|
|
|
def test_an_area_is_renamed_added_and_removed_into_another():
|
|
cfg = _cfg(types={"alert.fired": {"area": "hardware"}})
|
|
new, error = edited(cfg, _form(cfg, area_label_network="Networking", new_area="Power & UPS",
|
|
area_move_hardware="storage",
|
|
**{"type_area_alert.fired": "hardware"}))
|
|
assert error is None
|
|
assert dict(new.areas)["network"] == "Networking"
|
|
assert new.areas[-1] == ["power_ups", "Power & UPS"]
|
|
assert "hardware" not in dict(new.areas) and new.moved == {"hardware": "storage"}
|
|
# A type sorted under the removed area follows its entries.
|
|
assert new.types["alert.fired"] == {"area": "storage"}
|
|
|
|
|
|
def test_a_type_is_hidden_or_given_an_area():
|
|
cfg = _cfg()
|
|
form = _form(cfg, **{"type_area_ansible.changed": "service"})
|
|
del form["type_shown_alert.resumed"]
|
|
new, error = edited(cfg, form)
|
|
assert error is None
|
|
assert new.types == {"ansible.changed": {"area": "service"}, "alert.resumed": {"shown": False}}
|
|
|
|
|
|
@pytest.mark.parametrize("extra,said", [
|
|
({"area_move_network": "network"}, "Pick another area"),
|
|
({"area_move_network": "nowhere"}, "Pick another area"),
|
|
({"new_area": "Storage"}, "already an area"),
|
|
({"new_area": "Other"}, "already an area"),
|
|
({"type_area_alert.fired": "nowhere"}, "isn't listed"),
|
|
])
|
|
def test_a_form_that_can_t_be_saved_says_why_and_changes_nothing(extra, said):
|
|
cfg = _cfg()
|
|
new, error = edited(cfg, _form(cfg, **extra))
|
|
assert said in error and new is cfg
|
|
|
|
|
|
def test_the_last_area_can_t_be_removed():
|
|
cfg = _cfg(areas=[["service", "Service"], ["network", "Network"]])
|
|
form = _form(cfg, area_move_service="network", area_move_network="service")
|
|
assert edited(cfg, form)[1] == "Keep at least one area."
|
|
|
|
|
|
def test_a_plugin_s_type_keeps_its_settings_while_the_plugin_is_away():
|
|
cfg = _cfg(types={"unifi.client_joined": {"shown": False}})
|
|
new, _ = edited(cfg, _form(cfg))
|
|
assert new.types == {"unifi.client_joined": {"shown": False}}
|
|
|
|
|
|
# ── Read back ───────────────────────────────────────────────────────────────
|
|
|
|
def test_an_entry_whose_record_aged_out_says_so_instead_of_linking():
|
|
e = _entry(ref_type="ansible_run", ref_id="r1", detail={"url": "/ansible/runs/r1", "note": "n"})
|
|
kept = service.view(e, _cfg(), {("ansible_run", "r1")})
|
|
gone = service.view(e, _cfg(), set())
|
|
assert kept["url"] == "/ansible/runs/r1" and not kept["gone"] and kept["note"] == "n"
|
|
assert gone["url"] is None and gone["gone"]
|
|
assert kept["kind_label"] == "Alert" and kept["area_label"] == "Hardware"
|
|
# An entry with no record behind it (a hand-written one) is never "gone".
|
|
assert not service.view(_entry(), _cfg(), set())["gone"]
|
|
|
|
|
|
# ── Pages ───────────────────────────────────────────────────────────────────
|
|
|
|
def _item(e, **kw):
|
|
v = service.view(e, _cfg(), {(e.ref_type, e.ref_id)})
|
|
v.update({"group": "Today", "opens_group": True, **kw})
|
|
return v
|
|
|
|
|
|
async def test_the_page_lists_what_happened_where_and_by_whom(app):
|
|
items = [_item(_entry(ref_type="alert_event", ref_id="a1", detail={"url": "/alerts/history?rule=r"})),
|
|
_item(_entry(type_="steward.config", area="monitoring", hosts=[], subject=None,
|
|
summary="Paused monitor api", actor="bryan via MCP"), opens_group=False)]
|
|
async with app.test_request_context("/timeline/"):
|
|
page = await render_template("timeline/list.html", items=items, after=None, last_group="Today",
|
|
filters={"host": "", "area": "", "kind": "", "hidden": False},
|
|
keep={}, areas=AREAS, kinds=catalog.KINDS, hosts=["web-1"])
|
|
assert "Disk hot fired" in page and "Paused monitor api" in page
|
|
assert '<a href="/timeline/?host=web-1">web-1</a>' in page
|
|
assert '<a href="/alerts/history?rule=r">Details</a>' in page
|
|
assert "bryan via MCP" in page and ">Hardware<" in page and "Alert · " in page
|
|
assert page.count('class="table-divider"') == 1
|
|
assert "End of the list." in page
|
|
|
|
|
|
async def test_the_next_page_keeps_the_filters(app):
|
|
items = [_item(_entry())]
|
|
async with app.test_request_context("/timeline/"):
|
|
rows = await render_template("timeline/_rows.html", items=items, last_group="Today",
|
|
after="2026-10-24T12:00:00+00:00|e1", keep={"host": "web-1"})
|
|
assert 'hx-get="/timeline/?before=2026-10-24T12%3A00%3A00%2B00%3A00%7Ce1&host=web-1&group=Today"' in rows
|
|
|
|
|
|
async def test_an_empty_timeline_says_so(app):
|
|
async with app.test_request_context("/timeline/"):
|
|
page = await render_template("timeline/list.html", items=[], after=None, last_group="",
|
|
filters={"host": "x", "area": "", "kind": "", "hidden": False},
|
|
keep={"host": "x"}, areas=AREAS, kinds=catalog.KINDS, hosts=[])
|
|
assert "Nothing on the timeline matches these filters." in page
|
|
|
|
|
|
async def test_a_host_s_card_shows_its_latest_and_links_the_rest(app):
|
|
host = NS(id="h1", name="web-1")
|
|
async with app.test_request_context("/timeline/host/h1"):
|
|
card = await render_template("timeline/_host_card.html", host=host,
|
|
items=[_item(_entry(ref_type="ansible_run", ref_id="gone"))])
|
|
empty = await render_template("timeline/_host_card.html", host=host, items=[])
|
|
assert "Disk hot fired" in card and 'href="/timeline/?host=web-1"' in card
|
|
assert "Nothing has happened to web-1" in empty
|
|
|
|
|
|
async def test_settings_lists_each_type_under_its_kind(app):
|
|
from steward.timeline.routes import _settings_ctx
|
|
cfg = _cfg(types={"alert.fired": {"area": "monitoring"}})
|
|
async with app.test_request_context("/settings/timeline/"):
|
|
page = await render_template("settings/timeline.html", **_settings_ctx(cfg, 0))
|
|
for t in catalog.TYPES.values():
|
|
assert t.label in page
|
|
assert 'name="type_shown_alert.fired" aria-label="Show Alert fired" checked' in page
|
|
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&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
|