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 = """ - +
+ +L1 rendering server for the Art DAG system. Manages distributed rendering jobs via Celery workers.
+ +L1 rendering server for the Art DAG system. Manages distributed rendering jobs via Celery workers.
+ +| Method | Path | Description |
|---|---|---|
| GET | /ui | Web UI for viewing runs |
| POST | /runs | Start a rendering run |
| GET | /runs | List all runs |
| GET | /runs/{run_id} | Get run status |
| GET | /cache | List cached content hashes |
| GET | /cache/{hash} | Download cached content |
| POST | /cache/upload | Upload file to cache |
| GET | /assets | List known assets |
| Method | +Path | +Description | +
|---|---|---|
| GET | /ui | Web UI for viewing runs |
| POST | /runs | Start a rendering run |
| GET | /runs | List all runs |
| GET | /runs/{run_id} | Get run status |
| GET | /cache | List cached content hashes |
| GET | /cache/{hash} | Download cached content |
| POST | /cache/upload | Upload file to cache |
| GET | /assets | List known assets |
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}
+
+
+
-""", 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'
'
+ media_html = f'
'
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
+
+
-
"""
-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'
')
+ html_parts.append(f'
')
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'
')
+ html_parts.append(f'
')
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'
')
+ html_parts.append(f'
')
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'
'
- html += ''
+ input_elem = f'
'
+ else:
+ input_elem = 'Unknown format
'
+
+ media_html += f'''
+
+ '''
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'
'
- html += ''
+ output_elem = f'
'
+ 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'''
+
-
- '''
+ '''
- 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}
+
+ {"Output" + run.output_hash + "" 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'''
-