feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
A ticket records a known problem: title, area, hosts, subject, description, links to alert rules and anomalies, and a log of comments, edits, resolving and reopening. Opening, resolving (with the resolution as the note) and reopening go on the timeline; every step is audited, and the audit rows stay off the timeline. An alert or anomaly an open ticket covers (by link, by subject, or a host-wide ticket on the host itself) shows a Known issue badge, and offers "Open a ticket" prefilled from its rule or anomaly when none does. Hosts, monitors and containers get a Known issues card. MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket, comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and list_anomalies carry known_issue; get_overview, get_host, get_monitor and get_container carry open_tickets. Also update_change and delete_change for changes recorded by hand (author or admin). Tickets are left out of backups, like the timeline (2026-10-15 ruling). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,10 +13,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,
|
||||
timeline)
|
||||
tickets, timeline)
|
||||
|
||||
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, automation,
|
||||
backups)
|
||||
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, tickets,
|
||||
automation, backups)
|
||||
|
||||
|
||||
def register_all(register_tool) -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ from steward.core import outbox
|
||||
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 steward.tickets.service import known_issues
|
||||
|
||||
from ._audit import audit_mcp, mcp_username
|
||||
from ._shape import (
|
||||
@@ -44,13 +45,17 @@ async def list_alerts() -> dict:
|
||||
(no sample for three of its usual gaps, at least 5 minutes); a firing
|
||||
alert that goes silent stays "firing" with `no_data_since` set. Each
|
||||
carries the rule's condition, the resource and metric it watches, `since`
|
||||
(its latest state change) and `last_sample_at`. Firing, unacknowledged
|
||||
alerts come first. An alert that cleared is not here; get_alert_history
|
||||
shows the transitions.
|
||||
(its latest state change), `last_sample_at`, and `known_issue`: the open
|
||||
tickets covering it (get_ticket says what is known and tried). Firing,
|
||||
unacknowledged alerts come first. An alert that cleared is not here;
|
||||
get_alert_history shows the transitions.
|
||||
"""
|
||||
async with db_session() as db:
|
||||
active = await alert_svc.active_alerts(db)
|
||||
names = await alert_svc.usernames(db, (s.acknowledged_by for s, _ in active))
|
||||
known = await known_issues(db, {
|
||||
rule.id: ([("alert_rule", rule.id), ("alert_rule", rule.parent_id or "")], rule.resource_name)
|
||||
for _, rule in active})
|
||||
order = {"firing": 0, "no_data": 1, "pending": 2, "acknowledged": 3}
|
||||
rows = [
|
||||
{
|
||||
@@ -67,6 +72,7 @@ async def list_alerts() -> dict:
|
||||
"age_s": age_s(state.last_transition_at),
|
||||
"acknowledged_by": names.get(state.acknowledged_by) if state.acknowledged_by else None,
|
||||
"acknowledged_at": iso(state.acknowledged_at),
|
||||
"known_issue": [{"ticket": t.id, "title": t.title} for t in known.get(rule.id, [])],
|
||||
}
|
||||
for state, rule in active
|
||||
]
|
||||
|
||||
@@ -45,6 +45,7 @@ def _row(v: anom.AnomalyView) -> dict:
|
||||
"reviewed_at": iso(a.reviewed_at),
|
||||
"note": a.note,
|
||||
"page": v.href,
|
||||
"known_issue": [{"ticket": t.id, "title": t.title} for t in v.tickets],
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +65,8 @@ async def list_anomalies(review: str = "to_review", limit: int = 50) -> dict:
|
||||
`usual_hold` (the level that hour usually holds) and `usual_peak` (how high
|
||||
it usually goes), all in the metric's own unit. `ongoing` is true while an
|
||||
occurrence is still going. To-review rows come first, newest occurrence
|
||||
first. review_anomaly marks one.
|
||||
first. `known_issue` lists the open tickets covering it. review_anomaly
|
||||
marks one; open_ticket keeps a real one's notes until it is fixed.
|
||||
"""
|
||||
if review and review not in anom.REVIEWS:
|
||||
raise ValueError(f"review must be one of {', '.join(anom.REVIEWS)}, or empty for all")
|
||||
|
||||
@@ -15,6 +15,7 @@ from steward.models.users import UserRole
|
||||
from ._shape import (
|
||||
age_s, bounded, clamp_limit, db_session, iso, lookback, pick, rnd, summarize,
|
||||
)
|
||||
from .tickets import open_tickets_for
|
||||
|
||||
AGENT_SOURCE = "host_agent"
|
||||
|
||||
@@ -141,8 +142,9 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
|
||||
|
||||
Returns the host's monitors (latest result, error, uptime), the agent's
|
||||
latest values, alerts currently pending, firing or acknowledged on this
|
||||
host's resources, and the Ansible inventory target linked to it with its
|
||||
groups (`ansible_target`: null when unlinked).
|
||||
host's resources, its open tickets (`open_tickets`: known problems someone
|
||||
recorded, list_tickets / get_ticket), and the Ansible inventory target
|
||||
linked to it with its groups (`ansible_target`: null when unlinked).
|
||||
|
||||
The agent's values are `agent.metrics` (host-level) and its sub-resources
|
||||
grouped by kind: `mounts` (fullest first), `interfaces` and `disk_io`
|
||||
@@ -166,6 +168,7 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
|
||||
select(AnsibleTarget).where(AnsibleTarget.host_id == h.id)
|
||||
.options(selectinload(AnsibleTarget.groups))
|
||||
)).scalar_one_or_none()
|
||||
tickets = await open_tickets_for(db, host=h.name)
|
||||
|
||||
agent = None
|
||||
if samples:
|
||||
@@ -218,6 +221,7 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
|
||||
],
|
||||
"agent": agent,
|
||||
"active_alerts": alerts,
|
||||
"open_tickets": tickets,
|
||||
"ansible_target": {
|
||||
"target_id": target.id,
|
||||
"target_name": target.name,
|
||||
|
||||
@@ -16,6 +16,7 @@ from ._audit import audit_mcp
|
||||
from ._shape import (
|
||||
age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url, rnd,
|
||||
)
|
||||
from .tickets import open_tickets_for
|
||||
|
||||
_STATES = ("up", "down", "pending", "disabled")
|
||||
|
||||
@@ -126,6 +127,7 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
|
||||
often it runs; `interval_from` says whether that is its own setting
|
||||
("monitor") or the global monitors interval it follows ("global").
|
||||
Header values in an HTTP monitor's configuration are redacted.
|
||||
`open_tickets` are the open tickets about it (list_tickets).
|
||||
"""
|
||||
failures = clamp_limit(failures, 10, 50)
|
||||
async with db_session() as db:
|
||||
@@ -133,11 +135,13 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
|
||||
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
||||
st = (await monitor_svc.monitor_states(db, [m]))[m.id]
|
||||
failed = await monitor_svc.recent_failures(db, m.id, failures)
|
||||
tickets = await open_tickets_for(db, subject=m.name)
|
||||
return {
|
||||
**_row(m, st, host_names),
|
||||
**_interval(m),
|
||||
"config": _config(m),
|
||||
"heartbeat": "".join("+" if r.is_up else "-" for r in st["recent"]),
|
||||
"open_tickets": tickets,
|
||||
"recent_failures": [
|
||||
{
|
||||
"checked_at": iso(r.checked_at),
|
||||
|
||||
@@ -18,12 +18,16 @@ from steward.models.hosts import Host
|
||||
from steward.models.monitors import Monitor
|
||||
from steward.models.users import UserRole
|
||||
from steward.monitors import service as monitor_svc
|
||||
from steward.tickets import service as tk
|
||||
|
||||
from ._shape import age_s, db_session, iso, now_utc, rnd
|
||||
from .tickets import brief
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FINDINGS_CAP = 50
|
||||
# Open tickets get_overview lists; list_tickets has the rest.
|
||||
TICKETS_CAP = 10
|
||||
# A scheduled backup this long past its slot with none made is overdue: one
|
||||
# 15-minute tick and the hour a failed try waits, with room to spare.
|
||||
BACKUP_OVERDUE_AFTER = timedelta(hours=2)
|
||||
@@ -291,6 +295,10 @@ async def get_overview() -> dict:
|
||||
|
||||
`counts` sizes the fleet; `counts.anomalies_to_review` is how many metric
|
||||
anomalies wait for someone to mark them normal or real (list_anomalies).
|
||||
`open_tickets` are known problems someone recorded and is working, most
|
||||
recently worked first (up to 10; `counts.tickets_open` counts them all,
|
||||
list_tickets lists them). A finding an open ticket covers is already
|
||||
known: read the ticket (get_ticket) before treating it as new.
|
||||
`active_maintenance` names windows currently silencing alerts.
|
||||
`all_clear` is true when nothing is wrong.
|
||||
"""
|
||||
@@ -322,6 +330,8 @@ async def get_overview() -> dict:
|
||||
open_anomalies = await anom.open_anomalies(db)
|
||||
to_review = await anom.to_review_count(db)
|
||||
settings = await get_all_settings(db)
|
||||
open_tickets = list((await db.execute(tk.query(status=tk.OPEN).limit(TICKETS_CAP + 1))).scalars())
|
||||
tickets_open = await tk.open_count(db)
|
||||
|
||||
thresholds = app.config.get("THRESHOLDS") or {}
|
||||
listed, silent, speaking = fold_agent_silence(active, agent)
|
||||
@@ -347,7 +357,10 @@ async def get_overview() -> dict:
|
||||
"critical": sum(1 for f in findings if f["severity"] == "critical"),
|
||||
"warning": sum(1 for f in findings if f["severity"] == "warning"),
|
||||
"anomalies_to_review": to_review,
|
||||
"tickets_open": tickets_open,
|
||||
},
|
||||
# Known problems someone is working: what is wrong that is already known.
|
||||
"open_tickets": [brief(t) for t in open_tickets[:TICKETS_CAP]],
|
||||
"active_maintenance": [
|
||||
{"window_id": w.id, "name": w.name, "end_at": iso(w.end_at),
|
||||
"scope": {"source_module": w.scope_source, "resource": w.scope_resource}}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tickets: known problems, open until someone resolves them, worked from MCP
|
||||
the same as from the Tickets pages (steward.tickets.service)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from steward.mcp._context import current_caller
|
||||
from steward.models.users import UserRole
|
||||
from steward.tickets import service as tk
|
||||
from steward.timeline import config as tl_config
|
||||
|
||||
from ._audit import audit_mcp, mcp_username
|
||||
from ._shape import age_s, bounded, clamp_limit, db_session, iso
|
||||
from .timeline import area_key, host_hint
|
||||
|
||||
LIST_MAX = 200
|
||||
|
||||
|
||||
def brief(t) -> dict:
|
||||
"""A ticket in a list, or carried by another tool's reply (get_overview,
|
||||
get_host, get_monitor, get_container, list_alerts, list_anomalies)."""
|
||||
return {"ticket": t.id, "title": t.title, "status": t.status, "area": t.area, "hosts": t.hosts,
|
||||
"subject": t.subject, "opened_at": iso(t.opened_at), "opened_by": t.opened_by,
|
||||
"updated_at": iso(t.updated_at), "age_s": age_s(t.opened_at),
|
||||
"resolved_at": iso(t.resolved_at), "resolved_by": t.resolved_by}
|
||||
|
||||
|
||||
async def open_tickets_for(db, *, host: str = "", subject: str = "") -> list[dict]:
|
||||
return [brief(t) for t in await tk.for_page(db, host=host, subject=subject)]
|
||||
|
||||
|
||||
async def _full(db, t) -> dict:
|
||||
log = await tk.notes(db, t.id)
|
||||
return {
|
||||
**brief(t),
|
||||
"description": t.body,
|
||||
"resolution": t.resolution,
|
||||
"links": await tk.link_views(db, await tk.links_of(db, t.id)),
|
||||
"log": [{"at": iso(n.at), "kind": n.kind, "by": n.actor, "text": n.body} for n in log],
|
||||
"url": f"/tickets/{t.id}",
|
||||
}
|
||||
|
||||
|
||||
async def _ticket(db, ticket):
|
||||
t = await tk.get(db, ticket)
|
||||
if t is None:
|
||||
raise ValueError(f"No ticket #{str(ticket).lstrip('#')}; list_tickets lists them.")
|
||||
return t
|
||||
|
||||
|
||||
def _hosts(hosts) -> list[str]:
|
||||
if isinstance(hosts, str):
|
||||
hosts = hosts.split(",")
|
||||
return [h.strip() for h in (hosts or []) if h and h.strip()]
|
||||
|
||||
|
||||
def _links(alert_rules=None, anomalies=None) -> list[tuple[str, str]]:
|
||||
def ids(v):
|
||||
return [i.strip() for i in (v.split(",") if isinstance(v, str) else (v or [])) if i and str(i).strip()]
|
||||
return [("alert_rule", i) for i in ids(alert_rules)] + [("anomaly", i) for i in ids(anomalies)]
|
||||
|
||||
|
||||
async def list_tickets(status: str = "open", host: str = "", subject: str = "", area: str = "",
|
||||
limit: int = 50) -> dict:
|
||||
"""Tickets: known problems someone recorded, open until resolved. Open
|
||||
ones come first, most recently worked on first.
|
||||
|
||||
Read the open tickets before reporting what is wrong: an alert or anomaly
|
||||
an open ticket covers is already known (list_alerts and list_anomalies
|
||||
mark it `known_issue`), and the ticket says what has been tried.
|
||||
|
||||
status: "open" (default), "resolved" or "all".
|
||||
host: a host's name, exactly; empty for every host.
|
||||
subject: what a ticket is about, exactly (a resource, "<host>/<container>",
|
||||
a monitor's name); empty for any.
|
||||
area: an area key (get_timeline's `areas`); empty for all.
|
||||
limit: default 50, max 200.
|
||||
"""
|
||||
if status not in (*tk.STATUSES, "all"):
|
||||
raise ValueError('status is "open", "resolved" or "all".')
|
||||
limit = clamp_limit(limit, 50, LIST_MAX)
|
||||
async with db_session() as db:
|
||||
rows = list((await db.execute(tk.query(status=status, host=host.strip(), subject=subject.strip(),
|
||||
area=area.strip()).limit(limit + 1))).scalars())
|
||||
open_n = await tk.open_count(db)
|
||||
out = {**bounded([brief(t) for t in rows], limit, key="tickets"), "open_total": open_n}
|
||||
if out["truncated"]:
|
||||
out["total"] = None
|
||||
out["more"] = f"More than {limit} tickets. Pass a larger `limit` (max {LIST_MAX}) or filter."
|
||||
return out
|
||||
|
||||
|
||||
async def get_ticket(ticket: int | str) -> dict:
|
||||
"""One ticket in full: its description, what it links to (alert rules,
|
||||
anomalies), its resolution, and its log of comments, edits, resolving and
|
||||
reopening, oldest first.
|
||||
|
||||
ticket: its number (12 or "#12").
|
||||
"""
|
||||
async with db_session() as db:
|
||||
return await _full(db, await _ticket(db, ticket))
|
||||
|
||||
|
||||
async def open_ticket(title: str, area: str, description: str = "", hosts: list[str] | str | None = None,
|
||||
subject: str = "", alert_rules: list[str] | str | None = None,
|
||||
anomalies: list[str] | str | None = None) -> dict:
|
||||
"""Open a ticket for a known problem that will take more than a moment to
|
||||
fix: a disk failing slowly, a service that keeps restarting, a backup that
|
||||
times out at night. Acknowledging an alert only quiets it; a ticket keeps
|
||||
what is known, what was tried and, once resolved, what fixed it, and it
|
||||
marks the alerts and anomalies it covers as a known issue. Check
|
||||
list_tickets first for one already open on it.
|
||||
|
||||
title: what is wrong, one line (up to 200 characters).
|
||||
area: what part of the infrastructure it is about, by key or label:
|
||||
service, host_os, hardware, storage, network, security, monitoring
|
||||
(get_timeline's `areas`).
|
||||
description: what is seen, since when, what has been tried (optional).
|
||||
hosts: the host(s) it is on, by exact name (list_hosts).
|
||||
subject: what it is about when that is not a host as a whole: an alert
|
||||
rule's resource, "<host>/<container>", a monitor's name. An alert or
|
||||
anomaly on this resource reads as a known issue while it is open.
|
||||
alert_rules: alert rule ids to link (list_alerts' `rule_id`).
|
||||
anomalies: anomaly ids to link (list_anomalies' `anomaly_id`).
|
||||
|
||||
Opening it goes on the timeline. Recorded in the audit log as "<you> via
|
||||
MCP".
|
||||
"""
|
||||
caller = current_caller()
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
cfg = await tl_config.load(db)
|
||||
try:
|
||||
f = await tk.fields(db, cfg, title=title, area=area_key(cfg, area), body=description,
|
||||
hosts=_hosts(hosts), subject=subject)
|
||||
except ValueError as exc:
|
||||
raise host_hint(exc) from None
|
||||
links = await tk.check_links(db, _links(alert_rules, anomalies))
|
||||
t = await tk.open_ticket(db, f, actor=mcp_username(caller.username), author_id=caller.user_id,
|
||||
links=links)
|
||||
out = await _full(db, t)
|
||||
await audit_mcp("ticket.opened", "ticket", str(t.id),
|
||||
{"name": t.title, "hosts": t.hosts, "links": [f"{r}:{i}" for r, i in links]})
|
||||
return out
|
||||
|
||||
|
||||
async def update_ticket(ticket: int | str, title: str | None = None, area: str | None = None,
|
||||
description: str | None = None, hosts: list[str] | str | None = None,
|
||||
subject: str | None = None, link_alert_rules: list[str] | str | None = None,
|
||||
link_anomalies: list[str] | str | None = None,
|
||||
unlink_alert_rules: list[str] | str | None = None,
|
||||
unlink_anomalies: list[str] | str | None = None) -> dict:
|
||||
"""Change a ticket: its title, area, description, hosts or subject, and
|
||||
what it links to. Only what you pass changes; an empty string clears the
|
||||
description or subject, an empty list clears the hosts. To add to the
|
||||
log, use comment_ticket; to close it, resolve_ticket.
|
||||
|
||||
ticket: its number. The rest as open_ticket; link_*/unlink_* take ids.
|
||||
Recorded in the ticket's log and the audit log.
|
||||
"""
|
||||
caller = current_caller()
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
t = await _ticket(db, ticket)
|
||||
cfg = await tl_config.load(db)
|
||||
try:
|
||||
f = await tk.fields(db, cfg, title=t.title if title is None else title,
|
||||
area=t.area if area is None else area_key(cfg, area),
|
||||
body=t.body if description is None else description,
|
||||
hosts=t.hosts if hosts is None else _hosts(hosts),
|
||||
subject=t.subject if subject is None else subject)
|
||||
except ValueError as exc:
|
||||
raise host_hint(exc) from None
|
||||
add = await tk.check_links(db, _links(link_alert_rules, link_anomalies))
|
||||
changed = await tk.edit(db, t, f, actor=mcp_username(caller.username), add_links=add,
|
||||
remove_links=_links(unlink_alert_rules, unlink_anomalies))
|
||||
out = await _full(db, t)
|
||||
if changed:
|
||||
await audit_mcp("ticket.edited", "ticket", str(t.id), {"name": t.title, "changed": changed})
|
||||
return {**out, "changed": changed}
|
||||
|
||||
|
||||
async def comment_ticket(ticket: int | str, comment: str) -> dict:
|
||||
"""Add a comment to a ticket's log: what you found, what you tried, what
|
||||
happened. Works on a resolved ticket too.
|
||||
|
||||
ticket: its number. comment: up to 8000 characters.
|
||||
Recorded in the audit log.
|
||||
"""
|
||||
return await _act(ticket, "ticket.commented", tk.comment, comment)
|
||||
|
||||
|
||||
async def resolve_ticket(ticket: int | str, resolution: str) -> dict:
|
||||
"""Resolve an open ticket. `resolution` says what fixed it, or why it no
|
||||
longer matters; it goes on the timeline as the record of the fix, so
|
||||
write it for someone reading the host's history later.
|
||||
|
||||
ticket: its number. Recorded in the audit log.
|
||||
"""
|
||||
return await _act(ticket, "ticket.resolved", tk.resolve, resolution)
|
||||
|
||||
|
||||
async def reopen_ticket(ticket: int | str, reason: str = "") -> dict:
|
||||
"""Reopen a resolved ticket: the problem is back, or the fix did not
|
||||
hold. `reason` says why (optional). Goes on the timeline; recorded in the
|
||||
audit log.
|
||||
"""
|
||||
return await _act(ticket, "ticket.reopened", tk.reopen, reason)
|
||||
|
||||
|
||||
async def _act(ticket, action: str, fn, text: str) -> dict:
|
||||
caller = current_caller()
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
t = await _ticket(db, ticket)
|
||||
await fn(db, t, text, actor=mcp_username(caller.username))
|
||||
out = await _full(db, t)
|
||||
await audit_mcp(action, "ticket", str(t.id), {"name": t.title})
|
||||
return out
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(list_tickets, role=UserRole.viewer, read_only=True)
|
||||
register_tool(get_ticket, role=UserRole.viewer, read_only=True)
|
||||
for fn in (open_ticket, update_ticket, comment_ticket, resolve_ticket, reopen_ticket):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
@@ -84,8 +84,9 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
|
||||
return out
|
||||
|
||||
|
||||
def _area(cfg, area: str) -> str:
|
||||
"""An area by its key or its label, as Settings → Timeline names it."""
|
||||
def area_key(cfg, area: str) -> str:
|
||||
"""An area by its key or its label, as Settings → Timeline names it
|
||||
(log_change, and the ticket tools)."""
|
||||
want = (area or "").strip().lower()
|
||||
for key, label in cfg.areas:
|
||||
if want in (key, label.lower()):
|
||||
@@ -93,6 +94,22 @@ def _area(cfg, area: str) -> str:
|
||||
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).")
|
||||
|
||||
|
||||
def _when(at: str):
|
||||
"""An ISO-8601 time (no zone is UTC), or None for an empty one."""
|
||||
if not (at or "").strip():
|
||||
return None
|
||||
try:
|
||||
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
|
||||
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def host_hint(exc: ValueError) -> ValueError:
|
||||
"""A refusal naming a host that isn't one, pointed at the tool that lists them."""
|
||||
return ValueError(f"{exc} list_hosts gives the names.") if "No host is named" in str(exc) else exc
|
||||
|
||||
|
||||
async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "",
|
||||
detail: str = "", at: str = "") -> dict:
|
||||
"""Record on the timeline a change Steward can't see happen: a config
|
||||
@@ -115,29 +132,22 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
|
||||
4000 characters).
|
||||
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
|
||||
|
||||
Shown as by "<you> via MCP"; the person whose token this is can edit or
|
||||
delete it from the Timeline page. Recorded in the audit log.
|
||||
Shown as by "<you> via MCP". It can be corrected with update_change or
|
||||
taken back with delete_change, by the person whose token this is or an
|
||||
admin. Recorded in the audit log.
|
||||
"""
|
||||
if isinstance(hosts, str):
|
||||
hosts = hosts.split(",")
|
||||
when = None
|
||||
if (at or "").strip():
|
||||
try:
|
||||
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
|
||||
when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
||||
when = _when(at)
|
||||
caller = current_caller()
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
cfg = await tl_config.load(db)
|
||||
try:
|
||||
f = await changes.fields(db, cfg, summary=summary, area=_area(cfg, area), hosts=hosts or [],
|
||||
f = await changes.fields(db, cfg, summary=summary, area=area_key(cfg, area), hosts=hosts or [],
|
||||
subject=subject, note=detail, at=when)
|
||||
except ValueError as exc:
|
||||
if "No host is named" in str(exc):
|
||||
raise ValueError(f"{exc} list_hosts gives the names.") from None
|
||||
raise
|
||||
raise host_hint(exc) from None
|
||||
entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username))
|
||||
[v] = await tl.views(db, cfg, [entry])
|
||||
await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id,
|
||||
@@ -145,6 +155,67 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
|
||||
return _entry(v)
|
||||
|
||||
|
||||
async def _own(db, entry_id: str):
|
||||
"""A recorded change the caller may edit: one their token's user wrote,
|
||||
or any for an admin."""
|
||||
caller = current_caller()
|
||||
entry = await changes.get(db, entry_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"No recorded change has the id {entry_id!r} (get_timeline's `entry_id`, "
|
||||
"on an entry of type change.recorded).")
|
||||
if not changes.can_edit(entry, caller.user_id, getattr(caller.role, "value", caller.role)):
|
||||
raise ValueError(f"Change {entry_id} was recorded by {entry.actor}; only they or an admin can change it.")
|
||||
return entry
|
||||
|
||||
|
||||
async def update_change(entry_id: str, summary: str | None = None, area: str | None = None,
|
||||
hosts: list[str] | str | None = None, subject: str | None = None,
|
||||
detail: str | None = None, at: str | None = None) -> dict:
|
||||
"""Correct a change recorded with log_change (or on the Timeline page):
|
||||
only what you pass changes; an empty string clears the subject or
|
||||
detail, an empty list the hosts. Only its author or an admin can.
|
||||
|
||||
entry_id: the change's `entry_id` (log_change's reply, or get_timeline).
|
||||
The rest as log_change. Recorded in the audit log.
|
||||
"""
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
entry = await _own(db, entry_id)
|
||||
cfg = await tl_config.load(db)
|
||||
if isinstance(hosts, str):
|
||||
hosts = hosts.split(",")
|
||||
try:
|
||||
f = await changes.fields(
|
||||
db, cfg, summary=entry.summary if summary is None else summary,
|
||||
area=entry.area if area is None else area_key(cfg, area),
|
||||
hosts=entry.hosts if hosts is None else hosts,
|
||||
subject=(entry.subject or "") if subject is None else subject,
|
||||
note=((entry.detail or {}).get("note") or "") if detail is None else detail,
|
||||
at=entry.at if at is None else _when(at))
|
||||
except ValueError as exc:
|
||||
raise host_hint(exc) from None
|
||||
changes.edit(entry, f)
|
||||
[v] = await tl.views(db, cfg, [entry])
|
||||
await audit_mcp("timeline.change_edited", "timeline_entry", entry.id, {"name": entry.summary})
|
||||
return _entry(v)
|
||||
|
||||
|
||||
async def delete_change(entry_id: str) -> dict:
|
||||
"""Take a recorded change off the timeline: one logged by mistake, or
|
||||
twice. Only its author or an admin can; the audit log keeps that it was.
|
||||
|
||||
entry_id: the change's `entry_id`.
|
||||
"""
|
||||
async with db_session() as db:
|
||||
async with db.begin():
|
||||
entry = await _own(db, entry_id)
|
||||
summary = entry.summary
|
||||
await db.delete(entry)
|
||||
await audit_mcp("timeline.change_deleted", "timeline_entry", entry_id, {"name": summary})
|
||||
return {"deleted": entry_id, "summary": summary}
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
|
||||
register_tool(log_change, role=UserRole.operator, read_only=False)
|
||||
for fn in (log_change, update_change, delete_change):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
|
||||
Reference in New Issue
Block a user