From 9a399cfcee4900977601e9d854dc730b84ece97d Mon Sep 17 00:00:00 2001 From: gilesb Date: Wed, 7 Jan 2026 22:55:44 +0000 Subject: [PATCH] Add clean URLs with content negotiation and pagination for L1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add content negotiation (HTML for browsers, JSON for APIs) - Clean URLs: /runs, /cache, /run/{id}, /cache/{hash}/detail - Auth routes: /login, /logout, /register with clean URLs - Add render_page() helper for consistent page layout - Add infinite scroll pagination for HTML views - Add API pagination with ?page=1&limit=20 - Redirect old /ui/* routes to clean URLs - Update nav links to use clean URLs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server.py | 1178 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 735 insertions(+), 443 deletions(-) diff --git a/server.py b/server.py index de5f4a5..9e8729d 100644 --- a/server.py +++ b/server.py @@ -191,9 +191,9 @@ HOME_HTML = """

Art DAG L1 Server

@@ -332,10 +332,375 @@ async def get_run(run_id: str): return run +@app.get("/run/{run_id}") +async def run_detail(run_id: str, request: Request): + """Run detail. HTML for browsers, JSON for APIs.""" + run = load_run(run_id) + if not run: + if wants_html(request): + content = f'

Run not found: {run_id}

' + return HTMLResponse(render_page("Not Found", content, None, active_tab="runs"), status_code=404) + raise HTTPException(404, f"Run {run_id} not found") + + # 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") + effects = result.get("effects", []) + if effects: + run.effects_commit = effects[0].get("repo_commit") + run.effect_url = effects[0].get("repo_url") + run.infrastructure = result.get("infrastructure") + 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) + + if wants_html(request): + current_user = get_user_from_cookie(request) + if not current_user: + content = '

Login to view run details.

' + return HTMLResponse(render_page("Login Required", content, current_user, active_tab="runs"), status_code=401) + + # Check user owns this run + actor_id = f"@{current_user}@{L2_DOMAIN}" + if run.username not in (current_user, actor_id): + content = '

Access denied.

' + return HTMLResponse(render_page("Access Denied", content, current_user, active_tab="runs"), status_code=403) + + # Build effect URL + if run.effect_url: + effect_url = run.effect_url + elif run.effects_commit and run.effects_commit != "unknown": + effect_url = f"https://git.rose-ash.com/art-dag/effects/src/commit/{run.effects_commit}/{run.recipe}" + else: + effect_url = f"https://git.rose-ash.com/art-dag/effects/src/branch/main/{run.recipe}" + + # Status badge colors + status_colors = { + "completed": "bg-green-600 text-white", + "running": "bg-yellow-600 text-white", + "failed": "bg-red-600 text-white", + "pending": "bg-gray-600 text-white" + } + status_badge = status_colors.get(run.status, "bg-gray-600 text-white") + + # Build media HTML for input/output + media_html = "" + 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: + media_html = '
' + if has_input: + input_hash = run.inputs[0] + input_media_type = detect_media_type(CACHE_DIR / input_hash) + input_video_src = video_src_for_request(input_hash, request) + if input_media_type == "video": + input_elem = f'' + elif input_media_type == "image": + input_elem = f'input' + else: + input_elem = '

Unknown format

' + media_html += f''' +
+
Input
+ {input_hash[:24]}... +
{input_elem}
+
+ ''' + if has_output: + output_hash = run.output_hash + output_media_type = detect_media_type(CACHE_DIR / output_hash) + output_video_src = video_src_for_request(output_hash, request) + if output_media_type == "video": + output_elem = f'' + elif output_media_type == "image": + output_elem = f'output' + else: + output_elem = '

Unknown format

' + media_html += f''' +
+
Output
+ {output_hash[:24]}... +
{output_elem}
+
+ ''' + media_html += '
' + + # Build inputs list + inputs_html = ''.join([f'{inp}' for inp in run.inputs]) + + # Infrastructure section + infra_html = "" + if run.infrastructure: + software = run.infrastructure.get("software", {}) + hardware = run.infrastructure.get("hardware", {}) + infra_html = f''' +
+
Infrastructure
+
+ Software: {software.get("name", "unknown")} ({software.get("content_hash", "unknown")[:16]}...)
+ Hardware: {hardware.get("name", "unknown")} ({hardware.get("content_hash", "unknown")[:16]}...) +
+
+ ''' + + # Error display + error_html = "" + if run.error: + error_html = f''' +
+
Error
+
{run.error}
+
+ ''' + + # Publish section + publish_html = "" + if run.status == "completed" and run.output_hash: + publish_html = f''' +
+

Publish to L2

+

Register this transformation output on the L2 ActivityPub server.

+
+
+ + +
+
+ ''' + + output_link = "" + if run.output_hash: + output_link = f'''
+
Output
+ {run.output_hash} +
''' + + completed_html = "" + if run.completed_at: + completed_html = f'''
+
Completed
+
{run.completed_at[:19].replace('T', ' ')}
+
''' + + content = f''' + + + + + Back to runs + + +
+
+
+ + {run.recipe} + + {run.run_id[:16]}... +
+ {run.status} +
+ + {error_html} + {media_html} + +
+

Provenance

+
+
+
Owner
+
{run.username or "anonymous"}
+
+
+
Effect
+ {run.recipe} +
+
+
Effects Commit
+
{run.effects_commit or "N/A"}
+
+
+
Input(s)
+
{inputs_html}
+
+ {output_link} +
+
Run ID
+
{run.run_id}
+
+
+
Created
+
{run.created_at[:19].replace('T', ' ')}
+
+ {completed_html} + {infra_html} +
+
+ + {publish_html} +
+ ''' + + return HTMLResponse(render_page(f"Run: {run.recipe}", content, current_user, active_tab="runs")) + + # JSON response + return run.model_dump() + + @app.get("/runs") -async def list_runs(): - """List all runs.""" - return list_all_runs() +async def list_runs(request: Request, page: int = 1, limit: int = 20): + """List runs. HTML for browsers (with infinite scroll), JSON for APIs (with pagination).""" + current_user = get_user_from_cookie(request) + + all_runs = list_all_runs() + total = len(all_runs) + + # Filter by user if logged in for HTML + if wants_html(request) and current_user: + actor_id = f"@{current_user}@{L2_DOMAIN}" + all_runs = [r for r in all_runs if r.username in (current_user, actor_id)] + total = len(all_runs) + + # Pagination + start = (page - 1) * limit + end = start + limit + runs_page = all_runs[start:end] + has_more = end < total + + if wants_html(request): + if not current_user: + content = '

Login to see your runs.

' + return HTMLResponse(render_page("Runs", content, current_user, active_tab="runs")) + + if not runs_page: + if page == 1: + content = '

You have no runs yet. Use the CLI to start a run.

' + else: + return HTMLResponse("") # Empty for infinite scroll + else: + # Status badge colors + status_colors = { + "completed": "bg-green-600 text-white", + "running": "bg-yellow-600 text-white", + "failed": "bg-red-600 text-white", + "pending": "bg-gray-600 text-white" + } + + html_parts = [] + for run in runs_page: + status_badge = status_colors.get(run.status, "bg-gray-600 text-white") + html_parts.append(f''' + +
+
+
+ {run.recipe} + +
+ {run.status} +
+
+ Created: {run.created_at[:19].replace('T', ' ')} +
+ ''') + + # Show input and output thumbnails + 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_parts.append('
') + if has_input: + input_hash = run.inputs[0] + input_media_type = detect_media_type(CACHE_DIR / input_hash) + html_parts.append(f''' +
+
Input
+
+ ''') + if input_media_type == "video": + html_parts.append(f'') + else: + html_parts.append(f'input') + html_parts.append('
') + + if has_output: + output_hash = run.output_hash + output_media_type = detect_media_type(CACHE_DIR / output_hash) + html_parts.append(f''' +
+
Output
+
+ ''') + if output_media_type == "video": + html_parts.append(f'') + else: + html_parts.append(f'output') + html_parts.append('
') + html_parts.append('
') + + if run.status == "failed" and run.error: + html_parts.append(f'
Error: {run.error[:100]}
') + + html_parts.append('
') + + # For infinite scroll, just return cards if not first page + if page > 1: + if has_more: + html_parts.append(f''' +
+

Loading more...

+
+ ''') + return HTMLResponse('\n'.join(html_parts)) + + # First page - full content + infinite_scroll_trigger = "" + if has_more: + infinite_scroll_trigger = f''' +
+

Loading more...

+
+ ''' + + content = f''' +

Runs ({total} total)

+
+ {''.join(html_parts)} + {infinite_scroll_trigger} +
+ ''' + + return HTMLResponse(render_page("Runs", content, current_user, active_tab="runs")) + + # JSON response for APIs + return { + "runs": [r.model_dump() for r in runs_page], + "pagination": { + "page": page, + "limit": limit, + "total": total, + "has_more": has_more + } + } @app.get("/cache/{content_hash}") @@ -410,111 +775,48 @@ async def get_cached_mp4(content_hash: str): return FileResponse(mp4_path, media_type="video/mp4") -@app.get("/ui/cache/{content_hash}", response_class=HTMLResponse) -async def ui_cache_view(content_hash: str, request: Request): - """View cached content with appropriate display.""" +@app.get("/cache/{content_hash}/detail") +async def cache_detail(content_hash: str, request: Request): + """View cached content detail. HTML for browsers, JSON for APIs.""" current_user = get_user_from_cookie(request) - if not current_user: - return HTMLResponse(f''' - - - - - - Login Required | Art DAG L1 - {TAILWIND_CONFIG} - - -
-

Art DAG L1 Server

-
-

Login to view cached content.

-
-
- - -''', status_code=401) - - # Check user has access to this file - user_hashes = get_user_cache_hashes(current_user) - if content_hash not in user_hashes: - return HTMLResponse(f''' - - - - - - Access Denied | Art DAG L1 - {TAILWIND_CONFIG} - - -
-

Access denied

-
- - -''', status_code=403) cache_path = CACHE_DIR / content_hash - if not cache_path.exists(): - return HTMLResponse(f''' - - - - - - Not Found | Art DAG L1 - {TAILWIND_CONFIG} - - -
-

Art DAG L1 Server

- - - Back - -
-

Content not found: {content_hash}

-
-
- - -''', status_code=404) + if wants_html(request): + content = f'

Content not found: {content_hash}

' + return HTMLResponse(render_page("Not Found", content, current_user, active_tab="cache"), status_code=404) + raise HTTPException(404, f"Content {content_hash} not in cache") - media_type = detect_media_type(cache_path) - file_size = cache_path.stat().st_size - size_str = f"{file_size:,} bytes" - if file_size > 1024*1024: - size_str = f"{file_size/(1024*1024):.1f} MB" - elif file_size > 1024: - size_str = f"{file_size/1024:.1f} KB" + if wants_html(request): + if not current_user: + content = '

Login to view cached content.

' + return HTMLResponse(render_page("Login Required", content, current_user, active_tab="cache"), status_code=401) - # Build media display HTML - if media_type == "video": - video_src = video_src_for_request(content_hash, request) - media_html = f'' - elif media_type == "image": - media_html = f'{content_hash}' - else: - media_html = f'

Unknown file type. Download file

' + # Check user has access + user_hashes = get_user_cache_hashes(current_user) + if content_hash not in user_hashes: + content = '

Access denied.

' + return HTMLResponse(render_page("Access Denied", content, current_user, active_tab="cache"), status_code=403) - html = f""" - - - - - - {content_hash[:16]}... | Art DAG L1 - {TAILWIND_CONFIG} - - - -
- - -""" - return html + ''' + + return HTMLResponse(render_page(f"Cache: {content_hash[:16]}...", content, current_user, active_tab="cache")) + + # JSON response - return metadata + meta = load_cache_meta(content_hash) + file_size = cache_path.stat().st_size + media_type = detect_media_type(cache_path) + return { + "content_hash": content_hash, + "size": file_size, + "media_type": media_type, + "meta": meta + } + + +@app.get("/cache/{content_hash}/meta-form", response_class=HTMLResponse) +async def cache_meta_form(content_hash: str, request: Request): + """Clean URL redirect to the HTMX meta form.""" + # Just redirect to the old endpoint for now + from starlette.responses import RedirectResponse + return RedirectResponse(f"/ui/cache/{content_hash}/meta-form", status_code=302) + + +@app.get("/ui/cache/{content_hash}") +async def ui_cache_view(content_hash: str): + """Redirect to clean URL.""" + return RedirectResponse(url=f"/cache/{content_hash}/detail", status_code=302) @app.get("/ui/cache/{content_hash}/meta-form", response_class=HTMLResponse) @@ -923,9 +1248,179 @@ async def ui_republish_cache(content_hash: str, request: Request): @app.get("/cache") -async def list_cache(): - """List cached content hashes.""" - return [f.name for f in CACHE_DIR.iterdir() if f.is_file()] +async def list_cache( + request: Request, + page: int = 1, + limit: int = 20, + folder: Optional[str] = None, + collection: Optional[str] = None, + tag: Optional[str] = None +): + """List cached content. HTML for browsers (with infinite scroll), JSON for APIs (with pagination).""" + current_user = get_user_from_cookie(request) + + if wants_html(request): + # Require login for HTML cache view + if not current_user: + content = '

Login to see cached content.

' + return HTMLResponse(render_page("Cache", content, current_user, active_tab="cache")) + + # Get hashes owned by/associated with this user + user_hashes = get_user_cache_hashes(current_user) + + # Get cache items that belong to the user + cache_items = [] + if CACHE_DIR.exists(): + for f in CACHE_DIR.iterdir(): + if f.is_file() and not f.name.endswith('.provenance.json') and not f.name.endswith('.meta.json') and not f.name.endswith('.mp4'): + if f.name in user_hashes: + meta = load_cache_meta(f.name) + + # Apply folder filter + if folder: + item_folder = meta.get("folder", "/") + if folder != "/" and not item_folder.startswith(folder): + continue + if folder == "/" and item_folder != "/": + continue + + # Apply collection filter + if collection: + if collection not in meta.get("collections", []): + continue + + # Apply tag filter + if tag: + if tag not in meta.get("tags", []): + continue + + stat = f.stat() + cache_items.append({ + "hash": f.name, + "size": stat.st_size, + "mtime": stat.st_mtime, + "meta": meta + }) + + # Sort by modification time (newest first) + cache_items.sort(key=lambda x: x["mtime"], reverse=True) + total = len(cache_items) + + # Pagination + start = (page - 1) * limit + end = start + limit + items_page = cache_items[start:end] + has_more = end < total + + if not items_page: + if page == 1: + filter_msg = "" + if folder: + filter_msg = f" in folder {folder}" + elif collection: + filter_msg = f" in collection '{collection}'" + elif tag: + filter_msg = f" with tag '{tag}'" + content = f'

No cached files{filter_msg}. Upload files or run effects to see them here.

' + else: + return HTMLResponse("") # Empty for infinite scroll + else: + html_parts = [] + for item in items_page: + content_hash = item["hash"] + cache_path = CACHE_DIR / content_hash + media_type = detect_media_type(cache_path) + + # Format size + size = item["size"] + if size > 1024*1024: + size_str = f"{size/(1024*1024):.1f} MB" + elif size > 1024: + size_str = f"{size/1024:.1f} KB" + else: + size_str = f"{size} bytes" + + html_parts.append(f''' + +
+
+ {media_type} + {size_str} +
+
{content_hash[:24]}...
+
+ ''') + + if media_type == "video": + video_src = video_src_for_request(content_hash, request) + html_parts.append(f'') + elif media_type == "image": + html_parts.append(f'{content_hash[:16]}') + else: + html_parts.append('

Unknown file type

') + + html_parts.append('
') + + # For infinite scroll, just return cards if not first page + if page > 1: + if has_more: + query_params = f"page={page + 1}" + if folder: + query_params += f"&folder={folder}" + if collection: + query_params += f"&collection={collection}" + if tag: + query_params += f"&tag={tag}" + html_parts.append(f''' +
+

Loading more...

+
+ ''') + return HTMLResponse('\n'.join(html_parts)) + + # First page - full content + infinite_scroll_trigger = "" + if has_more: + query_params = "page=2" + if folder: + query_params += f"&folder={folder}" + if collection: + query_params += f"&collection={collection}" + if tag: + query_params += f"&tag={tag}" + infinite_scroll_trigger = f''' +
+

Loading more...

+
+ ''' + + content = f''' +

Cache ({total} items)

+
+ {''.join(html_parts)} + {infinite_scroll_trigger} +
+ ''' + + return HTMLResponse(render_page("Cache", content, current_user, active_tab="cache")) + + # JSON response for APIs - list all hashes with optional pagination + all_hashes = [f.name for f in CACHE_DIR.iterdir() if f.is_file() and not f.name.endswith('.provenance.json') and not f.name.endswith('.meta.json') and not f.name.endswith('.mp4')] + total = len(all_hashes) + start = (page - 1) * limit + end = start + limit + hashes_page = all_hashes[start:end] + has_more = end < total + + return { + "hashes": hashes_page, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "has_more": has_more + } + } @app.delete("/cache/{content_hash}") @@ -998,7 +1493,7 @@ async def ui_discard_cache(content_hash: str, request: Request): return '''
- Item discarded. Back to cache + Item discarded. Back to cache
''' @@ -1539,6 +2034,12 @@ def get_user_from_cookie(request) -> Optional[str]: return verify_token_with_l2(token) +def wants_html(request: Request) -> bool: + """Check if request wants HTML (browser) vs JSON (API).""" + accept = request.headers.get("accept", "") + return "text/html" in accept and "application/json" not in accept + + # Tailwind CSS config for all L1 templates TAILWIND_CONFIG = ''' @@ -1558,6 +2059,58 @@ TAILWIND_CONFIG = ''' ''' +def render_page(title: str, content: str, username: Optional[str] = None, active_tab: str = None) -> str: + """Render a page with nav bar and content. Used for clean URL pages.""" + user_info = "" + if username: + user_info = f''' +
+ Logged in as {username} + Logout +
+ ''' + else: + user_info = ''' +
+ Login +
+ ''' + + runs_active = "border-b-2 border-blue-500 text-white" if active_tab == "runs" else "text-gray-400 hover:text-white" + cache_active = "border-b-2 border-blue-500 text-white" if active_tab == "cache" else "text-gray-400 hover:text-white" + + return f""" + + + + + + {title} | Art DAG L1 Server + {TAILWIND_CONFIG} + + +
+
+

+ Art DAG L1 Server +

+ {user_info} +
+ + + +
+ {content} +
+
+ + +""" + + def render_ui_html(username: Optional[str] = None, tab: str = "runs") -> str: """Render main UI HTML with optional user context.""" user_info = "" @@ -1649,10 +2202,10 @@ def get_auth_page_html(page_type: str = "login", error: str = None) -> str:

- Art DAG L1 Server + Art DAG L1 Server

- + @@ -1661,13 +2214,13 @@ def get_auth_page_html(page_type: str = "login", error: str = None) -> str:
{error_html} -
+ {form_fields} -
-
- ''' - - html = f""" - - - - - - {run.recipe} - {run.run_id[:8]} | Art DAG L1 - {TAILWIND_CONFIG} - - -
-

- Art DAG L1 Server -

- - - - - - Back to runs - - -
-
-
- - {run.recipe} - - {run.run_id[:16]}... -
- {run.status} -
- - {error_html} - {media_html} - -
-

Provenance

-
-
-
Owner
-
{run.username or "anonymous"}
-
-
-
Effect
- {run.recipe} -
-
-
Effects Commit
-
{run.effects_commit or "N/A"}
-
-
-
Input(s)
-
{inputs_html}
-
- {"" if run.output_hash else ""} -
-
Run ID
-
{run.run_id}
-
-
-
Created
-
{run.created_at[:19].replace('T', ' ')}
-
- {"
Completed
" + run.completed_at[:19].replace('T', ' ') + "
" if run.completed_at else ""} - {infra_html} -
-
- -
-

Raw JSON

-
{provenance_json}
-
- - {publish_html} -
-
- - -""" - return html +@app.get("/ui/detail/{run_id}") +async def ui_detail_page(run_id: str): + """Redirect to clean URL.""" + return RedirectResponse(url=f"/run/{run_id}", status_code=302) @app.get("/ui/run/{run_id}", response_class=HTMLResponse)