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})
|
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:
|
||||||
|
|||||||
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