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. 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 import logging
from typing import Optional from typing import Optional
import requests
from fastapi import APIRouter, Request, Depends, HTTPException from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from artdag_common import render from artdag_common import render
from artdag_common.middleware import wants_html, wants_json from artdag_common.middleware import wants_html, wants_json
@@ -21,132 +22,72 @@ router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AttachRendererRequest(BaseModel): def check_renderer_health(url: str, timeout: float = 5.0) -> bool:
l1_url: str """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("") @router.get("")
async def list_renderers( async def list_renderers(request: Request):
request: Request, """List configured L1 renderers."""
user: dict = Depends(require_auth), # Get user if logged in
): username = get_user_from_cookie(request)
"""List attached L1 renderers.""" user = None
import db 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"]) # Build server list with health status
servers = []
# Convert to dicts with status info for url in settings.l1_servers:
renderers = [ servers.append({
{"url": url, "known": url in settings.l1_servers} "url": url,
for url in renderer_urls "healthy": check_renderer_health(url),
] })
if wants_json(request): if wants_json(request):
return {"renderers": renderers, "known_servers": settings.l1_servers} return {"servers": servers}
templates = get_templates(request) templates = get_templates(request)
return render(templates, "renderers/list.html", request, return render(templates, "renderers/list.html", request,
renderers=renderers, servers=servers,
known_servers=settings.l1_servers,
user=user, user=user,
active_tab="renderers", 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("") @router.post("")
async def attach_renderer( @router.post("/{path:path}")
req: AttachRendererRequest, async def renderer_post_catchall(request: Request, path: str = ""):
user: dict = Depends(require_auth), """
): Catch-all for POST requests.
"""Attach an L1 renderer."""
import db
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 templates = get_templates(request)
if not l1_url.startswith("http"): return render(templates, "renderers/list.html", request,
raise HTTPException(400, "Invalid URL") servers=[{"url": url, "healthy": check_renderer_health(url)} for url in settings.l1_servers],
user=get_user_from_cookie(request),
# Check if already attached error="Renderers are configured by the system administrator. Use the Connect button to access a renderer.",
existing = await db.get_user_renderers(user["username"]) active_tab="renderers",
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}

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 %}