Dashboard: pipeline status view (Prefect flow runs + recent review actions, 30s auto-refresh); reviewed_at column
This commit is contained in:
@@ -132,6 +132,22 @@ def stats():
|
||||
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:
|
||||
"""Update DB status ONLY — instant. File moves happen at import time."""
|
||||
conn = _conn()
|
||||
@@ -139,7 +155,9 @@ def _set_status(sha: str, status: str, request: Request) -> HTMLResponse:
|
||||
if not row:
|
||||
conn.close()
|
||||
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()
|
||||
row = conn.execute("SELECT * FROM image_hashes WHERE sha256=?", (sha,)).fetchone()
|
||||
conn.close()
|
||||
@@ -180,7 +198,9 @@ async def bulk_action(request: Request):
|
||||
updated = 0
|
||||
missing = 0
|
||||
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:
|
||||
updated += 1
|
||||
else:
|
||||
|
||||
73
dashboard/templates/pipeline.html
Normal file
73
dashboard/templates/pipeline.html
Normal 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
105
status_helper.py
Normal 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','?')}")
|
||||
Reference in New Issue
Block a user