Files
FabledSteward/steward/timeline/config.py
T
bvandeusenandClaude Opus 5.5 4ea735d87c
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m31s
CI / integration (push) Successful in 1m41s
CI / publish (push) Skipped
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>
2026-10-24 17:03:05 -04:00

151 lines
6.1 KiB
Python

"""Settings → Timeline, as the reader applies it.
Three settings, read together:
- timeline.areas: [[key, label], …], the areas in the order the page lists them.
- timeline.types: {type: {"area": key, "shown": bool}}, each only where it
departs from the catalog. An "area" there sorts every entry of the type
under it; without one, an entry sorts under the area it was written with.
- timeline.area_moved: {removed key: key its entries now sort under}.
Nothing here is written onto an entry, so a change re-sorts the whole
history, and a hidden type's entries come back when it is shown again.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from sqlalchemy import and_, false, or_, true
from . import catalog
from steward.models.timeline import TimelineEntry
@dataclass
class Config:
areas: list[list[str]]
types: dict[str, dict] = field(default_factory=dict)
moved: dict[str, str] = field(default_factory=dict)
@property
def labels(self) -> dict[str, str]:
return {key: label for key, label in self.areas}
def follow(self, area: str) -> str:
"""Where an area's entries sort now: through any removals, to a live
area, or Other."""
seen = set()
while area in self.moved and area not in seen:
seen.add(area)
area = self.moved[area]
return area if area in self.labels else catalog.OTHER[0]
def type_area(self, type_: str) -> str | None:
"""The area Settings sorts every entry of this type under, or None."""
return (self.types.get(type_) or {}).get("area") or None
def shown(self, type_: str) -> bool:
override = (self.types.get(type_) or {}).get("shown")
if override is not None:
return bool(override)
known = catalog.TYPES.get(type_)
return known.shown if known else True
def area_of(self, entry) -> str:
return self.follow(self.type_area(entry.type) or entry.area)
def area_label(self, key: str) -> str:
return self.labels.get(key) or catalog.OTHER[1]
def hidden_types(self) -> set[str]:
keys = set(catalog.TYPES) | set(self.types)
return {k for k in keys if not self.shown(k)}
def area_filter(self, area: str):
"""A SQL condition for "entries sorting under `area`"."""
sources = [k for k in [*self.labels, *self.moved, catalog.OTHER[0]]
if self.follow(k) == area]
fixed = {t for t, o in self.types.items() if o.get("area")}
to_here = [t for t in fixed if self.follow(self.types[t]["area"]) == area]
written = TimelineEntry.area.in_(sources) if sources else false()
if area == catalog.OTHER[0]:
# An area nobody listed (a plugin's, gone) sorts under Other too.
written = or_(written, TimelineEntry.area.notin_([*self.labels, *self.moved]))
unfixed = TimelineEntry.type.notin_(fixed) if fixed else true()
return or_(TimelineEntry.type.in_(to_here) if to_here else false(),
and_(unfixed, written))
def kind_types(kind: str) -> list[str]:
return [t.key for t in catalog.TYPES.values() if t.kind == kind]
async def load(db) -> Config:
from steward.core.settings import get_setting
areas = await get_setting(db, "timeline.areas") or catalog.DEFAULT_AREAS
return Config(areas=[list(a) for a in areas],
types=dict(await get_setting(db, "timeline.types") or {}),
moved=dict(await get_setting(db, "timeline.area_moved") or {}))
def slug(label: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", label.strip().lower()).strip("_")[:32]
def edited(cfg: Config, form) -> tuple[Config, str | None]:
"""The settings Settings → Timeline's form asks for, or the reason it
can't have them. The form carries, per area, `area_label_<key>` and
`area_move_<key>` ("" to keep it, else the key its entries go to);
`new_area` for one more; and per type `type_area_<type>` ("" for as
written) and `type_shown_<type>` (present when ticked)."""
areas, moved = [], dict(cfg.moved)
for key, label in cfg.areas:
dest = (form.get(f"area_move_{key}") or "").strip()
if dest:
if dest == key or dest not in cfg.labels:
return cfg, f"Pick another area for {label}'s entries to go to."
moved[key] = dest
continue
areas.append([key, (form.get(f"area_label_{key}") or "").strip() or label])
new = (form.get("new_area") or "").strip()
if new:
key = slug(new)
if not key or key == catalog.OTHER[0] or key in {k for k, _ in cfg.areas}:
return cfg, f"There is already an area like “{new}”; pick another name."
areas.append([key, new])
moved.pop(key, None)
if not areas:
return cfg, "Keep at least one area."
live = {k for k, _ in areas}
# An area listed again is no longer moved; one moved into an area that
# was itself moved goes on to wherever that went (Config.follow).
moved = {k: d for k, d in moved.items() if k not in live}
after = Config(areas=areas, moved=moved)
types = {}
for key, t in catalog.TYPES.items():
override = {}
area = (form.get(f"type_area_{key}") or "").strip()
if area:
# A type sorted under an area removed in this same save follows its entries.
area = after.follow(area)
if area not in live:
return cfg, f"“{t.label}” names an area that isn't listed."
override["area"] = area
shown = f"type_shown_{key}" in form
if shown != t.shown:
override["shown"] = shown
if override:
types[key] = override
# Types of a plugin that isn't loaded keep what they had.
types.update({k: v for k, v in cfg.types.items() if k not in catalog.TYPES})
after.types = types
return after, None
async def save(db, cfg: Config) -> None:
from steward.core.settings import set_setting
await set_setting(db, "timeline.areas", cfg.areas)
await set_setting(db, "timeline.types", cfg.types)
await set_setting(db, "timeline.area_moved", cfg.moved)