Add FastAPI+htmx review dashboard (port 8092, systemd service)

This commit is contained in:
2026-08-08 08:52:17 +10:00
parent 5ca92fec7b
commit 11e79b7066
5 changed files with 304 additions and 0 deletions

147
dashboard/app.py Normal file
View File

@@ -0,0 +1,147 @@
"""photo-pipeline dashboard — FastAPI + htmx review UI.
Reads photo_pipeline.db + staging dirs on .13. Serves:
/ — overview: batch/source stats + verdict counts
/review — thumbnail review grid (files in 02_review / 03_delete)
/thumbs/... — generated thumbnails
htmx actions — POST /review/{sha}/approve, /reject, /reset (return updated card)
"""
import sqlite3
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image
BASE = Path(__file__).parent
DB_PATH = BASE.parent / "photo_pipeline.db"
THUMB_DIR = Path("/mnt/data/.thumbs")
THUMB_SIZE = (240, 240)
app = FastAPI(title="photo-pipeline dashboard")
templates = Jinja2Templates(directory=str(BASE / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
EXT_IMAGES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic"}
def _conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _is_image(p: Path) -> bool:
return p.suffix.lower() in EXT_IMAGES and p.exists()
def _thumb(path: str):
"""Generate + return URL for a thumbnail of an image path."""
src = Path(path)
if not src.exists():
return None
key = src.stem + "_" + str(abs(hash(str(src))))[:8] + ".jpg"
THUMB_DIR.mkdir(parents=True, exist_ok=True)
dest = THUMB_DIR / key
if not dest.exists():
try:
with Image.open(src) as im:
im.convert("RGB")
im.thumbnail(THUMB_SIZE)
im.save(dest, "JPEG", quality=70)
except Exception:
return None
return f"/thumbs/{key}"
def _load_item(row: sqlite3.Row) -> dict:
p = Path(row["path"])
return {
"sha256": row["sha256"],
"path": row["path"],
"status": row["status"],
"source": row["source"],
"thumb": _thumb(row["path"]) if _is_image(p) else None,
"exists": p.exists(),
}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
conn = _conn()
rows = conn.execute(
"""SELECT source, status, COUNT(*) as n FROM image_hashes
GROUP BY source, status ORDER BY source, status"""
).fetchall()
conn.close()
return templates.TemplateResponse(
request, "index.html", {"rows": rows, "total": sum(r["n"] for r in rows)}
)
@app.get("/review", response_class=HTMLResponse)
def review(request: Request, source: str = None, status: str = None):
conn = _conn()
q = "SELECT sha256, path, status, source FROM image_hashes WHERE 1=1"
params = []
if source:
q += " AND source=?"
params.append(source)
if status:
q += " AND status=?"
params.append(status)
q += " ORDER BY added_at DESC LIMIT 200"
rows = conn.execute(q, params).fetchall()
conn.close()
items = [_load_item(r) for r in rows]
return templates.TemplateResponse(
request, "review.html",
{"items": items, "source": source, "status": status},
)
def _set_status(sha: str, status: str, request: Request) -> HTMLResponse:
conn = _conn()
row = conn.execute("SELECT * FROM image_hashes WHERE sha256=?", (sha,)).fetchone()
if not row:
conn.close()
raise HTTPException(404)
conn.execute("UPDATE image_hashes SET status=? WHERE sha256=?", (status, sha))
conn.commit()
conn.close()
# return the fresh card (htmx swaps it in place)
return templates.TemplateResponse(
request, "_card.html", {"item": _load_item(row)}
)
@app.post("/review/{sha}/approve")
def approve(sha: str, request: Request):
return _set_status(sha, "approved", request)
@app.post("/review/{sha}/reject")
def reject(sha: str, request: Request):
return _set_status(sha, "rejected", request)
@app.post("/review/{sha}/reset")
def reset_status(sha: str, request: Request):
return _set_status(sha, "scanned", request)
@app.get("/thumbs/{name}")
def thumb_file(name: str):
f = THUMB_DIR / name
if not f.exists():
raise HTTPException(404)
return FileResponse(f)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8092)

1
dashboard/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
{% set sha8 = item["sha256"][:8] %}
<div class="card" id="card-{{ sha8 }}">
{% if item["thumb"] %}
<img src="{{ item["thumb"] }}" loading="lazy" alt="">
{% else %}
<div style="aspect-ratio:1;display:flex;align-items:center;justify-content:center;color:#555;font-size:2rem;"></div>
{% endif %}
<div class="meta">
{{ item["path"].split("/")[-1] }}<br>
<span class="badge {{ item["status"] }}">{{ item["status"] }}</span>
<span style="color:#666"> · {{ item["source"] }}</span>
</div>
<div class="actions">
<button class="approve" hx-post="/review/{{ item["sha256"] }}/approve" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML">✅ Keep</button>
<button class="reject" hx-post="/review/{{ item["sha256"] }}/reject" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML">🗑 Reject</button>
<button class="reset" hx-post="/review/{{ item["sha256"] }}/reset" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML"></button>
</div>
</div>

View File

@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>photo-pipeline dashboard</title>
<script src="/static/htmx.min.js" defer></script>
<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; }
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.scanned { background: #333; }
.badge.approved { background: #1d4; color: #031; }
.badge.rejected { background: #d43; color: #fff; }
.badge.review { background: #da4; color: #321; }
.stat { font-size: 2rem; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; }
.card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden; }
.card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.card .meta { padding: .5rem; font-size: .75rem; color: #aaa; word-break: break-all; }
.card .actions { display: flex; gap: .25rem; padding: .5rem; }
.card button { flex: 1; border: 0; border-radius: 4px; padding: .4rem; cursor: pointer; font-size: .8rem; }
.approve { background: #1d4; color: #031; }
.reject { background: #d43; color: #fff; }
.reset { background: #444; color: #eee; }
.none { color: #666; font-style: italic; padding: 2rem; text-align: center; }
</style>
</head>
<body>
<header>
<h1>📸 photo-pipeline</h1>
<a href="/">Overview</a>
<a href="/review">Review queue</a>
</header>
<main>
<h2>Library status</h2>
<p class="stat">{{ total }}</p>
<p>images fingerprinted</p>
<table>
<tr><th>Source</th><th>Status</th><th>Count</th></tr>
{% for r in rows %}
<tr>
<td>{{ r["source"] }}</td>
<td><span class="badge {{ r["status"] }}">{{ r["status"] }}</span></td>
<td>{{ r["n"] }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="none">No images yet — run photo-ingest first</td></tr>
{% endfor %}
</table>
</main>
</body>
</html>

View File

@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Review — photo-pipeline</title>
<script src="/static/htmx.min.js" defer></script>
<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; }
.filters { padding: 1rem 1.5rem; display: flex; gap: .5rem; }
.filters select, .filters button { background: #222; color: #eee; border: 1px solid #444; border-radius: 6px; padding: .4rem .8rem; }
main { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; }
.card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden; }
.card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.card .meta { padding: .5rem; font-size: .7rem; color: #aaa; word-break: break-all; }
.card .badge { display: inline-block; padding: .1rem .4rem; border-radius: 999px; font-size: .65rem; margin-top: .25rem; }
.badge.approved { background: #1d4; color: #031; }
.badge.rejected { background: #d43; color: #fff; }
.badge.scanned { background: #333; }
.badge.review { background: #da4; color: #321; }
.card .actions { display: flex; gap: .25rem; padding: .5rem; }
.card button { flex: 1; border: 0; border-radius: 4px; padding: .4rem; cursor: pointer; font-size: .75rem; }
.approve { background: #1d4; color: #031; }
.reject { background: #d43; color: #fff; }
.reset { background: #444; color: #eee; }
.none { color: #666; font-style: italic; padding: 2rem; text-align: center; }
</style>
</head>
<body>
<header>
<h1>📸 photo-pipeline</h1>
<a href="/">Overview</a>
<a href="/review">Review queue</a>
</header>
<div class="filters">
<form method="get" action="/review" style="display:flex;gap:.5rem;">
<select name="status">
<option value="">all statuses</option>
{% for s in ["scanned", "review", "approved", "rejected"] %}
<option value="{{ s }}" {% if status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
<input type="text" name="source" value="{{ source or '' }}" placeholder="source (e.g. archive-pictures)">
<button type="submit">Filter</button>
</form>
</div>
<main>
<div class="grid" id="review-grid">
{% for item in items %}
<div class="card" id="card-{{ item["sha256"][:8] }}">
{% if item["thumb"] %}
<img src="{{ item["thumb"] }}" loading="lazy" alt="">
{% else %}
<div style="aspect-ratio:1;display:flex;align-items:center;justify-content:center;color:#555;font-size:2rem;"></div>
{% endif %}
<div class="meta">
{{ item["path"].split("/")[-1] }}<br>
<span class="badge {{ item["status"] }}">{{ item["status"] }}</span>
<span style="color:#666"> · {{ item["source"] }}</span>
</div>
<div class="actions">
<button class="approve" hx-post="/review/{{ item["sha256"] }}/approve" hx-target="#card-{{ item["sha256"][:8] }}" hx-swap="outerHTML">✅ Keep</button>
<button class="reject" hx-post="/review/{{ item["sha256"] }}/reject" hx-target="#card-{{ item["sha256"][:8] }}" hx-swap="outerHTML">🗑 Reject</button>
<button class="reset" hx-post="/review/{{ item["sha256"] }}/reset" hx-target="#card-{{ item["sha256"][:8] }}" hx-swap="outerHTML"></button>
</div>
</div>
{% else %}
<p class="none">No images match the filter.</p>
{% endfor %}
</div>
</main>
</body>
</html>