From 1e95badddb59ceddd2ac927f55a2507d19ed55a0 Mon Sep 17 00:00:00 2001 From: gilesb Date: Wed, 7 Jan 2026 20:44:11 +0000 Subject: [PATCH] Update UI to use Tailwind CSS with dark theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace custom CSS with Tailwind CSS via CDN - Consistent dark theme across all pages - Responsive layouts with mobile-friendly breakpoints - Updated: home, login/register, runs list, cache list, run detail, cache detail - Modern card-based UI with hover states and status badges 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server.py | 1070 +++++++++++++++++++++++++++-------------------------- 1 file changed, 553 insertions(+), 517 deletions(-) diff --git a/server.py b/server.py index 09fa2df..350d0ee 100644 --- a/server.py +++ b/server.py @@ -175,83 +175,68 @@ async def api_info(): HOME_HTML = """ - + + + Art DAG L1 Server - + - -
- -

Art DAG L1 Server

-

L1 rendering server for the Art DAG system. Manages distributed rendering jobs via Celery workers.

+ +
+ -

Dependencies

-
    -
  • artdag (GitHub): Core DAG execution engine
  • -
  • artdag-effects (rose-ash): Effect implementations
  • -
  • Redis: Message broker, result backend, and run persistence
  • +

    Art DAG L1 Server

    +

    L1 rendering server for the Art DAG system. Manages distributed rendering jobs via Celery workers.

    + +

    Dependencies

    +
      +
    • artdag (GitHub): Core DAG execution engine
    • +
    • artdag-effects (rose-ash): Effect implementations
    • +
    • Redis: Message broker, result backend, and run persistence
    -

    API Endpoints

    - - - - - - - - - - -
    MethodPathDescription
    GET/uiWeb UI for viewing runs
    POST/runsStart a rendering run
    GET/runsList all runs
    GET/runs/{run_id}Get run status
    GET/cacheList cached content hashes
    GET/cache/{hash}Download cached content
    POST/cache/uploadUpload file to cache
    GET/assetsList known assets
    +

    API Endpoints

    +
    + + + + + + + + + + + + + + + + + + +
    MethodPathDescription
    GET/uiWeb UI for viewing runs
    POST/runsStart a rendering run
    GET/runsList all runs
    GET/runs/{run_id}Get run status
    GET/cacheList cached content hashes
    GET/cache/{hash}Download cached content
    POST/cache/uploadUpload file to cache
    GET/assetsList known assets
    +
    -

    Start a Run

    -
    curl -X POST /runs \\
    +        

    Start a Run

    +
    curl -X POST /runs \\
       -H "Content-Type: application/json" \\
       -d '{"recipe": "dog", "inputs": ["33268b6e..."]}'
    -

    Provenance

    -

    Every render produces a provenance record linking inputs, effects, and infrastructure:

    -
    {
    +        

    Provenance

    +

    Every render produces a provenance record linking inputs, effects, and infrastructure:

    +
    {
       "output": {"content_hash": "..."},
       "inputs": [...],
       "effects": [...],
    @@ -432,11 +417,20 @@ async def ui_cache_view(content_hash: str, request: Request):
         if not current_user:
             return HTMLResponse(f'''
     
    -
    -Login Required | Art DAG L1
    -
    -    

    Art DAG L1 Server

    -

    Login to view cached content.

    + + + + + Login Required | Art DAG L1 + {TAILWIND_CONFIG} + + +
    +

    Art DAG L1 Server

    +
    +

    Login to view cached content.

    +
    +
    ''', status_code=401) @@ -444,22 +438,49 @@ async def ui_cache_view(content_hash: str, request: Request): # Check user has access to this file user_hashes = get_user_cache_hashes(current_user) if content_hash not in user_hashes: - return HTMLResponse('

    Access denied

    ', status_code=403) + 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""" + return HTMLResponse(f''' - -Not Found | Art DAG L1 - -

    Art DAG L1 Server

    - ← Back to runs -

    Content not found: {content_hash}

    + + + + + Not Found | Art DAG L1 + {TAILWIND_CONFIG} + + +
    +

    Art DAG L1 Server

    + + + Back + +
    +

    Content not found: {content_hash}

    +
    +
    -""", status_code=404) +''', status_code=404) media_type = detect_media_type(cache_path) file_size = cache_path.stat().st_size @@ -469,57 +490,75 @@ async def ui_cache_view(content_hash: str, request: Request): elif file_size > 1024: size_str = f"{file_size/1024:.1f} KB" - html = f""" - - - - {content_hash[:16]}... | Art DAG L1 - - - -

    Art DAG L1 Server

    - ← Back to runs - -
    -
    -
    - {media_type.capitalize()} - {content_hash[:32]}... -
    - Download -
    - -
    -""" - + # Build media display HTML if media_type == "video": video_src = video_src_for_request(content_hash, request) - html += f'' + media_html = f'' elif media_type == "image": - html += f'{content_hash}' + media_html = f'{content_hash}' else: - html += f'

    Unknown file type. Download file

    ' + media_html = f'

    Unknown file type. Download file

    ' - html += f""" -
    + html = f""" + + + + + + {content_hash[:16]}... | Art DAG L1 + {TAILWIND_CONFIG} + + +
    +

    + Art DAG L1 Server +

    -
    -

    Details

    -
    -
    Content Hash (SHA3-256)
    -
    {content_hash}
    + + + + + Back to cache + + +
    +
    +
    + {media_type.capitalize()} + {content_hash[:24]}... +
    + + Download +
    -
    -
    Type
    -
    {media_type}
    + +
    + {media_html}
    -
    -
    Size
    -
    {size_str}
    -
    -
    -
    Raw URL
    - + +
    +

    Details

    +
    +
    +
    Content Hash (SHA3-256)
    +
    {content_hash}
    +
    +
    +
    Type
    +
    {media_type}
    +
    +
    +
    Size
    +
    {size_str}
    +
    +
    +
    Raw URL
    + +
    +
    @@ -1071,219 +1110,150 @@ def get_user_from_cookie(request) -> Optional[str]: return verify_token_with_l2(token) -UI_CSS = """ - * { box-sizing: border-box; } - body { - font-family: system-ui, -apple-system, sans-serif; - margin: 0; padding: 24px; - background: #111; color: #eee; - font-size: 16px; +# Tailwind CSS config for all L1 templates +TAILWIND_CONFIG = ''' + + + +''' + def render_ui_html(username: Optional[str] = None, tab: str = "runs") -> str: """Render main UI HTML with optional user context.""" user_info = "" if username: user_info = f''' -
    - Logged in as {username} - Logout +
    + Logged in as {username} + Logout
    ''' else: user_info = ''' -
    - Login +
    + Login
    ''' - runs_active = "active" if tab == "runs" else "" - cache_active = "active" if tab == "cache" else "" + runs_active = "border-b-2 border-blue-500 text-white" if tab == "runs" else "text-gray-400 hover:text-white" + cache_active = "border-b-2 border-blue-500 text-white" if tab == "cache" else "text-gray-400 hover:text-white" - runs_content = "" - cache_content = "" - if tab == "runs": - runs_content = ''' -
    - Loading... -
    - ''' - else: - cache_content = ''' -
    - Loading... + content_url = "/ui/runs" if tab == "runs" else "/ui/cache-list" + + return f""" + + + + + + Art DAG L1 Server + {TAILWIND_CONFIG} + + +
    +
    +

    + Art DAG L1 Server +

    + {user_info} +
    + + + +
    +
    +
    Loading...
    +
    +
    + + +""" + + +def get_auth_page_html(page_type: str = "login", error: str = None) -> str: + """Generate login or register page HTML with Tailwind CSS.""" + is_login = page_type == "login" + title = "Login" if is_login else "Register" + + login_active = "bg-blue-600 text-white" if is_login else "bg-dark-500 text-gray-400 hover:text-white" + register_active = "bg-dark-500 text-gray-400 hover:text-white" if is_login else "bg-blue-600 text-white" + + error_html = f'
    {error}
    ' if error else '' + + form_fields = ''' + + + ''' + + if not is_login: + form_fields += ''' + ''' return f""" - + - Art DAG L1 Server - - + + + {title} | Art DAG L1 Server + {TAILWIND_CONFIG} - - {user_info} -

    Art DAG L1 Server

    - - {runs_content} - {cache_content} - - -""" + +
    +

    + Art DAG L1 Server +

    + + + + + Back + -UI_LOGIN_HTML = """ - - - - Login | Art DAG L1 Server - - - -

    Art DAG L1 Server

    - ← Back +
    +
    + Login + Register +
    - """ -UI_REGISTER_HTML = """ - - - - Register | Art DAG L1 Server - - - -

    Art DAG L1 Server

    - ← Back - - - - -""" +UI_LOGIN_HTML = get_auth_page_html("login") +UI_REGISTER_HTML = get_auth_page_html("register") @app.get("/ui", response_class=HTMLResponse) @@ -1316,10 +1286,7 @@ async def ui_login(username: str = Form(...), password: str = Form(...)): except Exception: pass - return HTMLResponse(UI_LOGIN_HTML.replace( - '', - '

    Invalid username or password

    ' - )) + return HTMLResponse(get_auth_page_html("login", "Invalid username or password")) @app.get("/ui/register", response_class=HTMLResponse) @@ -1348,15 +1315,9 @@ async def ui_register( return response elif resp.status_code == 400: error = resp.json().get("detail", "Registration failed") - return HTMLResponse(UI_REGISTER_HTML.replace( - '', - f'

    {error}

    ' - )) + return HTMLResponse(get_auth_page_html("register", error)) except Exception as e: - return HTMLResponse(UI_REGISTER_HTML.replace( - '', - f'

    Registration failed: {e}

    ' - )) + return HTMLResponse(get_auth_page_html("register", f"Registration failed: {e}")) @app.get("/ui/logout") @@ -1388,7 +1349,7 @@ async def ui_publish_run(run_id: str, request: Request, output_name: str = Form( resp.raise_for_status() result = resp.json() return HTMLResponse(f''' -
    +
    Published to L2 as {result["asset"]["name"]}
    ''') @@ -1398,9 +1359,9 @@ async def ui_publish_run(run_id: str, request: Request, output_name: str = Form( error_detail = e.response.json().get("detail", str(e)) except Exception: error_detail = str(e) - return HTMLResponse(f'
    Error: {error_detail}
    ') + return HTMLResponse(f'
    Error: {error_detail}
    ') except Exception as e: - return HTMLResponse(f'
    Error: {e}
    ') + return HTMLResponse(f'
    Error: {e}
    ') @app.get("/ui/runs", response_class=HTMLResponse) @@ -1411,33 +1372,39 @@ async def ui_runs(request: Request): # Require login to see runs if not current_user: - return '

    Login to see your runs.

    ' + return '

    Login to see your runs.

    ' # Filter runs by user - match both plain username and ActivityPub format (@user@domain) actor_id = f"@{current_user}@{L2_DOMAIN}" runs = [r for r in runs if r.username in (current_user, actor_id)] if not runs: - return '

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

    ' + return '

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

    ' - html_parts = ['
    '] + # 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[: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}" - owner_badge = f'by {run.username or "anonymous"}' if not current_user else '' + status_badge = status_colors.get(run.status, "bg-gray-600 text-white") html_parts.append(f''' - -
    -
    -
    - {run.recipe} - {run.run_id}{owner_badge} + +
    +
    +
    + {run.recipe} +
    - {run.status} + {run.status}
    -
    +
    Created: {run.created_at[:19].replace('T', ' ')}
    ''') @@ -1447,21 +1414,21 @@ async def ui_runs(request: Request): 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('
    ') + html_parts.append('
    ') # Input box if has_input: input_hash = run.inputs[0] input_media_type = detect_media_type(CACHE_DIR / input_hash) html_parts.append(f''' -
    - -
    +
    +
    Input: {input_hash[:16]}...
    +
    ''') if input_media_type == "video": - html_parts.append(f'') + html_parts.append(f'') elif input_media_type == "image": - html_parts.append(f'input') + html_parts.append(f'input') html_parts.append('
    ') # Output box @@ -1469,21 +1436,21 @@ async def ui_runs(request: Request): output_hash = run.output_hash output_media_type = detect_media_type(CACHE_DIR / output_hash) html_parts.append(f''' -
    - -
    +
    +
    Output: {output_hash[:16]}...
    +
    ''') if output_media_type == "video": - html_parts.append(f'') + html_parts.append(f'') elif output_media_type == "image": - html_parts.append(f'output') + html_parts.append(f'output') html_parts.append('
    ') html_parts.append('
    ') # Show error if failed if run.status == "failed" and run.error: - html_parts.append(f'
    Error: {run.error}
    ') + html_parts.append(f'
    Error: {run.error}
    ') html_parts.append('
    ') @@ -1503,7 +1470,7 @@ async def ui_cache_list( # Require login to see cache if not current_user: - return '

    Login to see cached content.

    ' + return '

    Login to see cached content.

    ' # Get hashes owned by/associated with this user user_hashes = get_user_cache_hashes(current_user) @@ -1512,7 +1479,7 @@ async def ui_cache_list( 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'): + 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: # Load metadata for filtering meta = load_cache_meta(f.name) @@ -1554,9 +1521,9 @@ async def ui_cache_list( filter_msg = f" in collection '{collection}'" elif tag: filter_msg = f" with tag '{tag}'" - return f'

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

    ' + return f'

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

    ' - html_parts = ['
    '] + html_parts = ['
    '] for item in cache_items[:50]: # Limit to 50 items content_hash = item["hash"] @@ -1573,30 +1540,24 @@ async def ui_cache_list( size_str = f"{size} bytes" html_parts.append(f''' - -
    -
    -
    - {media_type} - {content_hash} -
    - {size_str} +
    +
    +
    + {media_type} + {size_str}
    -
    -
    -
    +
    {content_hash[:24]}...
    +
    ''') if media_type == "video": - html_parts.append(f'') + html_parts.append(f'') elif media_type == "image": - html_parts.append(f'{content_hash[:16]}') + html_parts.append(f'{content_hash[:16]}') else: - html_parts.append(f'

    Unknown file type

    ') + html_parts.append('

    Unknown file type

    ') html_parts.append(''' -
    -
    @@ -1613,23 +1574,62 @@ async def ui_detail_page(run_id: str, request: Request): if not current_user: return HTMLResponse(f''' - -Login Required | Art DAG L1 - -

    Art DAG L1 Server

    -

    Login to view run details.

    + + + + + Login Required | Art DAG L1 + {TAILWIND_CONFIG} + + +
    +

    Art DAG L1 Server

    +
    +

    Login to view run details.

    +
    +
    ''', status_code=401) run = load_run(run_id) if not run: - return HTMLResponse('

    Run not found

    ', status_code=404) + return HTMLResponse(f''' + + + + + + Not Found | Art DAG L1 + {TAILWIND_CONFIG} + + +
    +

    Run not found

    +
    + + +''', status_code=404) # Check user owns this run actor_id = f"@{current_user}@{L2_DOMAIN}" if run.username not in (current_user, actor_id): - return HTMLResponse('

    Access denied

    ', status_code=403) + return HTMLResponse(f''' + + + + + + Access Denied | Art DAG L1 + {TAILWIND_CONFIG} + + +
    +

    Access denied

    +
    + + +''', status_code=403) # Check Celery task status if running if run.status == "running" and run.celery_task_id: @@ -1662,142 +1662,89 @@ async def ui_detail_page(run_id: str, request: Request): 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_class = run.status - html = f""" - - - - {run.recipe} - {run.run_id[:8]} | Art DAG L1 - - - - -

    Art DAG L1 Server

    - ← Back to runs + # 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") -
    -
    -
    - {run.recipe} - {run.run_id} -
    - {run.status} -
    -""" - - # Media row + # 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: - html += '
    ' + media_html = '
    ' if has_input: input_hash = run.inputs[0] input_media_type = detect_media_type(CACHE_DIR / input_hash) - html += f''' -
    - -
    - ''' + input_video_src = video_src_for_request(input_hash, request) if input_media_type == "video": - input_video_src = video_src_for_request(input_hash, request) - html += f'' + input_elem = f'' elif input_media_type == "image": - html += f'input' - html += '
    ' + 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) - html += f''' -
    - -
    - ''' + output_video_src = video_src_for_request(output_hash, request) if output_media_type == "video": - output_video_src = video_src_for_request(output_hash, request) - html += f'' + output_elem = f'' elif output_media_type == "image": - html += f'output' - html += '
    ' + output_elem = f'output' + else: + output_elem = '

    Unknown format

    ' - html += '
    ' - - # Provenance section - html += f''' -
    -

    Provenance

    -
    -
    Owner
    -
    {run.username or "anonymous"}
    -
    -
    -
    Effect
    - -
    -
    -
    Effects Commit
    -
    {run.effects_commit or "N/A"}
    -
    -
    -
    Input(s)
    -
    - ''' - for inp in run.inputs: - html += f'{inp}
    ' - html += f''' + media_html += f''' +
    +
    Output
    + {output_hash[:24]}... +
    {output_elem}
    -
    - ''' + ''' - if run.output_hash: - html += f''' -
    -
    Output
    - -
    - ''' + 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", {}) - html += f''' -
    -
    Infrastructure
    -
    + 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]}...)
    ''' - html += f''' -
    -
    Run ID
    -
    {run.run_id}
    -
    -
    -
    Created
    -
    {run.created_at}
    -
    - ''' - - if run.completed_at: - html += f''' -
    -
    Completed
    -
    {run.completed_at}
    -
    - ''' - + # Error display + error_html = "" if run.error: - html += f''' -
    -
    Error
    -
    {run.error}
    + error_html = f''' +
    +
    Error
    +
    {run.error}
    ''' @@ -1818,35 +1765,108 @@ async def ui_detail_page(run_id: str, request: Request): "error": run.error }, indent=2) - html += f''' -

    Raw JSON

    -
    {provenance_json}
    - ''' - - # Add publish section for completed runs + # Publish section + publish_html = "" if run.status == "completed" and run.output_hash: - html += f''' -

    Publish to L2

    -

    Register this transformation output on the L2 ActivityPub server.

    -
    -
    - - -
    + publish_html = f''' +
    +

    Publish to L2

    +

    Register this transformation output on the L2 ActivityPub server.

    +
    +
    + + +
    +
    ''' - html += ''' -
    + 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 @@ -1855,7 +1875,7 @@ async def ui_run_partial(run_id: str): """HTMX partial: single run (for polling updates).""" run = load_run(run_id) if not run: - return '
    Run not found
    ' + return '
    Run not found
    ' # Check Celery task status if running if run.status == "running" and run.celery_task_id: @@ -1881,19 +1901,27 @@ async def ui_run_partial(run_id: str): 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 "" + # 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") + poll_attr = f'hx-get="/ui/run/{run_id}" hx-trigger="every 2s" hx-swap="outerHTML"' if run.status == "running" else "" html = f''' -
    -
    -
    - {run.recipe} - {run.run_id} + +
    +
    +
    + {run.recipe} +
    - {run.status} + {run.status}
    -
    +
    Created: {run.created_at[:19].replace('T', ' ')}
    ''' @@ -1903,34 +1931,42 @@ async def ui_run_partial(run_id: str): has_output = run.status == "completed" and run.output_hash and (CACHE_DIR / run.output_hash).exists() if has_input or has_output: - html += '
    ' + html += '
    ' if has_input: input_hash = run.inputs[0] input_media_type = detect_media_type(CACHE_DIR / input_hash) - html += f'
    {input_hash[:24]}...
    ' + html += f''' +
    +
    Input: {input_hash[:16]}...
    +
    + ''' if input_media_type == "video": - html += f'' + html += f'' elif input_media_type == "image": - html += f'input' + html += f'input' html += '
    ' if has_output: output_hash = run.output_hash output_media_type = detect_media_type(CACHE_DIR / output_hash) - html += f'
    ' + html += f''' +
    +
    Output: {output_hash[:16]}...
    +
    + ''' if output_media_type == "video": - html += f'' + html += f'' elif output_media_type == "image": - html += f'output' + html += f'output' html += '
    ' html += '
    ' if run.status == "failed" and run.error: - html += f'
    Error: {run.error}
    ' + html += f'
    Error: {run.error}
    ' - html += '
    ' + html += '
    ' return html