Simplify renderers to use env-configured L1 servers

- L1 servers now come from L1_SERVERS env var instead of per-user attachment
- Added renderers/list.html template showing available servers
- Health check shows if servers are online
- Elegant error handling for invalid requests (no more raw JSON errors)
- Connect button passes auth token to L1 for seamless login

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-11 15:36:38 +00:00
parent dcb487e6f4
commit 8c4a30d18f
2 changed files with 108 additions and 115 deletions

View File

@@ -1,15 +1,16 @@
"""
Renderer (L1) management routes for L2 server.
Handles L1 server attachments and delegation.
L1 servers are configured via environment variable L1_SERVERS.
Users connect to renderers to create and run recipes.
"""
import logging
from typing import Optional
import requests
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from fastapi.responses import HTMLResponse, RedirectResponse
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
@@ -21,132 +22,72 @@ router = APIRouter()
logger = logging.getLogger(__name__)
class AttachRendererRequest(BaseModel):
l1_url: str
def check_renderer_health(url: str, timeout: float = 5.0) -> bool:
"""Check if a renderer is healthy."""
try:
resp = requests.get(f"{url}/", timeout=timeout)
return resp.status_code == 200
except Exception:
return False
@router.get("")
async def list_renderers(
request: Request,
user: dict = Depends(require_auth),
):
"""List attached L1 renderers."""
import db
async def list_renderers(request: Request):
"""List configured L1 renderers."""
# Get user if logged in
username = get_user_from_cookie(request)
user = None
if username:
# Get token for connection links
token = request.cookies.get("auth_token", "")
user = {"username": username, "token": token}
renderer_urls = await db.get_user_renderers(user["username"])
# Convert to dicts with status info
renderers = [
{"url": url, "known": url in settings.l1_servers}
for url in renderer_urls
]
# Build server list with health status
servers = []
for url in settings.l1_servers:
servers.append({
"url": url,
"healthy": check_renderer_health(url),
})
if wants_json(request):
return {"renderers": renderers, "known_servers": settings.l1_servers}
return {"servers": servers}
templates = get_templates(request)
return render(templates, "renderers/list.html", request,
renderers=renderers,
known_servers=settings.l1_servers,
servers=servers,
user=user,
active_tab="renderers",
)
@router.get("/{path:path}")
async def renderer_catchall(path: str, request: Request):
"""Catch-all for invalid renderer URLs - redirect to list."""
if wants_json(request):
raise HTTPException(404, "Not found")
return RedirectResponse(url="/renderers", status_code=302)
@router.post("")
async def attach_renderer(
req: AttachRendererRequest,
user: dict = Depends(require_auth),
):
"""Attach an L1 renderer."""
import db
@router.post("/{path:path}")
async def renderer_post_catchall(request: Request, path: str = ""):
"""
Catch-all for POST requests.
l1_url = req.l1_url.rstrip("/")
The old API expected JSON POST to attach renderers.
Now renderers are env-configured, so redirect to the list.
"""
if wants_json(request):
return {
"error": "Renderers are now configured via environment. See /renderers for available servers.",
"servers": settings.l1_servers,
}
# Validate URL
if not l1_url.startswith("http"):
raise HTTPException(400, "Invalid URL")
# Check if already attached
existing = await db.get_user_renderers(user["username"])
if l1_url in existing:
raise HTTPException(400, "Renderer already attached")
# Test connection
try:
import requests
resp = requests.get(f"{l1_url}/health", timeout=10)
if resp.status_code != 200:
raise HTTPException(400, f"L1 server not healthy: {resp.status_code}")
except Exception as e:
raise HTTPException(400, f"Failed to connect to L1: {e}")
# Attach
await db.attach_renderer(user["username"], l1_url)
return {"attached": True, "url": l1_url}
@router.delete("/{renderer_id}")
async def detach_renderer(
renderer_id: int,
user: dict = Depends(require_auth),
):
"""Detach an L1 renderer."""
import db
success = await db.detach_renderer(user["username"], renderer_id)
if not success:
raise HTTPException(404, "Renderer not found")
return {"detached": True}
@router.post("/{renderer_id}/sync")
async def sync_renderer(
renderer_id: int,
request: Request,
user: dict = Depends(require_auth),
):
"""Sync assets with an L1 renderer."""
import db
import requests
renderer = await db.get_renderer(renderer_id)
if not renderer:
raise HTTPException(404, "Renderer not found")
if renderer.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
l1_url = renderer["url"]
# Get user's assets
assets = await db.get_user_assets(user["username"])
synced = 0
errors = []
for asset in assets:
if asset.get("ipfs_cid"):
try:
# Tell L1 to import from IPFS
resp = requests.post(
f"{l1_url}/cache/import",
json={"ipfs_cid": asset["ipfs_cid"]},
headers={"Authorization": f"Bearer {user['token']}"},
timeout=30,
)
if resp.status_code == 200:
synced += 1
else:
errors.append(f"{asset['name']}: {resp.status_code}")
except Exception as e:
errors.append(f"{asset['name']}: {e}")
if wants_html(request):
if errors:
return HTMLResponse(f'<span class="text-yellow-400">Synced {synced}, {len(errors)} errors</span>')
return HTMLResponse(f'<span class="text-green-400">Synced {synced} assets</span>')
return {"synced": synced, "errors": errors}
templates = get_templates(request)
return render(templates, "renderers/list.html", request,
servers=[{"url": url, "healthy": check_renderer_health(url)} for url in settings.l1_servers],
user=get_user_from_cookie(request),
error="Renderers are configured by the system administrator. Use the Connect button to access a renderer.",
active_tab="renderers",
)

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Renderers</h1>
<p class="text-gray-400 mb-6">
Renderers are L1 servers that process your media. Connect to a renderer to create and run recipes.
</p>
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded mb-6">
{{ error }}
</div>
{% endif %}
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded mb-6">
{{ success }}
</div>
{% endif %}
<div class="space-y-4">
{% for server in servers %}
<div class="bg-gray-800 rounded-lg p-4 flex items-center justify-between">
<div>
<a href="{{ server.url }}" target="_blank" class="text-blue-400 hover:text-blue-300 font-medium">
{{ server.url }}
</a>
{% if server.healthy %}
<span class="ml-2 text-green-400 text-sm">Online</span>
{% else %}
<span class="ml-2 text-red-400 text-sm">Offline</span>
{% endif %}
</div>
<div class="flex gap-2">
<a href="{{ server.url }}/auth?auth_token={{ user.token }}"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-sm">
Connect
</a>
</div>
</div>
{% else %}
<p class="text-gray-500">No renderers configured.</p>
{% endfor %}
</div>
<div class="mt-8 text-gray-500 text-sm">
<p>Renderers are configured by the system administrator.</p>
</div>
</div>
{% endblock %}