feat: add detail page with provenance and linkable hashes

- Click run to see full detail page
- Effect name links to git.rose-ash.com source
- Input/output hashes link to cache downloads
- Full provenance: effect, inputs, output, timestamps

🤖 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:18:44 +00:00
parent 73908b318d
commit c03a26dbf2

193
server.py
View File

@@ -377,13 +377,7 @@ def detect_media_type(cache_path: Path) -> str:
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>
UI_CSS = """
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
@@ -391,14 +385,19 @@ UI_HTML = """
background: #111; color: #eee;
}
h1 { margin: 0 0 20px 0; color: #fff; }
h2 { color: #ccc; margin: 24px 0 12px 0; font-size: 16px; }
a { color: #60a5fa; text-decoration: none; }
a:hover { color: #93c5fd; text-decoration: underline; }
.runs { display: flex; flex-direction: column; gap: 12px; }
.run {
background: #222; border-radius: 8px; padding: 16px;
border: 1px solid #333;
}
.run-link { display: block; text-decoration: none; color: inherit; }
.run-link:hover .run { border-color: #555; background: #282828; }
.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; }
.run-recipe { font-weight: bold; font-size: 18px; color: #fff; }
.status {
padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500;
}
@@ -418,17 +417,33 @@ UI_HTML = """
.media-box { min-width: 100%; }
}
.hash { font-family: monospace; font-size: 11px; color: #666; }
.hash a { color: #888; }
.hash a:hover { color: #60a5fa; }
.info { font-size: 13px; color: #aaa; }
.refresh-btn {
.refresh-btn, .back-btn {
background: #333; color: #fff; border: none; padding: 8px 16px;
border-radius: 4px; cursor: pointer; margin-bottom: 16px;
text-decoration: none; display: inline-block;
}
.refresh-btn:hover { background: #444; }
.refresh-btn:hover, .back-btn:hover { background: #444; }
.no-runs { color: #666; font-style: italic; }
</style>
.provenance { background: #1a1a1a; border-radius: 8px; padding: 16px; margin-top: 16px; }
.prov-item { margin: 8px 0; }
.prov-label { color: #888; font-size: 12px; }
.prov-value { font-family: monospace; font-size: 13px; }
code { background: #222; padding: 2px 6px; border-radius: 4px; }
"""
UI_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Art DAG L1 Server</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>""" + UI_CSS + """</style>
</head>
<body>
<h1>Art DAG L1 Server</h1>
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
<button class="refresh-btn" hx-get="/ui/runs" hx-target="#runs" hx-swap="innerHTML">
Refresh
</button>
@@ -458,8 +473,10 @@ async def ui_runs():
for run in runs[:20]: # Limit to 20 most recent
status_class = run.status
effect_url = f"https://git.rose-ash.com/art-dag/effects/src/branch/main/{run.recipe}"
html_parts.append(f'''
<a href="/ui/detail/{run.run_id}" class="run-link">
<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>
@@ -516,14 +533,162 @@ async def ui_runs():
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></a>')
html_parts.append('</div>')
return '\n'.join(html_parts)
@app.get("/ui/detail/{run_id}", response_class=HTMLResponse)
async def ui_detail_page(run_id: str):
"""Full detail page for a run."""
run = load_run(run_id)
if not run:
return HTMLResponse('<h1>Run not found</h1>', status_code=404)
# 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)
effect_url = f"https://git.rose-ash.com/art-dag/effects/src/branch/main/{run.recipe}"
status_class = run.status
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>{run.recipe} - {run.run_id[:8]} | Art DAG L1</title>
<style>{UI_CSS}</style>
</head>
<body>
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
<a href="/ui" class="back-btn">&larr; Back to runs</a>
<div class="run">
<div class="run-header">
<div>
<a href="{effect_url}" target="_blank" class="run-recipe">{run.recipe}</a>
<span class="run-id">{run.run_id}</span>
</div>
<span class="status {status_class}">{run.status}</span>
</div>
"""
# Media row
has_input = run.inputs and (CACHE_DIR / run.inputs[0]).exists()
has_output = run.status == "completed" and run.output_hash and (CACHE_DIR / run.output_hash).exists()
if has_input or has_output:
html += '<div class="media-row">'
if has_input:
input_hash = run.inputs[0]
input_media_type = detect_media_type(CACHE_DIR / input_hash)
html += f'''
<div class="media-box">
<label>Input: <a href="/cache/{input_hash}">{input_hash[:24]}...</a></label>
<div class="media-container">
'''
if input_media_type == "video":
html += f'<video src="/cache/{input_hash}" controls muted loop></video>'
elif input_media_type == "image":
html += f'<img src="/cache/{input_hash}" alt="input">'
html += '</div></div>'
if has_output:
output_hash = run.output_hash
output_media_type = detect_media_type(CACHE_DIR / output_hash)
html += f'''
<div class="media-box">
<label>Output: <a href="/cache/{output_hash}">{output_hash[:24]}...</a></label>
<div class="media-container">
'''
if output_media_type == "video":
html += f'<video src="/cache/{output_hash}" controls autoplay muted loop></video>'
elif output_media_type == "image":
html += f'<img src="/cache/{output_hash}" alt="output">'
html += '</div></div>'
html += '</div>'
# Provenance section
html += f'''
<div class="provenance">
<h2>Provenance</h2>
<div class="prov-item">
<div class="prov-label">Effect</div>
<div class="prov-value"><a href="{effect_url}" target="_blank">{run.recipe}</a></div>
</div>
<div class="prov-item">
<div class="prov-label">Input(s)</div>
<div class="prov-value">
'''
for inp in run.inputs:
html += f'<a href="/cache/{inp}">{inp}</a><br>'
html += f'''
</div>
</div>
'''
if run.output_hash:
html += f'''
<div class="prov-item">
<div class="prov-label">Output</div>
<div class="prov-value"><a href="/cache/{run.output_hash}">{run.output_hash}</a></div>
</div>
'''
html += f'''
<div class="prov-item">
<div class="prov-label">Run ID</div>
<div class="prov-value">{run.run_id}</div>
</div>
<div class="prov-item">
<div class="prov-label">Created</div>
<div class="prov-value">{run.created_at}</div>
</div>
'''
if run.completed_at:
html += f'''
<div class="prov-item">
<div class="prov-label">Completed</div>
<div class="prov-value">{run.completed_at}</div>
</div>
'''
if run.error:
html += f'''
<div class="prov-item">
<div class="prov-label">Error</div>
<div class="prov-value" style="color: #f87171;">{run.error}</div>
</div>
'''
html += '''
</div>
</div>
</body>
</html>
'''
return html
@app.get("/ui/run/{run_id}", response_class=HTMLResponse)
async def ui_run_detail(run_id: str):
async def ui_run_partial(run_id: str):
"""HTMX partial: single run (for polling updates)."""
run = load_run(run_id)
if not run: