Show last run timestamp on ops automation status
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 5s
Build and Publish Container Image / build-and-push (push) Successful in 1m9s

Expose last_run_at / last_run on the status contract and display Last run
and Tasks columns on the /ops/ui status page for the selected window.
This commit is contained in:
tegwick 2026-07-22 00:16:25 +02:00
parent a304ad7397
commit 6c34e2c1f1
3 changed files with 75 additions and 4 deletions

View file

@ -654,6 +654,11 @@ def classify_activity(definition: dict[str, Any], window: dict[str, Any], runs:
else:
status = "no_due"
last_run = latest_run_summary(runs)
temporal_last = None
if temporal and temporal.get("last_fired_at"):
temporal_last = temporal.get("last_fired_at")
return {
"id": definition["id"],
"name": definition["name"],
@ -666,6 +671,9 @@ def classify_activity(definition: dict[str, Any], window: dict[str, Any], runs:
"expected_fires": expected,
"expected_fire_count": len(expected),
"observed_run_count": len(runs),
"last_run_at": last_run.get("fired_at") if last_run else None,
"last_run": last_run,
"temporal_last_fired_at": temporal_last,
"runs": runs,
"evidence": evidence,
"temporal": temporal,
@ -674,6 +682,26 @@ def classify_activity(definition: dict[str, Any], window: dict[str, Any], runs:
}
def latest_run_summary(runs: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return the newest run by fired_at (then scheduled_for) within the provided list."""
best: dict[str, Any] | None = None
best_ts: datetime | None = None
for run in runs:
ts = coerce_datetime(run.get("fired_at")) or coerce_datetime(run.get("scheduled_for"))
if ts is None:
continue
if best_ts is None or ts > best_ts:
best_ts = ts
best = {
"run_id": run.get("run_id"),
"fired_at": run.get("fired_at") or iso(ts),
"scheduled_for": run.get("scheduled_for"),
"tasks_spawned": run.get("tasks_spawned"),
"version_used": run.get("version_used"),
}
return best
def workflow_status_matches(workflow: dict[str, Any], names: set[str]) -> bool:
value = str(workflow.get("status") or "").upper()
return any(name in value for name in names)

View file

@ -402,11 +402,27 @@ async def ui_status(since: str = Query(default="sunday")) -> HTMLResponse:
"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>{html.escape(str(a.get('run_count', a.get('runs_count', ''))))}</td>"
f"<td>{html.escape(str(len(a.get('expected_fires') or a.get('expected') or [])))}</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))
@ -418,11 +434,16 @@ async def ui_status(since: str = Query(default="sunday")) -> HTMLResponse:
<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>Runs</th><th>Expected</th><th>Enabled</th></tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan="5">No activities</td></tr>'}</tbody>
<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)

View file

@ -75,6 +75,28 @@ def test_completed_when_expected_run_exists() -> None:
)
assert report["status"] == "completed"
assert report["last_run_at"] == "2026-06-26T07:00:10+00:00"
assert report["last_run"]["run_id"] == "run-1"
assert report["last_run"]["tasks_spawned"] == 1
def test_last_run_picks_newest_fired_at() -> None:
runs = [
{
"run_id": "older",
"fired_at": "2026-06-26T07:00:00+00:00",
"tasks_spawned": 0,
},
{
"run_id": "newer",
"fired_at": "2026-06-28T07:00:00+00:00",
"tasks_spawned": 2,
},
]
summary = status.latest_run_summary(runs)
assert summary is not None
assert summary["run_id"] == "newer"
assert summary["tasks_spawned"] == 2
def test_validation_failure_wins_over_completed_run() -> None: