Dashboard: pipeline status view (Prefect flow runs + recent review actions, 30s auto-refresh); reviewed_at column

This commit is contained in:
2026-08-08 11:23:00 +10:00
parent a6d0ced949
commit 1814b5c5c4
3 changed files with 200 additions and 2 deletions

View File

@@ -132,6 +132,22 @@ def stats():
return json.dumps({"total": total, "by_status": by_status, "by_source": by_source}) return json.dumps({"total": total, "by_status": by_status, "by_source": by_source})
@app.get("/pipeline", response_class=HTMLResponse)
def pipeline(request: Request):
"""Pipeline status: recent Prefect flow runs + recent review actions."""
import sys
if str(BASE.parent) not in sys.path:
sys.path.insert(0, str(BASE.parent))
import status_helper
runs = status_helper.get_flow_runs(limit=15)
actions = status_helper.get_recent_actions(limit=20)
return templates.TemplateResponse(
request, "pipeline.html",
{"runs": runs, "actions": actions},
)
def _set_status(sha: str, status: str, request: Request) -> HTMLResponse: def _set_status(sha: str, status: str, request: Request) -> HTMLResponse:
"""Update DB status ONLY — instant. File moves happen at import time.""" """Update DB status ONLY — instant. File moves happen at import time."""
conn = _conn() conn = _conn()
@@ -139,7 +155,9 @@ def _set_status(sha: str, status: str, request: Request) -> HTMLResponse:
if not row: if not row:
conn.close() conn.close()
raise HTTPException(404) raise HTTPException(404)
conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) conn.execute(
"UPDATE image_hashes SET status=?, reviewed_at=datetime('now') WHERE sha256=?",
(status, sha))
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM image_hashes WHERE sha256=?", (sha,)).fetchone() row = conn.execute("SELECT * FROM image_hashes WHERE sha256=?", (sha,)).fetchone()
conn.close() conn.close()
@@ -180,7 +198,9 @@ async def bulk_action(request: Request):
updated = 0 updated = 0
missing = 0 missing = 0
for sha in shas: for sha in shas:
cur = conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha)) cur = conn.execute(
"UPDATE image_hashes SET status=?, reviewed_at=datetime('now') WHERE sha256=?",
(status, sha))
if cur.rowcount: if cur.rowcount:
updated += 1 updated += 1
else: else:

View File

@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pipeline — photo-pipeline</title>
<meta http-equiv="refresh" content="30">
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; margin: 0; background: #111; color: #eee; }
header { padding: 1rem 1.5rem; border-bottom: 1px solid #333; display: flex; gap: 1.5rem; align-items: baseline; }
header h1 { font-size: 1.2rem; margin: 0; }
header a { color: #6cf; text-decoration: none; }
main { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
h2 { font-size: 1.1rem; margin-top: 2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid #222; }
th { color: #999; font-size: .8rem; text-transform: uppercase; }
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; }
.badge.completed { background: #1d4; color: #031; }
.badge.crashed, .badge.failed { background: #d43; color: #fff; }
.badge.scheduled { background: #44a; color: #fff; }
.badge.running { background: #4a4; color: #fff; }
.badge.approved { background: #1d4; color: #031; }
.badge.rejected { background: #d43; color: #fff; }
.badge.cancelled { background: #666; }
.badge.unknown { background: #333; }
.auto { color: #666; font-size: .8rem; text-align: right; margin-top: 2rem; }
</style>
</head>
<body>
<header>
<h1>📸 photo-pipeline</h1>
<a href="/">Overview</a>
<a href="/review">Review queue</a>
<a href="/pipeline">Pipeline</a>
<a href="/review?status=approved">Approved</a>
</header>
<main>
<h2>Recent flow runs (Prefect)</h2>
<table>
<tr><th>Flow</th><th>State</th><th>Run</th><th>Started</th><th>Duration</th></tr>
{% for r in runs %}
<tr>
<td>{{ r["flow"] }}</td>
<td><span class="badge {{ r["state"]|lower }}">{{ r["state"] }}</span></td>
<td style="color:#aaa">{{ r["name"] }}</td>
<td>{{ r["start"] }}</td>
<td>{{ r["duration"] }}</td>
</tr>
{% else %}
<tr><td colspan="5" style="color:#666;font-style:italic">No flow runs yet</td></tr>
{% endfor %}
</table>
<h2>Recent review actions</h2>
<table>
<tr><th>File</th><th>Decision</th><th>Source</th><th>When</th></tr>
{% for a in actions %}
<tr>
<td>{{ a["name"] }}</td>
<td><span class="badge {{ a["status"] }}">{{ a["status"] }}</span></td>
<td>{{ a["source"] }}</td>
<td>{{ a["when"] }}</td>
</tr>
{% else %}
<tr><td colspan="4" style="color:#666;font-style:italic">No review actions yet — decisions you make appear here</td></tr>
{% endfor %}
</table>
<p class="auto">Auto-refreshes every 30s.</p>
</main>
</body>
</html>

105
status_helper.py Normal file
View File

@@ -0,0 +1,105 @@
"""photo-pipeline: Prefect status helper for the dashboard.
Queries the Prefect API (via the client) for recent flow runs and their state,
plus recent review actions from the fingerprint DB.
"""
import asyncio
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
os.environ.setdefault("PREFECT_API_URL", "http://localhost:4200/api")
BASE = Path(__file__).parent
DB_PATH = BASE / "photo_pipeline.db"
def _fmt_time(dt):
if not dt:
return ""
try:
return dt.astimezone().strftime("%Y-%m-%d %H:%M")
except Exception:
return str(dt)[:16]
def get_flow_runs(limit: int = 15) -> list[dict]:
"""Recent flow runs from Prefect, newest first."""
from prefect.client.orchestration import get_client
async def _fetch():
async with get_client() as client:
runs = await client.read_flow_runs(limit=limit)
# fetch flow names
flow_names = {}
for r in runs:
if r.flow_id not in flow_names:
try:
f = await client.read_flow(r.flow_id)
flow_names[r.flow_id] = f.name
except Exception:
flow_names[r.flow_id] = "?"
out = []
for r in runs:
out.append({
"name": r.name or "?",
"flow": flow_names.get(r.flow_id, "?"),
"state": (r.state_type or "?").replace("StateType.", ""),
"state_name": r.state_name or "",
"start": _fmt_time(r.start_time),
"end": _fmt_time(r.end_time),
"duration": _duration(r.start_time, r.end_time),
})
return out
try:
return asyncio.run(_fetch())
except Exception as e:
return [{"error": str(e)}]
def _duration(start, end):
if not start or not end:
return ""
try:
s = (end - start).total_seconds()
if s < 60:
return f"{s:.0f}s"
if s < 3600:
return f"{s/60:.1f}m"
return f"{s/3600:.1f}h"
except Exception:
return ""
def get_recent_actions(limit: int = 20) -> list[dict]:
"""Recent review decisions from the DB (by reviewed_at timestamp)."""
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT sha256, path, status, source, reviewed_at FROM image_hashes "
"WHERE reviewed_at IS NOT NULL "
"ORDER BY reviewed_at DESC LIMIT ?", (limit,)
).fetchall()
conn.close()
return [{
"name": Path(r["path"]).name,
"status": r["status"],
"source": r["source"],
"when": r["reviewed_at"] or "",
} for r in rows]
except Exception as e:
return [{"error": str(e)}]
if __name__ == "__main__":
import json
print("=== flow runs ===")
for r in get_flow_runs(6):
print(f" {r.get('flow','?'):25s} {r.get('state','?'):12s} {r.get('start','')}")
print("=== recent actions ===")
for a in get_recent_actions(6):
print(f" {a.get('status','?'):10s} {a.get('name','?')}")