Show last run timestamp on ops automation status
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:
parent
a304ad7397
commit
6c34e2c1f1
3 changed files with 75 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue