feat(timeline): one record of what happened to the infrastructure, newest first (#6348)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m31s
CI / integration (push) Successful in 1m41s
CI / publish (push) Skipped

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:
2026-10-24 17:03:05 -04:00
co-authored by Claude Opus 5.5
parent 8e2f17d198
commit 4ea735d87c
36 changed files with 1882 additions and 25 deletions
+6 -4
View File
@@ -833,10 +833,11 @@ class PartOfCoveringRule(ValueError):
f"rule per host; change or disable it there.")
async def acknowledge(db, rule_id: str, user_id: str | None) -> AlertState:
async def acknowledge(db, rule_id: str, user_id: str | None, actor: str | None = None) -> AlertState:
"""Acknowledge a firing alert, in the caller's transaction, and record it
in the alert's history as a firing → acknowledged event by `user_id`, so
who saw it outlives the alert (#5463).
who saw it outlives the alert (#5463). `actor` names them on the timeline
when it isn't their username alone ("<user> via MCP").
LookupError when the rule has no state; ValueError when it is not firing.
"""
@@ -853,8 +854,9 @@ async def acknowledge(db, rule_id: str, user_id: str | None) -> AlertState:
state.acknowledged_by = user_id
state.acknowledged_at = now
state.last_transition_at = now
_add_event(db, rule_id, AlertStateEnum.firing.value, AlertStateEnum.acknowledged.value,
None, now, actor_id=user_id)
rule = await db.get(AlertRule, rule_id)
await _add_event(db, rule, AlertStateEnum.firing.value, AlertStateEnum.acknowledged.value,
None, now, actor_id=user_id, actor=actor)
return state
+3
View File
@@ -574,6 +574,9 @@ async def start_run(
await session.execute(
update(AnsibleRun).where(AnsibleRun.id == run_id).values(**values)
)
if final_status is not None:
from steward.timeline.record import run_finished
await run_finished(session, run_id)
lines_since_flush = 0
last_flush_at = time.monotonic()
+24 -2
View File
@@ -233,6 +233,7 @@ def create_app(
from .audit.routes import audit_bp
from .ai_access.routes import ai_access_bp
from .account.routes import account_bp
from .timeline.routes import timeline_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
@@ -247,6 +248,7 @@ def create_app(
app.register_blueprint(audit_bp)
app.register_blueprint(ai_access_bp)
app.register_blueprint(account_bp)
app.register_blueprint(timeline_bp)
# Register the unified Monitor status source for the Status page. Plugins
# may register additional sources from setup(). Idempotent.
@@ -580,15 +582,32 @@ def _register_core_tasks(app: Quart) -> None:
)
)
# The timeline's first fill from the records that predate it, after the
# first boot that has it; once done, each later turn is one settings read.
async def run_timeline_backfill():
from .timeline.service import backfill
await backfill(app)
app._task_registry.append(
ScheduledTask(
name="timeline_backfill",
coro_factory=run_timeline_backfill,
interval_seconds=86400,
run_on_startup=True,
)
)
async def _mark_interrupted_runs(app: Quart) -> None:
from sqlalchemy import update
from .models.ansible import AnsibleRun, AnsibleRunStatus
from datetime import datetime, timezone
from .timeline.record import run_finished
async with app.db_sessionmaker() as session:
async with session.begin():
await session.execute(
stopped = (await session.execute(
update(AnsibleRun)
.where(AnsibleRun.status.in_(
[AnsibleRunStatus.running, AnsibleRunStatus.queued]))
@@ -596,7 +615,10 @@ async def _mark_interrupted_runs(app: Quart) -> None:
status=AnsibleRunStatus.interrupted,
finished_at=datetime.now(timezone.utc),
)
)
.returning(AnsibleRun.id)
)).scalars().all()
for run_id in stopped:
await run_finished(session, run_id)
async def _run_scheduler(app: Quart) -> None:
+2
View File
@@ -35,6 +35,8 @@ KEPT: frozenset[str] = frozenset({
EXCLUDED: frozenset[str] = frozenset({
# Logs and history (the 2026-10-15 ruling): a recovery doesn't need them.
"audit_events", "alert_events",
# The timeline of what happened to the infrastructure: history.
"timeline_entries",
# Live state: each rule starts inactive, and alert_reconcile gives a
# restored rule its state row within a minute (ensure_rule_states).
"alert_states",
+22 -10
View File
@@ -571,11 +571,12 @@ async def _evaluate_rule(
state.acknowledged_at = None
said = "; ".join(n for n in (resumed, note) if n) or None
if step.event:
event_id = _add_event(session, rule.id, "no_data" if resumed else from_state.value,
step.event, value, now, note=said)
event_id = await _add_event(session, rule, "no_data" if resumed else from_state.value,
step.event, value, now, note=said)
notifications.append((step.event, event_id, None))
elif resumed:
event_id = _add_event(session, rule.id, "no_data", state.state.value, value, now, note=said)
event_id = await _add_event(session, rule, "no_data", state.state.value, value, now, note=said,
resumed=True)
notifications.append(("data_resumed", event_id, resumed))
await session.flush()
return notifications
@@ -595,8 +596,8 @@ async def reset_rule_state(session: AsyncSession, rule: AlertRule, note: str,
return
now = datetime.now(timezone.utc)
if state.state in (AlertStateEnum.firing, AlertStateEnum.acknowledged):
_add_event(session, rule.id, state.state.value, "resolved", None, now, note=note,
actor_id=actor_id)
await _add_event(session, rule, state.state.value, "resolved", None, now, note=note,
actor_id=actor_id)
# The watch starts over now: silence is measured from here, against the
# cadence of whatever the rule watches from now on.
state.last_transition_at = now
@@ -610,21 +611,27 @@ async def reset_rule_state(session: AsyncSession, rule: AlertRule, note: str,
state.acknowledged_at = None
def _add_event(
async def _add_event(
session: AsyncSession,
rule_id: str,
rule: AlertRule,
from_state: str,
to_state: str,
value: float | None,
now: datetime,
note: str | None = None,
actor_id: str | None = None,
actor: str | None = None,
resumed: bool = False,
) -> str:
"""Add an AlertEvent row and return its ID."""
"""Add an AlertEvent row, and its timeline entry, and return its ID.
`actor` names who did it on the timeline when `actor_id`'s username
alone would not say it ("<user> via MCP"). `resumed` marks a series
back from silence that left the alert's state as it was."""
from steward.timeline.record import from_alert
event_id = str(uuid.uuid4())
event = AlertEvent(
id=event_id,
rule_id=rule_id,
rule_id=rule.id,
from_state=from_state,
to_state=to_state,
metric_value=value,
@@ -633,6 +640,11 @@ def _add_event(
actor_id=actor_id,
)
session.add(event)
try:
await from_alert(session, rule, event, actor, resumed=resumed)
except Exception:
# The alert's own history matters more than its timeline entry.
logger.exception("Alert event %s: its timeline entry could not be written", event_id)
return event_id
@@ -825,7 +837,7 @@ async def check_silence(app: "Quart") -> None:
muted = _muted_by(windows, rule)
# Silent with its agent: recorded, and "agent offline" is the email (#5721).
hushed = AGENT_OFFLINE if host_behind(rule) in offline else None
event_id = _add_event(session, rule.id, state.state.value, "no_data", None, now,
event_id = await _add_event(session, rule, state.state.value, "no_data", None, now,
note="; ".join(n for n in (note, hushed, muted) if n))
if rule.notify_no_data and not muted and not hushed:
notify.append((rule, event_id, note))
+15 -3
View File
@@ -22,12 +22,17 @@ async def log_audit(
entity_id: str | None = None,
detail: dict | None = None,
) -> None:
"""Write one audit event. Never raises — failures are logged and swallowed."""
"""Write one audit event, and its timeline entry when the action has one
(steward.timeline.catalog.audit_type). Never raises — failures are logged
and swallowed."""
from steward.core.ids import time_ordered_id
from steward.models.audit import AuditEvent
from steward.timeline.record import from_audit
try:
async with app.db_sessionmaker() as db:
async with db.begin():
db.add(AuditEvent(
event = AuditEvent(
id=time_ordered_id(),
user_id=user_id,
username=username or "unknown",
action=action,
@@ -35,7 +40,14 @@ async def log_audit(
entity_id=entity_id,
detail_json=json.dumps(detail) if detail else None,
timestamp=datetime.now(timezone.utc),
))
)
db.add(event)
try:
# In a savepoint: an entry that fails leaves the audit row standing.
async with db.begin_nested():
await from_audit(db, event, detail)
except Exception:
logger.exception("Audit event %r: its timeline entry could not be written", action)
except Exception:
logger.exception("Failed to write audit event action=%r", action)
+5
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import delete
from steward.core.anomalies import prune as prune_anomalies
from steward.timeline.service import prune as prune_timeline
from steward.models.alerts import AlertEvent
from steward.models.ansible import AnsibleRun
from steward.models.audit import AuditEvent
@@ -81,6 +82,10 @@ async def run_cleanup(app: "Quart") -> dict:
pruned = await prune_anomalies(session, cutoff)
removed.append([f"metric_anomalies: reviewed, ended over {data_days} days ago",
pruned])
# The timeline keeps its own window, everything by default.
pruned, days = await prune_timeline(session, now)
if days:
removed.append([f"timeline_entries: older than {days} days", pruned])
files = await asyncio.to_thread(prune_run_logs, ARTIFACT_DIR, cutoff)
removed.append([f"Ansible run log files: older than {data_days} days", files])
+11
View File
@@ -52,6 +52,17 @@ DEFAULTS: dict[str, Any] = {
"data.retention_days": 90,
# The audit log is a security record: kept longer than operational data.
"audit.retention_days": 365,
# The timeline is the record of what happened to the infrastructure: kept
# until this many days are set (0 keeps it all). Its areas, and each entry
# type's area and visibility, are Settings → Timeline (steward/timeline).
"timeline.retention_days": 0,
"timeline.areas": [["service", "Service"], ["host_os", "Host OS"], ["hardware", "Hardware"],
["storage", "Storage"], ["network", "Network"], ["security", "Security"],
["monitoring", "Monitoring"]],
"timeline.types": {},
"timeline.area_moved": {},
# When the timeline was first filled from the records that predate it.
"timeline.backfilled_at": "",
"monitors.poll_interval_seconds": 60,
# Root log level: DEBUG, INFO, WARNING or ERROR (steward/core/logging_setup.py).
# WARNING also hides the per-request access lines.
+2
View File
@@ -13,6 +13,7 @@ from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.hosts import Host
from steward.models.users import UserRole
from steward.hosts import service as host_svc
from steward.timeline.service import carry_host_rename
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts")
@@ -202,6 +203,7 @@ async def update_host(host_id: str):
# Agent and container metrics, and the rules and windows on them,
# go by the host's name: move them with it.
moved = await renames.carry_rename(db, renames.HOST_SOURCES, old_name, name)
await carry_host_rename(db, old_name, name)
if moved:
await audit_request("host.renamed", "host", name,
{"host_id": host_id, "renamed_from": old_name, "carried": moved})
+3 -2
View File
@@ -12,9 +12,10 @@ each argument's empty value means, and what comes back.
"""
from __future__ import annotations
from . import alerts, anomalies, audit, automation, backups, hosts, metrics, monitors, overview, session
from . import (alerts, anomalies, audit, automation, backups, hosts, metrics, monitors, overview, session,
timeline)
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, automation,
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, automation,
backups)
+3 -2
View File
@@ -11,7 +11,7 @@ from steward.mcp._context import current_caller, mcp_app
from steward.models.alerts import AlertOperator, AlertRule, AlertState, MaintenanceWindow
from steward.models.users import UserRole
from ._audit import audit_mcp
from ._audit import audit_mcp, mcp_username
from ._shape import (
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd,
)
@@ -389,7 +389,8 @@ async def acknowledge_alert(rule: str) -> dict:
async with db_session() as db, db.begin():
r = await _rule(db, rule)
try:
state = await alert_svc.acknowledge(db, r.id, caller.user_id)
state = await alert_svc.acknowledge(db, r.id, caller.user_id,
mcp_username(caller.username))
except LookupError:
raise ValueError(f"{r.name} has never been evaluated, so there is nothing to "
"acknowledge.") from None
+76
View File
@@ -0,0 +1,76 @@
"""The timeline: everything that happened to the infrastructure, in one list."""
from __future__ import annotations
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 ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str = "",
range: str = "7d", include_hidden: bool = False, limit: int = 50) -> dict:
"""What happened to the infrastructure, newest first, from every source in
one list: alerts firing, resolving and going silent; playbook runs and
what they changed; changes to Steward's own settings, users and backups.
Each entry outlives the record it came from, so this reaches further back
than get_alert_history, list_ansible_runs or list_audit_events.
Reach for it first for "what changed on this host lately?" or "what else
was going on when this broke?". Follow an entry's `url` or ids into the
tool for that record while it is still kept (`details_kept`).
host: a host's name, exactly; empty for every host.
area: what part of the infrastructure an entry is about, as Settings →
Timeline names its areas (`areas` in the reply lists them); empty for all.
kind: how it came about: "change", "automation", "alert", "anomaly",
"ticket" or "steward"; empty for all.
type: one entry type (`types` in the reply); it is returned even if hidden.
range: "1h", "6h", "24h", "7d" (default) or "30d".
include_hidden: also the types Settings hides from the timeline (dry runs,
sign-ins, playbook runs that changed nothing).
limit: default 50, max 500.
"""
if kind and kind not in catalog.KINDS:
raise ValueError(f"kind {kind!r} is not one of {', '.join(catalog.KINDS)}.")
since, key = lookback(range)
limit = clamp_limit(limit, 50, 500)
async with db_session() as db:
cfg = await tl_config.load(db)
if area and area not in cfg.labels and area != catalog.OTHER[0]:
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)}.")
stmt = tl.query(cfg, host=host.strip(), area=area, kind=kind, type_=type,
since=since, include_hidden=include_hidden)
rows = (await db.execute(stmt.order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc())
.limit(limit + 1))).scalars().all()
items = await tl.views(db, cfg, rows)
entries = [
{
"at": iso(v["entry"].at),
"age_s": age_s(v["entry"].at),
"until": iso(v["entry"].until),
"type": v["entry"].type,
"kind": v["kind"],
"area": v["area"],
"summary": v["entry"].summary,
"note": v["note"],
"hosts": v["entry"].hosts,
"subject": v["entry"].subject,
"by": v["entry"].actor,
"url": v["url"],
"details_kept": not v["gone"],
"ref": {"type": v["entry"].ref_type, "id": v["entry"].ref_id} if v["entry"].ref_type else None,
}
for v in items
]
out = {"range": key, **bounded(entries, limit, key="entries"),
"areas": dict(cfg.labels), "types": sorted(catalog.TYPES)}
if out["truncated"]:
out["total"] = None
out["more"] = (f"More than {limit} entries in range. Pass a larger `limit` (max 500), a "
"shorter `range`, or filter by `host`, `area`, `kind` or `type`.")
return out
def register(register_tool) -> None:
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
@@ -0,0 +1,43 @@
"""The timeline: one row per thing that happened to the infrastructure.
Revision ID: 0047_timeline
Revises: 0046_metric_anomalies
Create Date: 2026-10-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY
revision: str = "0047_timeline"
down_revision: Union[str, None] = "0046_metric_anomalies"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"timeline_entries",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.Column("until", sa.DateTime(timezone=True), nullable=True),
sa.Column("type", sa.String(64), nullable=False),
sa.Column("area", sa.String(32), nullable=False),
sa.Column("hosts", ARRAY(sa.String(128)), nullable=False, server_default="{}"),
sa.Column("subject", sa.String(255), nullable=True),
sa.Column("summary", sa.Text, nullable=False),
sa.Column("detail", sa.JSON, nullable=True),
sa.Column("actor", sa.String(64), nullable=False, server_default="system"),
sa.Column("ref_type", sa.String(32), nullable=True),
sa.Column("ref_id", sa.String(64), nullable=True),
)
op.create_index("ix_timeline_entries_at", "timeline_entries", ["at", "id"])
op.create_index("ix_timeline_entries_hosts", "timeline_entries", ["hosts"],
postgresql_using="gin")
op.create_index("ix_timeline_entries_type", "timeline_entries", ["type"])
op.create_index("uq_timeline_entries_ref", "timeline_entries", ["ref_type", "ref_id"],
unique=True, postgresql_where=sa.text("ref_id IS NOT NULL"))
def downgrade() -> None:
op.drop_table("timeline_entries")
+2
View File
@@ -14,6 +14,7 @@ from .ansible_schedule import AnsibleSchedule
from .settings import AppSetting
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
from .outbox import EmailOutbox, email_outbox_events
from .timeline import TimelineEntry
__all__ = [
"Base", "User", "ApiToken", "UserInvite", "PasswordReset",
@@ -27,4 +28,5 @@ __all__ = [
"AppSetting",
"Dashboard", "DashboardWidget", "DashboardShareToken",
"EmailOutbox", "email_outbox_events",
"TimelineEntry",
]
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, Index, String, Text, text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from steward.core.ids import time_ordered_id
class TimelineEntry(Base):
"""One thing that happened to the infrastructure: an alert firing, a
playbook run, a change to Steward's settings. Each row stands on its own,
so it outlives the record it points to (`ref_type` / `ref_id`) once that
record's retention removes it.
`type` is a key of steward.timeline.catalog. Whether a type is shown, and
which area it sorts under, are settings applied when the timeline is read;
`area` is the area the event itself named when it was written, used unless
Settings → Timeline gives its type one."""
__tablename__ = "timeline_entries"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# When something that lasted (a run) ended.
until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
type: Mapped[str] = mapped_column(String(64), nullable=False)
area: Mapped[str] = mapped_column(String(32), nullable=False)
# The hosts it happened on, by name, as every other record keyed by host.
hosts: Mapped[list[str]] = mapped_column(ARRAY(String(128)), nullable=False, default=list)
# What it happened to, when that is not a host: a container, a rule's resource.
subject: Mapped[str | None] = mapped_column(String(255), nullable=True)
summary: Mapped[str] = mapped_column(Text, nullable=False)
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# A username, "<user> via MCP", or "system".
actor: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
ref_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
__table_args__ = (
Index("ix_timeline_entries_at", "at", "id"),
Index("ix_timeline_entries_hosts", "hosts", postgresql_using="gin"),
Index("ix_timeline_entries_type", "type"),
# One entry per source record, so a backfill can run over rows the
# live writers already covered.
Index("uq_timeline_entries_ref", "ref_type", "ref_id", unique=True,
postgresql_where=text("ref_id IS NOT NULL")),
)
+1
View File
@@ -42,6 +42,7 @@
<div class="nav-group">
<div class="nav-group-label">Overview</div>
<a href="/" {% if _p == '/' %}class="active" aria-current="page"{% endif %}>Dashboard</a>
<a href="/timeline/" {% if _p.startswith('/timeline') %}class="active" aria-current="page"{% endif %}>Timeline</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Infrastructure</div>
+5
View File
@@ -202,6 +202,11 @@
{% endif %}
</div>{# Ansible card #}
{# ── History: what happened to this host lately (timeline) ─────────────────── #}
<div hx-get="/timeline/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading history…</span></div>
</div>
{# ── Docker (docker plugin fragment; renders nothing if the host has none).
After Ansible: the container list can run long, and Ansible beneath it was
easy to miss (#5214). ─────────────────────────────────────────────────────── #}
+1
View File
@@ -8,6 +8,7 @@
("notifications", "Notifications", "/settings/notifications/"),
("reports", "Reports", "/settings/reports/"),
("backups", "Backups", "/settings/backups/"),
("timeline", "Timeline", "/settings/timeline/"),
("auth", "Authentication", "/settings/auth/"),
("users", "Users", "/settings/users/"),
("ansible", "Ansible", "/settings/ansible/"),
+76
View File
@@ -0,0 +1,76 @@
{# settings/timeline.html — the timeline's areas, and for each type of entry
the area it sorts under and whether it is shown. Applied when the timeline
is read, so a change re-sorts entries already written. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}Settings — Timeline — Steward{% endblock %}
{% from "settings/_tabs.html" import settings_tabs %}
{% block section_tabs %}{{ settings_tabs("timeline") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Timeline", "")]) }}{% endblock %}
{% block content %}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" action="/settings/timeline/">{{ csrf_field() }}
<div class="card" style="max-width:760px;">
<h2 class="section-title">Areas</h2>
<p class="field-hint" style="margin-bottom:0.75rem;">What part of the infrastructure an entry is about.</p>
<table class="table">
<thead><tr><th>Name</th><th>Remove, moving its entries to</th></tr></thead>
<tbody>
{% for key, label in cfg.areas %}
<tr>
<td><input type="text" name="area_label_{{ key }}" value="{{ label }}" aria-label="Name of {{ label }}" maxlength="40"></td>
<td>
<select name="area_move_{{ key }}" aria-label="Remove {{ label }}">
<option value="">Keep it</option>
{% for other, other_label in cfg.areas if other != key %}<option value="{{ other }}">{{ other_label }}</option>{% endfor %}
</select>
</td>
</tr>
{% endfor %}
<tr>
<td><input type="text" name="new_area" value="" placeholder="Add an area" aria-label="Add an area" maxlength="40"></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="card" style="max-width:760px;margin-top:1.25rem;">
<h2 class="section-title">What the timeline shows</h2>
<p class="field-hint" style="margin-bottom:0.75rem;">A hidden type is still recorded; showing it again brings its entries back.</p>
<table class="table">
<thead><tr><th>Type</th><th>Area</th><th>Shown</th></tr></thead>
{% for kind, types in by_kind.items() %}
<tbody>
<tr class="table-divider"><th colspan="3" scope="rowgroup">{{ kinds[kind] }}</th></tr>
{% for t in types %}
{% set override = cfg.types.get(t.key, {}) %}
<tr>
<td>{{ t.label }}<div class="field-hint">{{ t.about }}</div></td>
<td>
<select name="type_area_{{ t.key }}" aria-label="Area for {{ t.label }}">
<option value="">As recorded</option>
{% for key, label in cfg.areas %}<option value="{{ key }}"{% if override.get('area') == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</td>
<td><input type="checkbox" name="type_shown_{{ t.key }}" aria-label="Show {{ t.label }}"{% if cfg.shown(t.key) %} checked{% endif %}></td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
<p class="field-hint">“As recorded” sorts each entry under the area it was written with: an alert by what its rule watches, a change by the area its author picked.</p>
</div>
<div class="card" style="max-width:760px;margin-top:1.25rem;">
<h2 class="section-title">Keeping it</h2>
<div class="form-group">
<label for="retention_days">Keep for <span class="field-hint">(days, 0 keeps everything)</span></label>
<input type="number" id="retention_days" name="retention_days" min="0" value="{{ retention }}" style="max-width:120px;">
</div>
</div>
<div class="form-actions" style="max-width:760px;"><button type="submit" class="btn">Save</button></div>
</form>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{# One timeline entry's parts, shared by the Timeline page and a host's card.
`item` is a timeline.service.view() row. #}
{# What happened: its kind, the sentence, a note, and the full record while it is kept. #}
{% macro entry_what(item) -%}
{%- set e = item.entry -%}
<div>
{%- if item.kind_label %}<span class="tone-dim">{{ item.kind_label }} · </span>{% endif -%}
{{ e.summary }}
</div>
{%- if item.note %}<div class="field-hint">{{ item.note }}</div>{% endif %}
{%- if item.url %}<div class="field-hint"><a href="{{ item.url }}">Details</a></div>
{%- elif item.gone %}<div class="field-hint">Details no longer kept</div>{% endif %}
{%- endmacro %}
{# Where: the hosts it happened on, each linked to its timeline, and what it happened to. #}
{% macro entry_where(e) -%}
{%- for h in e.hosts %}<a href="/timeline/?host={{ h|urlencode }}">{{ h }}</a>{{ ", " if not loop.last }}{% endfor -%}
{%- if e.subject and e.subject not in e.hosts %}{% if e.hosts %}<br>{% endif %}<span class="field-hint">{{ e.subject }}</span>{% endif -%}
{%- if not e.hosts and not e.subject %}<span class="tone-dim">—</span>{% endif -%}
{%- endmacro %}
@@ -0,0 +1,25 @@
{# A host's newest timeline entries, loaded into its page: what happened to it
lately, with the whole of it a click away. #}
{% from "_macros.html" import empty_state %}
{% from "timeline/_entry.html" import entry_what %}
<section class="card" aria-labelledby="host-history">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;">
<h3 id="host-history" class="section-title">History</h3>
<a class="field-hint" href="/timeline/?host={{ host.name|urlencode }}">All of {{ host.name }}'s history</a>
</div>
{% if items %}
<table class="table">
<tbody>
{% for item in items %}
<tr>
<td class="nowrap" style="width:1%;">{{ item.entry.at|when }}</td>
<td>{{ entry_what(item) }}</td>
<td class="nowrap" style="width:1%;">{{ item.entry.actor }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{{ empty_state("Nothing has happened to " ~ host.name ~ " that the timeline records yet.") }}
{% endif %}
</section>
+21
View File
@@ -0,0 +1,21 @@
{# timeline/_rows.html — one page of the timeline's rows, then the sentinel
that loads the next page as the reader nears it (core.paging). A divider
row opens each recency group: Today, Yesterday, Last 7 days, Older. #}
{% from "_macros.html" import scroll_more, scroll_end %}
{% from "timeline/_entry.html" import entry_what, entry_where %}
{% for item in items %}
{% set e = item.entry %}
{% if item.opens_group %}<tr class="table-divider"><th colspan="5" scope="rowgroup">{{ item.group }}</th></tr>{% endif %}
<tr>
<td class="nowrap">{{ e.at|when }}</td>
<td>{{ entry_what(item) }}</td>
<td>{{ entry_where(e) }}</td>
<td class="nowrap">{{ e.actor }}</td>
<td class="nowrap"><span class="chip">{{ item.area_label }}</span></td>
</tr>
{% endfor %}
{% if after %}
{{ scroll_more("/timeline/?before=" ~ (after|urlencode) ~ "&" ~ (keep|urlencode) ~ "&group=" ~ (last_group|urlencode), cols=5) }}
{% else %}
{{ scroll_end(cols=5) }}
{% endif %}
+54
View File
@@ -0,0 +1,54 @@
{# timeline/list.html — everything that happened to the infrastructure, newest
first, one list: alerts, playbook runs, changes to Steward's settings.
Filters are by host, area (what was affected) and kind (how it came about);
Settings → Timeline decides each type's area and whether it is shown, and
"Include hidden" shows the types it hides. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, empty_state %}
{% block title %}Timeline — Steward{% endblock %}
{% block content %}
{% call page_header("Timeline", "What happened, newest first: alerts, playbook runs and changes, on every host.") %}
{% if session.user_role == 'admin' %}<a class="btn btn-ghost btn-sm" href="/settings/timeline/">Areas and types</a>{% endif %}
{% endcall %}
<form class="filter-bar" method="get" action="/timeline/" role="search">
<select name="host" aria-label="Host" onchange="this.form.submit()">
<option value="">All hosts</option>
{% for h in hosts %}<option value="{{ h }}"{% if filters.host == h %} selected{% endif %}>{{ h }}</option>{% endfor %}
</select>
<select name="area" aria-label="Area" onchange="this.form.submit()">
<option value="">All areas</option>
{% for key, label in areas %}<option value="{{ key }}"{% if filters.area == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<select name="kind" aria-label="Kind" onchange="this.form.submit()">
<option value="">All kinds</option>
{% for key, label in kinds.items() %}<option value="{{ key }}"{% if filters.kind == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<label class="field-hint" style="display:flex;align-items:center;gap:0.35rem;">
<input type="checkbox" name="hidden" value="1"{% if filters.hidden %} checked{% endif %} onchange="this.form.submit()">
Include hidden types
</label>
<noscript><button type="submit" class="btn btn-ghost btn-sm">Filter</button></noscript>
</form>
{% if items %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>When ({{ tz_label() }})</th>
<th>What happened</th>
<th>Where</th>
<th>Who</th>
<th>Area</th>
</tr>
</thead>
<tbody>
{% include "timeline/_rows.html" %}
</tbody>
</table>
</div>
{% else %}
<div class="card">{{ empty_state("Nothing on the timeline matches these filters." if keep else "Nothing has been recorded on the timeline yet.") }}</div>
{% endif %}
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
"""The timeline: one record of what happened to the infrastructure, from
every source, newest first (catalog, config, record, service)."""
+137
View File
@@ -0,0 +1,137 @@
"""What can appear on the timeline: each entry type, the kind of event it is,
and the area it sorts under until Settings → Timeline says otherwise.
An area is what part of the infrastructure was affected (Service, Network,
Hardware …); a kind is how the event came about (a change someone made, an
automation run, an alert …). Kinds are fixed here. Areas, and each type's
area and visibility, are settings (steward.timeline.config), so they can be
changed without a release and the change re-sorts entries already written.
A plugin adds its own types with register_type() from its setup().
"""
from __future__ import annotations
from dataclasses import dataclass
from steward.core.settings import DEFAULTS
# How an event came about. Fixed: the page's "Kind" filter and the MCP
# tool's `kind` argument take these keys.
KINDS: dict[str, str] = {
"change": "Change", # a person or an agent did it
"automation": "Automation", # a playbook or maintenance run
"alert": "Alert",
"anomaly": "Anomaly",
"ticket": "Ticket",
"steward": "Steward", # Steward's own settings and access
}
# The starting areas, [key, label]: the timeline.areas setting's default.
# Settings → Timeline renames, adds and removes them.
DEFAULT_AREAS: list[list[str]] = DEFAULTS["timeline.areas"]
# Where an entry goes whose area is unknown: removed without a destination,
# or named by a plugin that is no longer loaded.
OTHER = ("other", "Other")
@dataclass(frozen=True)
class EntryType:
key: str
label: str # how Settings → Timeline names it
kind: str # a KINDS key
area: str # the area its entries are written with, unless the writer names one
shown: bool # whether the timeline shows it until Settings says otherwise
about: str # one line for Settings → Timeline
TYPES: dict[str, EntryType] = {}
def register_type(key: str, label: str, kind: str, area: str, shown: bool, about: str) -> None:
if kind not in KINDS:
raise ValueError(f"timeline type {key!r}: unknown kind {kind!r}")
TYPES[key] = EntryType(key, label, kind, area, shown, about)
for _t in (
("alert.fired", "Alert fired", "alert", "monitoring", True,
"An alert rule's condition held long enough to fire."),
("alert.resolved", "Alert resolved", "alert", "monitoring", True,
"A firing alert cleared, or its rule was changed."),
("alert.acknowledged", "Alert acknowledged", "alert", "monitoring", True,
"Someone took a firing alert."),
("alert.silent", "Readings stopped", "alert", "monitoring", True,
"What an alert rule watches stopped reporting."),
("alert.resumed", "Readings resumed", "alert", "monitoring", True,
"What an alert rule watches reported again after a silence."),
("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,
"A playbook run finished with nothing to change."),
("ansible.failed", "Playbook failed", "automation", "host_os", True,
"A playbook run failed, was cancelled, or was stopped by a restart."),
("ansible.check", "Playbook dry run", "automation", "host_os", False,
"A playbook run in check mode: it showed what would change."),
("steward.config", "Steward settings changed", "steward", "monitoring", True,
"Monitors, alert rules, maintenance windows, inventory, plugins, settings."),
("steward.access", "Users and access", "steward", "security", True,
"Users, roles, passwords and AI access tokens."),
("steward.signin", "Sign-ins", "steward", "security", False,
"Someone signed in, or failed to."),
("steward.backup", "Steward backups", "steward", "storage", True,
"Steward backed itself up, or was restored from a backup."),
):
register_type(*_t)
# Which area an alert's entry is written with, from what its rule watches.
# A source not listed here is Monitoring.
_SOURCE_AREAS = {
"icmp": "network", "dns": "network", "tcp": "service", "http": "service",
"docker": "service", "prometheus": "service",
"traefik": "network", "unifi": "network",
"snmp": "hardware",
}
# host_agent metrics by prefix, first match wins; the rest are Host OS.
_HOST_METRIC_AREAS = (
("updates_security", "security"),
("temp_", "hardware"), ("drive", "hardware"),
("disk_", "storage"), ("inodes_", "storage"), ("mounts_", "storage"), ("nfs_", "storage"),
("net_", "network"),
)
def area_of_metric(source: str, metric: str) -> str:
"""The area a reading of `metric` from `source` is about."""
if source == "host_agent":
for prefix, area in _HOST_METRIC_AREAS:
if metric.startswith(prefix):
return area
return "host_os"
if source == "snmp" and any(w in metric for w in ("volume", "disk_used", "storage")):
return "storage"
return _SOURCE_AREAS.get(source, "monitoring")
# Audit actions and the type each is entered as. The first prefix that
# matches wins; None leaves the action off the timeline, because another
# writer records the event itself (a run, an acknowledgement) or it is about
# one person's own preferences.
_AUDIT_TYPES = (
("ansible.run.", None), ("ansible.host_run", None),
("alert_rule.acknowledged", None), ("alert_rule.tested", None),
("settings.notification.tested", None),
("account.time_zone", None), ("account.email", None),
("host_agent.maintenance_plan", "steward.config"), ("host_agent.maintenance", None),
("docker.prune.", None),
("auth.login", "steward.signin"), ("auth.setup", "steward.config"),
("account.", "steward.access"), ("user.", "steward.access"), ("api_token.", "steward.access"),
("backup.", "steward.backup"),
)
def audit_type(action: str) -> str | None:
for prefix, type_ in _AUDIT_TYPES:
if action.startswith(prefix):
return type_
return "steward.config"
+150
View File
@@ -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)
+233
View File
@@ -0,0 +1,233 @@
"""Writing the timeline. Every source writes through record(), in the same
transaction as its own record, as the thing happens: the entry stands on its
own, so it is still there after the source's own row ages out.
from_alert, from_run and from_audit build the entry for one alert event,
finished Ansible run and audit row. The live writers and the backfill use the
same three, so an entry reads the same however it got there.
"""
from __future__ import annotations
import logging
from datetime import datetime
from sqlalchemy import select
from . import catalog
from steward.models.timeline import TimelineEntry
logger = logging.getLogger(__name__)
def record(session, *, type: str, at: datetime, summary: str, area: str | None = None,
hosts=(), subject: str | None = None, detail: dict | None = None,
actor: str = "system", until: datetime | None = None,
ref: tuple[str, str] | None = None) -> TimelineEntry:
"""Add one entry to `session`. `area` defaults to its type's; `ref` is
(kind, id) of the full record it points to, which is at most one entry."""
known = catalog.TYPES.get(type)
entry = TimelineEntry(
at=at, until=until, type=type,
area=area or (known.area if known else catalog.OTHER[0]),
hosts=sorted({h for h in hosts if h}), subject=subject[:255] if subject else None, summary=summary,
detail=detail or None, actor=(actor or "system")[:64],
ref_type=ref[0] if ref else None, ref_id=str(ref[1]) if ref else None)
session.add(entry)
return entry
async def username(session, user_id: str | None) -> str | None:
if not user_id:
return None
from steward.models.users import User
user = await session.get(User, user_id)
return user.username if user else None
# ── Alerts ──────────────────────────────────────────────────────────────────
_MONITOR_SOURCES = ("icmp", "tcp", "dns", "http")
async def hosts_of_rule(session, source: str, resource: str) -> list[str]:
"""The host an alert rule's resource is on, by name, when it has one: an
agent series ("<host>" or "<host>:<part>"), a container ("<host>/<name>")
or a monitor tied to a host."""
from steward.core.alerts import host_of_resource
if source == "host_agent":
return [host_of_resource(source, resource) or resource.split(":", 1)[0]]
if source == "docker":
return [resource.split("/", 1)[0]]
if source in _MONITOR_SOURCES:
from steward.models.hosts import Host
from steward.models.monitors import Monitor
name = (await session.execute(
select(Host.name).join(Monitor, Monitor.host_id == Host.id)
.where(Monitor.type == source, Monitor.name == resource).limit(1))).scalar()
return [name] if name else []
return []
def _alert_type(from_state: str, to_state: str) -> str | None:
if to_state == "firing":
return "alert.fired"
if to_state == "resolved":
return "alert.resolved"
if to_state == "acknowledged":
return "alert.acknowledged"
if to_state == "no_data":
return "alert.silent"
if from_state == "no_data":
return "alert.resumed"
return None
def alert_summary(type_: str, rule, value: float | None) -> str:
from steward.alerts.service import _BINARY
from steward.core.formatting import metric_label, metric_value
metric, res = rule.metric_name, rule.resource_name
said = metric_label(metric)
# The reading, and on a firing the condition it broke.
if metric in _BINARY:
reading = breach = f"{res} is {metric_value(metric, value)}"
else:
op = getattr(rule.operator, "value", rule.operator)
reading = f"{said} on {res} is {metric_value(metric, value)}"
breach = f"{reading} ({op} {metric_value(metric, rule.threshold)})"
return {
"alert.fired": f"{rule.name} fired: {breach}" if value is not None else f"{rule.name} fired",
"alert.resolved": f"{rule.name} resolved" + (f": {reading}" if value is not None else ""),
"alert.acknowledged": f"{rule.name} acknowledged",
"alert.silent": f"{rule.name}: no {said} reading from {res}",
"alert.resumed": f"{rule.name}: {said} readings from {res} again",
}[type_]
async def from_alert(session, rule, event, actor: str | None = None,
resumed: bool = False) -> TimelineEntry | None:
"""The entry for one alert event (an AlertEvent row), or None for a
transition the timeline doesn't show (pending and back). `resumed` is a
series back from silence with the alert's state unchanged, which can be
an already-firing alert still firing."""
type_ = "alert.resumed" if resumed else _alert_type(event.from_state, event.to_state)
if type_ is None:
return None
return record(
session, type=type_, at=event.transitioned_at,
summary=alert_summary(type_, rule, event.metric_value),
area=catalog.area_of_metric(rule.source_module, rule.metric_name),
hosts=await hosts_of_rule(session, rule.source_module, rule.resource_name),
subject=rule.resource_name,
detail={"rule_id": rule.id, "rule": rule.name, "source": rule.source_module,
"metric": rule.metric_name, "value": event.metric_value, "note": event.note,
"url": f"/alerts/history?rule={rule.id}"},
actor=actor or await username(session, getattr(event, "actor_id", None)) or "system",
ref=("alert_event", event.id))
# ── Ansible runs ────────────────────────────────────────────────────────────
def _run_type(run) -> str:
status = getattr(run.status, "value", run.status)
if status != "success":
return "ansible.failed"
if (run.params or {}).get("check"):
return "ansible.check"
hosts = ((run.results or {}).get("hosts") or {}).values()
return "ansible.changed" if any((h or {}).get("changed") for h in hosts) else "ansible.unchanged"
async def hosts_of_run(session, names) -> list[str]:
"""The Steward hosts behind a run's inventory names: a target linked to a
host is that host; a name that is a host's own is that host."""
from steward.models.ansible_inventory import AnsibleTarget
from steward.models.hosts import Host
names = set(names)
if not names:
return []
linked = dict((await session.execute(
select(AnsibleTarget.name, Host.name).join(Host, AnsibleTarget.host_id == Host.id)
.where(AnsibleTarget.name.in_(names)))).all())
direct = set((await session.execute(
select(Host.name).where(Host.name.in_(names - set(linked))))).scalars())
return sorted(set(linked.values()) | direct)
async def from_run(session, run) -> TimelineEntry:
"""The entry for one finished Ansible run."""
from steward.ansible.inventory_gen import run_title, scope_labels
from steward.ansible.origins import run_origins
type_ = _run_type(run)
per_host = (run.results or {}).get("hosts") or {}
scope = (await scope_labels(session, [run.inventory_scope])).get(run.inventory_scope)
title = run_title(run.playbook_path, scope)
status = getattr(run.status, "value", run.status)
changed = sum(1 for h in per_host.values() if (h or {}).get("changed"))
failed = sum(1 for h in per_host.values() if (h or {}).get("failed") or (h or {}).get("unreachable"))
if type_ == "ansible.failed":
said = {"cancelled": "cancelled", "interrupted": "stopped by a restart"}.get(status, "failed")
tail = f"{said} on {failed} host{'s' if failed != 1 else ''}" if failed else said
elif type_ == "ansible.check":
tail = f"dry run, would change {changed} host{'s' if changed != 1 else ''}" if changed \
else "dry run, nothing to change"
elif type_ == "ansible.changed":
tail = f"changed {changed} host{'s' if changed != 1 else ''}"
else:
tail = "nothing to change"
origin = (await run_origins(session, [run])).get(run.id) or {}
return record(
session, type=type_, at=run.started_at, until=run.finished_at,
summary=f"{title}: {tail}", hosts=await hosts_of_run(session, per_host),
subject=run.playbook_path,
detail={"status": status, "scope": scope, "origin": origin.get("text"),
"failures": ((run.results or {}).get("failures") or [])[:3],
"url": f"/ansible/runs/{run.id}"},
actor=origin.get("text") or "Steward", ref=("ansible_run", run.id))
async def run_finished(session, run_id: str) -> None:
"""Write the entry for a run that has just recorded its final status, in
a savepoint of the caller's transaction. Never raises: the run's own
record matters more than its entry."""
from steward.models.ansible import AnsibleRun
try:
async with session.begin_nested():
run = await session.get(AnsibleRun, run_id)
if run is not None:
await from_run(session, run)
except Exception:
logger.exception("Ansible run %s: its timeline entry could not be written", run_id)
# ── The audit log ───────────────────────────────────────────────────────────
async def from_audit(session, event, detail: dict | None) -> TimelineEntry | None:
"""The entry for one audit row (an AuditEvent), or None for an action the
timeline leaves to another writer (catalog.audit_type)."""
from steward.audit.service import action_text, entity_labels
type_ = catalog.audit_type(event.action)
if type_ is None:
return None
detail = detail or {}
entity = None
if event.entity_type and event.entity_id:
labels = await entity_labels(session, [{"event": event, "detail": detail}])
entity = labels.get((event.entity_type, str(event.entity_id)))
what = action_text(event.action)
if entity:
what = f"{what} {entity['label']}"
hosts = []
if event.entity_type == "host" and entity:
hosts = [entity["label"]]
elif event.entity_type == "monitor":
from steward.models.hosts import Host
from steward.models.monitors import Monitor
name = (await session.execute(
select(Host.name).join(Monitor, Monitor.host_id == Host.id)
.where(Monitor.id == str(event.entity_id)))).scalar()
hosts = [name] if name else []
return record(
session, type=type_, at=event.timestamp, summary=what[:1].upper() + what[1:],
hosts=hosts, subject=entity["label"] if entity else None,
detail={"action": event.action, "url": entity.get("url") if entity else None},
actor=event.username, ref=("audit_event", event.id))
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from quart import Blueprint, 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.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
timeline_bp = Blueprint("timeline", __name__)
def _filters() -> dict:
a = request.args
return {"host": a.get("host", "").strip(), "area": a.get("area", "").strip(),
"kind": a.get("kind", "").strip(), "hidden": a.get("hidden") == "1"}
@timeline_bp.get("/timeline/")
@require_role(UserRole.viewer)
async def timeline():
"""Everything that happened, newest first, a page at a time as the reader
scrolls; ?before=<cursor> is the next page's rows alone (core.paging)."""
f = _filters()
async with current_app.db_sessionmaker() as db:
cfg = await tl_config.load(db)
stmt = tl.query(cfg, host=f["host"], area=f["area"], kind=f["kind"],
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)
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,
"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"):
return await render_template("timeline/_rows.html", **ctx)
return await render_template("timeline/list.html", **ctx)
@timeline_bp.get("/timeline/host/<host_id>")
@require_role(UserRole.viewer)
async def host_card(host_id: str):
"""A host's newest entries, for its page (loaded into it)."""
async with current_app.db_sessionmaker() as db:
host = await db.get(Host, host_id)
if host is None:
return "", 404
cfg = await tl_config.load(db)
items = await tl.recent_for_host(db, cfg, host.name)
return await render_template("timeline/_host_card.html", host=host, items=items)
@timeline_bp.get("/settings/timeline/")
@require_role(UserRole.admin)
async def settings_page():
async with current_app.db_sessionmaker() as db:
cfg = await tl_config.load(db)
from steward.core.settings import get_setting
retention = int(await get_setting(db, "timeline.retention_days") or 0)
return await render_template("settings/timeline.html", **_settings_ctx(cfg, retention))
def _settings_ctx(cfg, retention: int, error: str | None = None) -> dict:
by_kind = {k: [t for t in catalog.TYPES.values() if t.kind == k] for k in catalog.KINDS}
return {"cfg": cfg, "retention": retention, "error": error, "kinds": catalog.KINDS,
"by_kind": {k: v for k, v in by_kind.items() if v}}
@timeline_bp.post("/settings/timeline/")
@require_role(UserRole.admin)
async def save_settings():
from steward.core.settings import set_setting
form = await request.form
try:
retention = max(0, int(form.get("retention_days") or 0))
except ValueError:
retention = None
async with current_app.db_sessionmaker() as db:
async with db.begin():
cfg = await tl_config.load(db)
new, error = tl_config.edited(cfg, form)
if error is None and retention is None:
error = "Keep for: a whole number of days, or 0 to keep everything."
if error is not None:
kept = retention if retention is not None else 0
return await render_template("settings/timeline.html",
**_settings_ctx(cfg, kept, error)), 400
await tl_config.save(db, new)
await set_setting(db, "timeline.retention_days", retention)
await audit_request("settings.saved", detail={"section": "timeline"})
return redirect(url_for("timeline.settings_page"))
+196
View File
@@ -0,0 +1,196 @@
"""Reading the timeline, and keeping it: the filtered list the page, the host
card and get_timeline share, the first fill from records that predate it,
its retention, and a host rename carried onto its entries."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.dialects.postgresql import insert
from . import catalog
from .config import Config, kind_types
from steward.models.timeline import TimelineEntry
logger = logging.getLogger(__name__)
# How far back the first fill reaches: the default retention of the records it reads.
BACKFILL_DAYS = 90
def query(cfg: Config, *, host: str = "", area: str = "", kind: str = "", type_: str = "",
since: datetime | None = None, include_hidden: bool = False):
"""The entries matching the filters, unordered (core.paging orders them).
Hidden types are left out unless `include_hidden`, or a `type_` asks for one."""
stmt = select(TimelineEntry)
if host:
stmt = stmt.where(TimelineEntry.hosts.contains([host]))
if area:
stmt = stmt.where(cfg.area_filter(area))
if kind:
stmt = stmt.where(TimelineEntry.type.in_(kind_types(kind)))
if type_:
stmt = stmt.where(TimelineEntry.type == type_)
elif not include_hidden:
hidden = cfg.hidden_types()
if hidden:
stmt = stmt.where(TimelineEntry.type.notin_(hidden))
if since is not None:
stmt = stmt.where(TimelineEntry.at >= since)
return stmt
# The full records an entry can point to, and whether one is still kept.
def _ref_models() -> dict:
from steward.models.alerts import AlertEvent
from steward.models.ansible import AnsibleRun
from steward.models.audit import AuditEvent
return {"alert_event": AlertEvent, "ansible_run": AnsibleRun, "audit_event": AuditEvent}
async def kept_refs(db, entries) -> set[tuple[str, str]]:
"""The (ref_type, ref_id) of `entries` whose full record still exists."""
wanted: dict[str, set[str]] = {}
for e in entries:
if e.ref_type and e.ref_id:
wanted.setdefault(e.ref_type, set()).add(e.ref_id)
kept = set()
models = _ref_models()
for ref_type, ids in wanted.items():
model = models.get(ref_type)
if model is None: # a kind only a plugin knows: assume it is kept
kept |= {(ref_type, i) for i in ids}
continue
found = (await db.execute(select(model.id).where(model.id.in_(ids)))).scalars()
kept |= {(ref_type, str(i)) for i in found}
return kept
def view(entry: TimelineEntry, cfg: Config, kept: set) -> dict:
"""One entry as the page and the MCP tool read it."""
known = catalog.TYPES.get(entry.type)
area = cfg.area_of(entry)
detail = entry.detail or {}
url = detail.get("url")
gone = bool(entry.ref_type) and (entry.ref_type, entry.ref_id) not in kept
return {
"entry": entry,
"type_label": known.label if known else entry.type,
"kind": known.kind if known else None,
"kind_label": catalog.KINDS.get(known.kind) if known else None,
"area": area,
"area_label": cfg.area_label(area),
"note": detail.get("note"),
"url": None if gone else url,
"gone": gone,
}
async def views(db, cfg: Config, entries) -> list[dict]:
kept = await kept_refs(db, entries)
return [view(e, cfg, kept) for e in entries]
async def recent_for_host(db, cfg: Config, host: str, limit: int = 8) -> list[dict]:
"""A host's newest shown entries, for its page."""
rows = (await db.execute(
query(cfg, host=host).order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc())
.limit(limit))).scalars().all()
return await views(db, cfg, rows)
# ── Keeping it ──────────────────────────────────────────────────────────────
async def prune(session, now: datetime) -> tuple[int, int]:
"""Delete entries past timeline.retention_days; (rows, days), with 0 days
keeping everything. Call inside a transaction."""
from steward.core.settings import get_setting
days = int(await get_setting(session, "timeline.retention_days") or 0)
if days <= 0:
return 0, 0
result = await session.execute(
delete(TimelineEntry).where(TimelineEntry.at < now - timedelta(days=days)))
return result.rowcount or 0, days
async def carry_host_rename(session, old: str, new: str) -> int:
"""Point a renamed host's entries at its new name, so its page still
shows its history."""
if not old or old == new:
return 0
result = await session.execute(
update(TimelineEntry).where(TimelineEntry.hosts.contains([old]))
.values(hosts=func.array_replace(TimelineEntry.hosts, old, new))
.execution_options(synchronize_session=False))
return result.rowcount or 0
async def backfill(app, now: datetime | None = None) -> int:
"""Fill the timeline once from the alert events, Ansible runs and audit
rows that predate it, BACKFILL_DAYS back, through the same builders the
live writers use. An entry for a record already covered is skipped, so
it is safe to run again. Records the run in timeline.backfilled_at."""
from steward.core.settings import get_setting, set_setting
from steward.models.alerts import AlertEvent, AlertRule
from steward.models.ansible import AnsibleRun
from steward.models.audit import AuditEvent
from .record import from_alert, from_audit, from_run
now = now or datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
if await get_setting(session, "timeline.backfilled_at"):
return 0
since = now - timedelta(days=BACKFILL_DAYS)
built: list[TimelineEntry] = []
async with session.begin():
# The builders add each entry to the session; no flush may write
# them before the insert below, which skips any already covered.
session.sync_session.autoflush = False
async def build(what: str, coro) -> None:
# One record that can't be read is skipped, not the whole fill.
try:
built.append(await coro)
except Exception:
logger.exception("Timeline fill: %s skipped", what)
for event, rule in (await session.execute(
select(AlertEvent, AlertRule).join(AlertRule, AlertEvent.rule_id == AlertRule.id)
.where(AlertEvent.transitioned_at >= since))).all():
await build(f"alert event {event.id}", from_alert(session, rule, event))
for run in (await session.execute(
select(AnsibleRun).where(AnsibleRun.started_at >= since,
AnsibleRun.finished_at.is_not(None)))).scalars().all():
await build(f"Ansible run {run.id}", from_run(session, run))
for event in (await session.execute(
select(AuditEvent).where(AuditEvent.timestamp >= since))).scalars().all():
await build(f"audit row {event.id}", from_audit(session, event, _audit_detail(event)))
rows = [e for e in built if e is not None]
for entry in rows:
session.expunge(entry)
for chunk in range(0, len(rows), 500):
await session.execute(
insert(TimelineEntry).values([_row(e) for e in rows[chunk:chunk + 500]])
.on_conflict_do_nothing(index_elements=["ref_type", "ref_id"],
index_where=text("ref_id IS NOT NULL")))
await set_setting(session, "timeline.backfilled_at", now.isoformat())
logger.info("Timeline: filled %d entries from the last %d days of records", len(rows), BACKFILL_DAYS)
return len(rows)
def _audit_detail(event) -> dict:
import json
try:
return json.loads(event.detail_json) if event.detail_json else {}
except (TypeError, ValueError):
return {}
def _row(entry: TimelineEntry) -> dict:
from steward.core.ids import time_ordered_id
return {"id": entry.id or time_ordered_id(), "at": entry.at, "until": entry.until,
"type": entry.type, "area": entry.area, "hosts": entry.hosts or [],
"subject": entry.subject, "summary": entry.summary, "detail": entry.detail,
"actor": entry.actor or "system", "ref_type": entry.ref_type, "ref_id": entry.ref_id}