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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user