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 fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
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)}
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__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8100)