activity-core/src/activity_core/ops_api.py

675 lines
25 KiB
Python
Raw Normal View History

"""FastAPI router for operator automation console (ACTIVITY-WP-0024)."""
from __future__ import annotations
import html
import json
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Callable
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from temporalio.client import Client
from activity_core.ops_auth import (
HEADER_NAME,
extract_sso_principal,
operator_token_configured,
require_operator,
)
from activity_core.ops_console import (
is_side_effect_definition,
ops_definition_detail,
ops_inventory,
ops_run_detail,
ops_runs,
ops_status,
recent_audits,
record_ops_audit,
set_definition_enabled,
set_schedule_paused,
)
from activity_core.orm import ActivityDefinition as ActivityDefinitionRow
router = APIRouter(prefix="/ops", tags=["ops-console"])
_get_db: Callable[[], async_sessionmaker[AsyncSession]] | None = None
_get_temporal: Callable[[], Client] | None = None
def bind_ops_deps(
get_db: Callable[[], async_sessionmaker[AsyncSession]],
get_temporal: Callable[[], Client],
) -> None:
global _get_db, _get_temporal
_get_db = get_db
_get_temporal = get_temporal
def _db() -> async_sessionmaker[AsyncSession]:
assert _get_db is not None
return _get_db()
def _temporal() -> Client:
assert _get_temporal is not None
return _get_temporal()
def _db_url() -> str | None:
return os.environ.get("ACTCORE_DB_URL")
class TriggerBody(BaseModel):
confirm_side_effect: bool = False
class MutateBody(BaseModel):
note: str | None = Field(default=None, max_length=500)
@router.get("/automations")
async def list_automations(
enabled: str = Query(default="all"),
target_repo: str | None = Query(
default=None,
description="Filter automations whose name/id/labels mention this repo slug (ACTIVITY-WP-0028)",
),
) -> dict[str, Any]:
report = await ops_inventory(
db_url=_db_url(),
temporal_host=os.environ.get("TEMPORAL_HOST"),
temporal_namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"),
enabled=enabled,
)
if target_repo and isinstance(report, dict):
needle = target_repo.strip().lower()
autos = report.get("automations") or []
if isinstance(autos, list) and needle:
filtered = []
for a in autos:
if not isinstance(a, dict):
continue
blob = " ".join(
str(a.get(k) or "")
for k in ("name", "id", "activity_id", "slug", "labels")
).lower()
if needle in blob:
filtered.append(a)
report = dict(report)
report["automations"] = filtered
report["filters"] = {
**(report.get("filters") or {}),
"target_repo": target_repo,
}
return report
@router.get("/automations/status")
async def automations_status(
since: str = Query(default="today"),
until: str | None = Query(default=None),
timezone_name: str = Query(default="Europe/Berlin", alias="timezone"),
activity_id: str | None = Query(default=None),
) -> dict[str, Any]:
report = await ops_status(
since=since,
until=until,
timezone_name=timezone_name,
db_url=_db_url(),
temporal_host=os.environ.get("TEMPORAL_HOST"),
temporal_namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"),
state_hub_url=os.environ.get("STATE_HUB_URL"),
activity_id=activity_id,
)
# ACTIVITY-WP-0026 T05: ops_run claim-queue visibility + SLA signals
try:
from datetime import timedelta
from sqlalchemy import and_, func, select
from activity_core.orm import OpsRun
from activity_core.ops_run_queue import ops_run_counts
sla_hours = float(os.environ.get("OPS_RUN_SLA_HOURS", "1") or "1")
now = datetime.now(timezone.utc)
sla_cutoff = now - timedelta(hours=max(0.1, sla_hours))
Session = _db()
async with Session() as session:
counts = await ops_run_counts(session)
stuck_stmt = select(func.count()).where(
and_(
OpsRun.state.in_(("open", "claimed")),
OpsRun.created_at < sla_cutoff,
)
)
stuck = int((await session.execute(stuck_stmt)).scalar_one() or 0)
report["ops_runs"] = {
"counts": counts,
"stuck_open_or_claimed": stuck,
"sla_hours": sla_hours,
"list_url": "/ops-runs?state=open",
}
except Exception as exc: # table may not exist pre-migration
report["ops_runs"] = {"error": str(exc), "counts": {}}
return report
@router.get("/automations/{definition_id}")
async def get_automation(definition_id: uuid.UUID) -> dict[str, Any]:
detail = await ops_definition_detail(
_db(),
_temporal(),
definition_id,
db_url=_db_url(),
temporal_host=os.environ.get("TEMPORAL_HOST"),
temporal_namespace=os.environ.get("TEMPORAL_NAMESPACE", "default"),
)
if not detail:
raise HTTPException(status_code=404, detail="ActivityDefinition not found")
return detail
@router.get("/automations/{definition_id}/runs")
async def list_runs(
definition_id: uuid.UUID,
since: str | None = Query(default=None, description="ISO datetime lower bound on fired_at"),
limit: int = Query(default=50, ge=1, le=200),
) -> dict[str, Any]:
since_dt: datetime | None = None
if since:
try:
since_dt = datetime.fromisoformat(since)
if since_dt.tzinfo is None:
since_dt = since_dt.replace(tzinfo=timezone.utc)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid since: {exc}") from exc
async with _db()() as session:
if await session.get(ActivityDefinitionRow, definition_id) is None:
raise HTTPException(status_code=404, detail="ActivityDefinition not found")
return await ops_runs(_db(), definition_id, since=since_dt, limit=limit)
@router.get("/automations/{definition_id}/runs/{run_id}")
async def get_run(
definition_id: uuid.UUID,
run_id: uuid.UUID,
) -> dict[str, Any]:
detail = await ops_run_detail(_db(), definition_id, run_id)
if not detail:
raise HTTPException(status_code=404, detail="run not found")
return detail
@router.post("/automations/{definition_id}/trigger")
async def trigger_automation(
definition_id: uuid.UUID,
body: TriggerBody | None = None,
principal: str = Depends(require_operator),
) -> dict[str, Any]:
body = body or TriggerBody()
Session = _db()
async with Session() as session:
row = await session.get(ActivityDefinitionRow, definition_id)
if row is None:
raise HTTPException(status_code=404, detail="ActivityDefinition not found")
if is_side_effect_definition(row) and not body.confirm_side_effect:
raise HTTPException(
status_code=400,
detail=(
"this activity may perform side effects; "
"pass confirm_side_effect=true to proceed"
),
)
name = row.name
trigger_key = f"manual-{uuid.uuid4()}"
workflow_id = f"activity-{definition_id}:{trigger_key}"
handle = await _temporal().start_workflow(
"RunActivityWorkflow",
args=[str(definition_id), trigger_key, datetime.now(tz=timezone.utc).isoformat()],
id=workflow_id,
task_queue="orchestrator-tq",
)
audit = await record_ops_audit(
action="trigger",
activity_id=str(definition_id),
activity_name=name,
principal=principal,
detail={"workflow_id": handle.id, "trigger_key": trigger_key},
)
return {
"workflow_id": handle.id,
"trigger_key": trigger_key,
"activity_id": str(definition_id),
"name": name,
"audit": audit,
}
@router.post("/automations/{definition_id}/enable")
async def enable_automation(
definition_id: uuid.UUID,
principal: str = Depends(require_operator),
) -> dict[str, Any]:
try:
return await set_definition_enabled(
_db(), _temporal(), definition_id, enabled=True, principal=principal
)
except KeyError:
raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None
@router.post("/automations/{definition_id}/disable")
async def disable_automation(
definition_id: uuid.UUID,
principal: str = Depends(require_operator),
) -> dict[str, Any]:
try:
return await set_definition_enabled(
_db(), _temporal(), definition_id, enabled=False, principal=principal
)
except KeyError:
raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None
@router.post("/automations/{definition_id}/pause")
async def pause_automation(
definition_id: uuid.UUID,
principal: str = Depends(require_operator),
) -> dict[str, Any]:
try:
return await set_schedule_paused(
_db(), _temporal(), definition_id, paused=True, principal=principal
)
except KeyError:
raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None
@router.post("/automations/{definition_id}/unpause")
async def unpause_automation(
definition_id: uuid.UUID,
principal: str = Depends(require_operator),
) -> dict[str, Any]:
try:
return await set_schedule_paused(
_db(), _temporal(), definition_id, paused=False, principal=principal
)
except KeyError:
raise HTTPException(status_code=404, detail="ActivityDefinition not found") from None
@router.get("/audits")
async def list_audits(limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]:
return {"audits": recent_audits(limit)}
@router.get("/auth/status")
async def auth_status() -> dict[str, Any]:
allow = (os.environ.get("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS") or "").lower() in {
"1",
"true",
"yes",
"on",
}
temporal = temporal_ui_url()
return {
"operator_token_configured": operator_token_configured(),
"mutation_header": HEADER_NAME,
# True when a shared token is required for *break-glass* / non-SSO clients.
# Browser SSO (Authelia Remote-User / Remote-Email) does not need the token.
"mutations_require_token": operator_token_configured() or not allow,
"sso_preferred": True,
"sso_headers": ["Remote-User", "Remote-Email", "Remote-Groups"],
"temporal_ui_url": temporal,
"sso_docs": "docs/ops-sso-access.md",
"public_hosts": {
"ops": "https://activity.coulomb.social",
"temporal_ui": temporal,
},
}
# --- Thin UI (no Jinja dependency) -------------------------------------------
_CSS = """
:root { font-family: system-ui, sans-serif; color: #1a1a1a; }
body { margin: 1.5rem; max-width: 1100px; }
nav a { margin-right: 1rem; }
nav a.external::after { content: ""; font-size: 0.75em; opacity: 0.7; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; font-size: 0.9rem; }
th { background: #f4f4f4; }
.status-ok { color: #0a0; } .status-bad { color: #a00; } .status-warn { color: #a60; }
.card { border: 1px solid #ddd; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
button, .btn { padding: 0.35rem 0.7rem; margin-right: 0.35rem; cursor: pointer; }
input[type=password], input[type=text] { padding: 0.3rem; min-width: 16rem; }
.muted { color: #666; font-size: 0.85rem; }
pre { background: #f8f8f8; padding: 0.75rem; overflow: auto; font-size: 0.8rem; }
"""
def temporal_ui_url() -> str:
"""Browser URL for Temporal Web UI (SSO ingress or local port-forward).
Override with ACTIVITY_CORE_TEMPORAL_UI_URL. Default prefers the public SSO
hostname when set via env; otherwise local port-forward on :8080.
"""
raw = (
os.environ.get("ACTIVITY_CORE_TEMPORAL_UI_URL")
or os.environ.get("TEMPORAL_UI_URL")
or "https://temporal.coulomb.social"
).strip()
return raw.rstrip("/") or "https://temporal.coulomb.social"
def _page(title: str, body: str) -> HTMLResponse:
temporal_href = html.escape(temporal_ui_url(), quote=True)
doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>{html.escape(title)} · activity-core ops</title>
<style>{_CSS}</style>
</head>
<body>
<nav>
<strong>activity-core ops</strong>
<a href="/ops/ui">Inventory</a>
<a href="/ops/ui/status?since=sunday">Status</a>
<a class="external" href="{temporal_href}" target="_blank" rel="noopener noreferrer"
title="Temporal Web UI (SSO: temporal.coulomb.social)">Temporal UI</a>
<a href="/ops/auth/status">Auth JSON</a>
</nav>
<hr/>
{body}
<script>
function operatorHeaders() {{
const t = localStorage.getItem('actcore_operator_token') || '';
const h = {{'Content-Type': 'application/json'}};
if (t) h['{HEADER_NAME}'] = t;
return h;
}}
function saveToken() {{
const v = document.getElementById('op-token').value;
localStorage.setItem('actcore_operator_token', v);
alert('Token saved in browser localStorage (not sent to server until you act).');
}}
async function opsAction(id, action, body) {{
const res = await fetch('/ops/automations/' + id + '/' + action, {{
method: 'POST',
headers: operatorHeaders(),
body: body ? JSON.stringify(body) : '{{}}'
}});
const text = await res.text();
let data; try {{ data = JSON.parse(text); }} catch {{ data = text; }}
if (!res.ok) {{
alert(action + ' failed: ' + res.status + ' ' + JSON.stringify(data));
return;
}}
alert(action + ' ok: ' + JSON.stringify(data).slice(0, 400));
location.reload();
}}
</script>
</body>
</html>"""
return HTMLResponse(doc)
def _token_form() -> str:
configured = "yes" if operator_token_configured() else "no"
return f"""
<div class="card">
<p><strong>Auth:</strong> On
<a href="https://activity.coulomb.social/ops/ui">activity.coulomb.social</a>,
Authelia SSO identity is preferred for mutations (no token paste needed).</p>
<label>Break-glass operator token (port-forward / emergency only):
<input id="op-token" type="password" placeholder="X-Operator-Token value" autocomplete="off"/>
</label>
<button type="button" onclick="saveToken()">Save token</button>
<p class="muted">Shared token configured on server: <strong>{configured}</strong>.
Header <code>{html.escape(HEADER_NAME)}</code> only when not using SSO.</p>
</div>
"""
@router.get("/ui", response_class=HTMLResponse)
@router.get("/ui/", response_class=HTMLResponse)
async def ui_index() -> HTMLResponse:
try:
inv = await ops_inventory(
db_url=_db_url(),
temporal_host=os.environ.get("TEMPORAL_HOST"),
)
error = None
except Exception as exc: # noqa: BLE001
inv = {"automations": [], "summary": {}, "warnings": [str(exc)]}
error = str(exc)
rows = []
for a in inv.get("automations") or []:
aid = html.escape(str(a.get("id") or ""))
name = html.escape(str(a.get("name") or ""))
enabled = "yes" if a.get("enabled") else "no"
cron = html.escape(str(a.get("cron_expression") or a.get("at") or ""))
tz = html.escape(str(a.get("timezone") or ""))
paused = a.get("temporal") or {}
pstate = html.escape(str(paused.get("paused") if "paused" in paused else paused.get("status")))
rows.append(
f"<tr><td><a href='/ops/ui/automations/{aid}'>{name}</a></td>"
f"<td>{enabled}</td><td>{html.escape(str(a.get('trigger_type')))}</td>"
f"<td><code>{cron}</code></td><td>{tz}</td><td>{pstate}</td></tr>"
)
summary = html.escape(json.dumps(inv.get("summary") or {}, indent=2))
warn = html.escape("; ".join(inv.get("warnings") or []) or "none")
err_html = f"<p class='status-bad'>Error: {html.escape(error)}</p>" if error else ""
body = f"""
<h1>Scheduled automations</h1>
{err_html}
{_token_form()}
<p class="muted">Warnings: {warn}</p>
<pre>{summary}</pre>
<table>
<thead><tr><th>Name</th><th>Enabled</th><th>Trigger</th><th>Schedule</th><th>TZ</th><th>Temporal</th></tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan="6">No automations found</td></tr>'}</tbody>
</table>
<p class="muted">Schedule cron is read-only in MVP edit definition files + sync.</p>
<p class="muted">Temporal Web UI (workflow debugger):
<a class="external" href="{html.escape(temporal_ui_url(), quote=True)}"
target="_blank" rel="noopener noreferrer">{html.escape(temporal_ui_url())}</a>
primary SSO URL; port-forward of <code>svc/actcore-temporal-ui</code> is break-glass only.</p>
"""
return _page("Inventory", body)
@router.get("/ui/status", response_class=HTMLResponse)
async def ui_status(since: str = Query(default="sunday")) -> HTMLResponse:
try:
report = await ops_status(
since=since,
db_url=_db_url(),
temporal_host=os.environ.get("TEMPORAL_HOST"),
state_hub_url=os.environ.get("STATE_HUB_URL"),
)
error = None
except Exception as exc: # noqa: BLE001
report = None
error = str(exc)
if error or not report:
body = f"<h1>Status</h1><p class='status-bad'>{html.escape(error or 'no report')}</p>{_token_form()}"
return _page("Status", body)
rows = []
for a in report.get("activities") or []:
st = str(a.get("status") or "")
cls = "status-ok" if st in {"completed", "ok", "disabled"} else (
"status-bad" if st in {"missed", "validation_failed", "sink_failed"} else "status-warn"
)
aid = html.escape(str(a.get("id") or ""))
last_run = a.get("last_run") or {}
last_at = (
a.get("last_run_at")
or last_run.get("fired_at")
or a.get("temporal_last_fired_at")
or ""
)
last_tasks = last_run.get("tasks_spawned")
last_tasks_s = "" if last_tasks is None else str(last_tasks)
run_count = a.get("observed_run_count", a.get("run_count", a.get("runs_count", "")))
expected_n = a.get("expected_fire_count")
if expected_n is None:
expected_n = len(a.get("expected_fires") or a.get("expected") or [])
rows.append(
f"<tr><td><a href='/ops/ui/automations/{aid}'>{html.escape(str(a.get('name')))}</a></td>"
f"<td class='{cls}'>{html.escape(st)}</td>"
f"<td title='latest fire in selected window (or Temporal last action)'>"
f"<code>{html.escape(str(last_at))}</code></td>"
f"<td>{html.escape(last_tasks_s)}</td>"
f"<td>{html.escape(str(run_count))}</td>"
f"<td>{html.escape(str(expected_n))}</td>"
f"<td>{html.escape(str(a.get('enabled')))}</td></tr>"
)
window = html.escape(json.dumps(report.get("window") or {}, indent=2))
summary = html.escape(json.dumps(report.get("summary") or {}, indent=2))
body = f"""
<h1>Automation status</h1>
{_token_form()}
<form method="get" action="/ops/ui/status">
<label>since <input type="text" name="since" value="{html.escape(since)}"/></label>
<button type="submit">Refresh</button>
</form>
<p class="muted">Last run = newest <code>fired_at</code> in the selected window
(falls back to Temporal schedule last action when no DB run is in-window).</p>
<pre>window: {window}</pre>
<pre>summary: {summary}</pre>
<table>
<thead><tr>
<th>Name</th><th>Status</th><th>Last run</th><th>Tasks</th>
<th>Runs</th><th>Expected</th><th>Enabled</th>
</tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan="7">No activities</td></tr>'}</tbody>
</table>
"""
return _page("Status", body)
@router.get("/ui/automations/{definition_id}", response_class=HTMLResponse)
async def ui_detail(definition_id: uuid.UUID) -> HTMLResponse:
detail = await ops_definition_detail(
_db(),
_temporal(),
definition_id,
db_url=_db_url(),
temporal_host=os.environ.get("TEMPORAL_HOST"),
)
if not detail:
raise HTTPException(status_code=404, detail="not found")
runs = await ops_runs(_db(), definition_id, limit=25)
aid = str(definition_id)
name = html.escape(str(detail.get("name") or ""))
side = bool(detail.get("side_effect"))
confirm = "true" if side else "false"
run_rows = []
for r in runs.get("runs") or []:
rid = str(r.get("run_id") or "")
arts = r.get("artifacts") or []
if arts:
art_html = " ".join(
f'<a href="{html.escape(str(a.get("url")))}" target="_blank" rel="noopener">'
f'{html.escape(str(a.get("label") or a.get("kind") or "artifact"))}</a>'
for a in arts
if a.get("url") and str(a.get("url")).startswith("https://")
) or '<span class="muted">none</span>'
else:
ops_states = [o.get("state") for o in (r.get("ops_runs") or [])]
if any(s == "open" for s in ops_states):
art_html = '<span class="muted">pending</span>'
elif any(s in ("failed", "expired") for s in ops_states):
art_html = '<span class="muted">failed</span>'
else:
art_html = '<span class="muted">none</span>'
run_rows.append(
f"<tr><td><a href='/ops/ui/automations/{html.escape(aid)}/runs/{html.escape(rid)}'>"
f"<code>{html.escape(rid[:13])}…</code></a></td>"
f"<td>{html.escape(str(r.get('fired_at') or ''))}</td>"
f"<td>{html.escape(str(r.get('scheduled_for') or ''))}</td>"
f"<td>{html.escape(str(r.get('tasks_spawned')))}</td>"
f"<td>{html.escape(str(len((r.get('evidence') or {}).get('task_spawns') or [])))}</td>"
f"<td>{art_html}</td></tr>"
)
detail_json = html.escape(json.dumps(detail, indent=2, default=str))
body = f"""
<h1>{name}</h1>
{_token_form()}
<p class="muted">id: <code>{html.escape(aid)}</code>
side_effect_risk: <strong>{side}</strong></p>
<div class="card">
<button type="button" onclick="opsAction('{html.escape(aid)}','trigger',{{confirm_side_effect:{confirm}}})">Run now</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','enable')">Enable</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','disable')">Disable</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','pause')">Pause schedule</button>
<button type="button" onclick="opsAction('{html.escape(aid)}','unpause')">Unpause schedule</button>
</div>
<h2>Definition (read-only schedule)</h2>
<pre>{detail_json}</pre>
<h2>Recent runs</h2>
<table>
<thead><tr><th>run_id</th><th>fired_at</th><th>scheduled_for</th><th>tasks</th><th>spawns</th><th>Artifacts</th></tr></thead>
<tbody>{''.join(run_rows) or '<tr><td colspan="6">No runs</td></tr>'}</tbody>
</table>
"""
return _page(name, body)
@router.get("/ui/automations/{definition_id}/runs/{run_id}", response_class=HTMLResponse)
async def ui_run_detail(definition_id: uuid.UUID, run_id: uuid.UUID) -> HTMLResponse:
run = await ops_run_detail(_db(), definition_id, run_id)
if not run:
raise HTTPException(status_code=404, detail="run not found")
aid = str(definition_id)
rid = str(run_id)
arts = run.get("artifacts") or []
art_lis = []
for a in arts:
url = a.get("url") or ""
label = html.escape(str(a.get("label") or a.get("kind") or "artifact"))
if isinstance(url, str) and url.startswith("https://"):
art_lis.append(
f'<li><a href="{html.escape(url)}" target="_blank" rel="noopener">{label}</a>'
f' <span class="muted">({html.escape(str(a.get("kind") or ""))})</span></li>'
)
else:
art_lis.append(f"<li>{label} <code>{html.escape(str(url))}</code></li>")
ops_blocks = []
for op in run.get("ops_runs") or []:
ops_blocks.append(
"<div class='card'>"
f"<p><strong>ops_run</strong> <code>{html.escape(str(op.get('id')))}</code> "
f"state=<strong>{html.escape(str(op.get('state')))}</strong></p>"
f"<p>{html.escape(str(op.get('title') or ''))}</p>"
f"<pre>{html.escape(json.dumps(op.get('result') or {{}}, indent=2, default=str))}</pre>"
"</div>"
)
body = f"""
<p class="muted"><a href="/ops/ui/automations/{html.escape(aid)}"> back to automation</a></p>
<h1>Run <code>{html.escape(rid)}</code></h1>
<div class="card">
<p>fired_at: <code>{html.escape(str(run.get('fired_at') or ''))}</code></p>
<p>scheduled_for: <code>{html.escape(str(run.get('scheduled_for') or ''))}</code></p>
<p>tasks_spawned: <strong>{html.escape(str(run.get('tasks_spawned')))}</strong>
version: <code>{html.escape(str(run.get('version_used') or ''))}</code></p>
</div>
<h2>Artifacts</h2>
<ul>{''.join(art_lis) or '<li class="muted">No deliverable links for this run</li>'}</ul>
<h2>Ops runs</h2>
{''.join(ops_blocks) or '<p class="muted">No linked ops_runs</p>'}
<h2>Evidence (compact)</h2>
<pre>{html.escape(json.dumps(run.get('evidence') or {{}}, indent=2, default=str))}</pre>
"""
return _page(f"Run {rid[:8]}", body)