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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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). ─────────────────────────────────────────────────────── #}
|
||||
|
||||
@@ -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/"),
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""The timeline: one record of what happened to the infrastructure, from
|
||||
every source, newest first (catalog, config, record, service)."""
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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"))
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user