feat: add HTMX web UI for viewing runs and results

- /ui endpoint shows runs list
- Auto-detects image vs video content
- HTMX polling for running jobs
- Dark theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 14:06:07 +00:00
parent e6184b8bd3
commit 5fbe9cbc45

207
server.py
View File

@@ -17,7 +17,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException, UploadFile, File from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
import redis import redis
@@ -255,6 +255,211 @@ async def upload_to_cache(file: UploadFile = File(...)):
return {"content_hash": content_hash, "filename": file.filename, "size": len(content)} return {"content_hash": content_hash, "filename": file.filename, "size": len(content)}
def detect_media_type(cache_path: Path) -> str:
"""Detect if file is image or video based on magic bytes."""
with open(cache_path, "rb") as f:
header = f.read(32)
# Video signatures
if header[:4] == b'\x1a\x45\xdf\xa3': # WebM/MKV
return "video"
if header[4:8] == b'ftyp': # MP4/MOV
return "video"
if header[:4] == b'RIFF' and header[8:12] == b'AVI ': # AVI
return "video"
# Image signatures
if header[:8] == b'\x89PNG\r\n\x1a\n': # PNG
return "image"
if header[:2] == b'\xff\xd8': # JPEG
return "image"
if header[:6] in (b'GIF87a', b'GIF89a'): # GIF
return "image"
if header[:4] == b'RIFF' and header[8:12] == b'WEBP': # WebP
return "image"
return "unknown"
UI_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Art DAG L1 Server</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0; padding: 20px;
background: #111; color: #eee;
}
h1 { margin: 0 0 20px 0; color: #fff; }
.runs { display: flex; flex-direction: column; gap: 12px; }
.run {
background: #222; border-radius: 8px; padding: 16px;
border: 1px solid #333;
}
.run-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.run-id { font-family: monospace; font-size: 12px; color: #888; }
.run-recipe { font-weight: bold; font-size: 18px; }
.status {
padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500;
}
.status.completed { background: #1a4d1a; color: #4ade80; }
.status.running { background: #4d4d1a; color: #facc15; }
.status.failed { background: #4d1a1a; color: #f87171; }
.status.pending { background: #333; color: #888; }
.media-container { margin-top: 12px; }
.media-container img, .media-container video {
max-width: 100%; max-height: 400px; border-radius: 4px;
}
.hash { font-family: monospace; font-size: 11px; color: #666; }
.info { font-size: 13px; color: #aaa; }
.refresh-btn {
background: #333; color: #fff; border: none; padding: 8px 16px;
border-radius: 4px; cursor: pointer; margin-bottom: 16px;
}
.refresh-btn:hover { background: #444; }
.no-runs { color: #666; font-style: italic; }
</style>
</head>
<body>
<h1>Art DAG L1 Server</h1>
<button class="refresh-btn" hx-get="/ui/runs" hx-target="#runs" hx-swap="innerHTML">
Refresh
</button>
<div id="runs" hx-get="/ui/runs" hx-trigger="load" hx-swap="innerHTML">
Loading...
</div>
</body>
</html>
"""
@app.get("/ui", response_class=HTMLResponse)
async def ui_index():
"""Web UI for viewing runs."""
return UI_HTML
@app.get("/ui/runs", response_class=HTMLResponse)
async def ui_runs():
"""HTMX partial: list of runs."""
runs = list_all_runs()
if not runs:
return '<p class="no-runs">No runs yet.</p>'
html_parts = ['<div class="runs">']
for run in runs[:20]: # Limit to 20 most recent
status_class = run.status
html_parts.append(f'''
<div class="run" hx-get="/ui/run/{run.run_id}" hx-trigger="every 2s[this.querySelector('.status.running')]" hx-swap="outerHTML">
<div class="run-header">
<div>
<span class="run-recipe">{run.recipe}</span>
<span class="run-id">{run.run_id}</span>
</div>
<span class="status {status_class}">{run.status}</span>
</div>
<div class="info">
Created: {run.created_at[:19].replace('T', ' ')}
</div>
''')
# Show input hash
if run.inputs:
html_parts.append(f'<div class="hash">Input: {run.inputs[0][:32]}...</div>')
# Show output if completed
if run.status == "completed" and run.output_hash:
cache_path = CACHE_DIR / run.output_hash
if cache_path.exists():
media_type = detect_media_type(cache_path)
html_parts.append(f'<div class="hash">Output: {run.output_hash[:32]}...</div>')
html_parts.append('<div class="media-container">')
if media_type == "video":
html_parts.append(f'<video src="/cache/{run.output_hash}" controls autoplay muted loop></video>')
elif media_type == "image":
html_parts.append(f'<img src="/cache/{run.output_hash}" alt="{run.output_name}">')
html_parts.append('</div>')
# Show error if failed
if run.status == "failed" and run.error:
html_parts.append(f'<div class="info" style="color: #f87171;">Error: {run.error}</div>')
html_parts.append('</div>')
html_parts.append('</div>')
return '\n'.join(html_parts)
@app.get("/ui/run/{run_id}", response_class=HTMLResponse)
async def ui_run_detail(run_id: str):
"""HTMX partial: single run (for polling updates)."""
run = load_run(run_id)
if not run:
return '<div class="run">Run not found</div>'
# Check Celery task status if running
if run.status == "running" and run.celery_task_id:
task = celery_app.AsyncResult(run.celery_task_id)
if task.ready():
if task.successful():
result = task.result
run.status = "completed"
run.completed_at = datetime.now(timezone.utc).isoformat()
run.output_hash = result.get("output", {}).get("content_hash")
output_path = Path(result.get("output", {}).get("local_path", ""))
if output_path.exists():
cache_file(output_path)
else:
run.status = "failed"
run.error = str(task.result)
save_run(run)
status_class = run.status
poll_attr = 'hx-get="/ui/run/{}" hx-trigger="every 2s" hx-swap="outerHTML"'.format(run_id) if run.status == "running" else ""
html = f'''
<div class="run" {poll_attr}>
<div class="run-header">
<div>
<span class="run-recipe">{run.recipe}</span>
<span class="run-id">{run.run_id}</span>
</div>
<span class="status {status_class}">{run.status}</span>
</div>
<div class="info">
Created: {run.created_at[:19].replace('T', ' ')}
</div>
'''
if run.inputs:
html += f'<div class="hash">Input: {run.inputs[0][:32]}...</div>'
if run.status == "completed" and run.output_hash:
cache_path = CACHE_DIR / run.output_hash
if cache_path.exists():
media_type = detect_media_type(cache_path)
html += f'<div class="hash">Output: {run.output_hash[:32]}...</div>'
html += '<div class="media-container">'
if media_type == "video":
html += f'<video src="/cache/{run.output_hash}" controls autoplay muted loop></video>'
elif media_type == "image":
html += f'<img src="/cache/{run.output_hash}" alt="{run.output_name}">'
html += '</div>'
if run.status == "failed" and run.error:
html += f'<div class="info" style="color: #f87171;">Error: {run.error}</div>'
html += '</div>'
return html
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8100) uvicorn.run(app, host="0.0.0.0", port=8100)