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>
151 lines
6.1 KiB
Python
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)
|