Add Renderers page for L1 server management
- Add user_renderers table to track which L1 servers users are attached to - Add L1_SERVERS config to define available renderers - Add /renderers page showing attachment status for each L1 server - Add attach functionality (redirects to L1 /auth with token) - Add detach functionality with HTMX updates - Add Renderers link to nav bar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
46
db.py
46
db.py
@@ -100,6 +100,15 @@ CREATE TABLE IF NOT EXISTS followers (
|
|||||||
UNIQUE(username, acct)
|
UNIQUE(username, acct)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- User's attached L1 renderers
|
||||||
|
CREATE TABLE IF NOT EXISTS user_renderers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(255) NOT NULL REFERENCES users(username),
|
||||||
|
l1_url TEXT NOT NULL,
|
||||||
|
attached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(username, l1_url)
|
||||||
|
);
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_content_hash ON assets(content_hash);
|
CREATE INDEX IF NOT EXISTS idx_assets_content_hash ON assets(content_hash);
|
||||||
@@ -715,3 +724,40 @@ async def get_anchor_stats() -> dict:
|
|||||||
"anchored_activities": anchored_activities,
|
"anchored_activities": anchored_activities,
|
||||||
"unanchored_activities": unanchored_activities
|
"unanchored_activities": unanchored_activities
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ User Renderers (L1 attachments) ============
|
||||||
|
|
||||||
|
async def get_user_renderers(username: str) -> list[str]:
|
||||||
|
"""Get L1 renderer URLs attached by a user."""
|
||||||
|
async with get_connection() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT l1_url FROM user_renderers WHERE username = $1 ORDER BY attached_at",
|
||||||
|
username
|
||||||
|
)
|
||||||
|
return [row["l1_url"] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def attach_renderer(username: str, l1_url: str) -> bool:
|
||||||
|
"""Attach a user to an L1 renderer. Returns True if newly attached."""
|
||||||
|
async with get_connection() as conn:
|
||||||
|
try:
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO user_renderers (username, l1_url)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (username, l1_url) DO NOTHING""",
|
||||||
|
username, l1_url
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def detach_renderer(username: str, l1_url: str) -> bool:
|
||||||
|
"""Detach a user from an L1 renderer. Returns True if was attached."""
|
||||||
|
async with get_connection() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM user_renderers WHERE username = $1 AND l1_url = $2",
|
||||||
|
username, l1_url
|
||||||
|
)
|
||||||
|
return "DELETE 1" in result
|
||||||
|
|||||||
111
server.py
111
server.py
@@ -48,6 +48,10 @@ L1_PUBLIC_URL = os.environ.get("L1_PUBLIC_URL", "https://celery-artdag.rose-ash.
|
|||||||
EFFECTS_REPO_URL = os.environ.get("EFFECTS_REPO_URL", "https://git.rose-ash.com/art-dag/effects")
|
EFFECTS_REPO_URL = os.environ.get("EFFECTS_REPO_URL", "https://git.rose-ash.com/art-dag/effects")
|
||||||
IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "")
|
IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "")
|
||||||
|
|
||||||
|
# Known L1 renderers (comma-separated URLs)
|
||||||
|
L1_SERVERS_STR = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com")
|
||||||
|
L1_SERVERS = [s.strip() for s in L1_SERVERS_STR.split(",") if s.strip()]
|
||||||
|
|
||||||
# Cookie domain for sharing auth across subdomains (e.g., ".rose-ash.com")
|
# Cookie domain for sharing auth across subdomains (e.g., ".rose-ash.com")
|
||||||
# If not set, derives from DOMAIN (strips first subdomain, adds leading dot)
|
# If not set, derives from DOMAIN (strips first subdomain, adds leading dot)
|
||||||
def _get_cookie_domain():
|
def _get_cookie_domain():
|
||||||
@@ -319,6 +323,7 @@ def base_html(title: str, content: str, username: str = None) -> str:
|
|||||||
<a href="/activities" class="text-gray-400 hover:text-white transition-colors">Activities</a>
|
<a href="/activities" class="text-gray-400 hover:text-white transition-colors">Activities</a>
|
||||||
<a href="/users" class="text-gray-400 hover:text-white transition-colors">Users</a>
|
<a href="/users" class="text-gray-400 hover:text-white transition-colors">Users</a>
|
||||||
<a href="/anchors/ui" class="text-gray-400 hover:text-white transition-colors">Anchors</a>
|
<a href="/anchors/ui" class="text-gray-400 hover:text-white transition-colors">Anchors</a>
|
||||||
|
<a href="/renderers" class="text-gray-400 hover:text-white transition-colors">Renderers</a>
|
||||||
<a href="/download/client" class="text-gray-400 hover:text-white transition-colors ml-auto" title="Download CLI client">Download Client</a>
|
<a href="/download/client" class="text-gray-400 hover:text-white transition-colors ml-auto" title="Download CLI client">Download Client</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -2824,6 +2829,112 @@ async def test_ots_connection():
|
|||||||
''')
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Renderers (L1 servers) ============
|
||||||
|
|
||||||
|
@app.get("/renderers", response_class=HTMLResponse)
|
||||||
|
async def renderers_page(request: Request):
|
||||||
|
"""Page to manage L1 renderer attachments."""
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
content = '''
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-4">Renderers</h2>
|
||||||
|
<p class="text-gray-400">Log in to manage your renderer connections.</p>
|
||||||
|
'''
|
||||||
|
return HTMLResponse(base_html("Renderers", content))
|
||||||
|
|
||||||
|
# Get user's attached renderers
|
||||||
|
attached = await db.get_user_renderers(username)
|
||||||
|
token = request.cookies.get("auth_token", "")
|
||||||
|
|
||||||
|
# Build renderer list
|
||||||
|
rows = []
|
||||||
|
for l1_url in L1_SERVERS:
|
||||||
|
is_attached = l1_url in attached
|
||||||
|
# Extract display name from URL
|
||||||
|
display_name = l1_url.replace("https://", "").replace("http://", "")
|
||||||
|
|
||||||
|
if is_attached:
|
||||||
|
status = '<span class="px-2 py-1 bg-green-600 text-white text-xs font-medium rounded-full">Attached</span>'
|
||||||
|
action = f'''
|
||||||
|
<a href="{l1_url}" target="_blank" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors">
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
<button hx-post="/renderers/detach" hx-vals='{{"l1_url": "{l1_url}"}}' hx-target="#renderer-{l1_url.replace("://", "-").replace("/", "-").replace(".", "-")}" hx-swap="outerHTML"
|
||||||
|
class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded transition-colors ml-2">
|
||||||
|
Detach
|
||||||
|
</button>
|
||||||
|
'''
|
||||||
|
else:
|
||||||
|
status = '<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>'
|
||||||
|
# Attach redirects to L1 with token
|
||||||
|
attach_url = f"{l1_url}/auth?auth_token={token}"
|
||||||
|
action = f'''
|
||||||
|
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors"
|
||||||
|
onclick="setTimeout(() => location.reload(), 2000)">
|
||||||
|
Attach
|
||||||
|
</a>
|
||||||
|
'''
|
||||||
|
|
||||||
|
row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-")
|
||||||
|
rows.append(f'''
|
||||||
|
<div id="renderer-{row_id}" class="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-white">{display_name}</div>
|
||||||
|
<div class="text-sm text-gray-400">{l1_url}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{status}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
content = f'''
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-4">Renderers</h2>
|
||||||
|
<p class="text-gray-400 mb-6">Connect to L1 rendering servers. After attaching, you can run effects and manage media on that renderer.</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{"".join(rows) if rows else '<p class="text-gray-500">No renderers configured.</p>'}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
return HTMLResponse(base_html("Renderers", content, username))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/renderers/detach", response_class=HTMLResponse)
|
||||||
|
async def detach_renderer(request: Request):
|
||||||
|
"""Detach from an L1 renderer."""
|
||||||
|
username = get_user_from_cookie(request)
|
||||||
|
if not username:
|
||||||
|
return HTMLResponse('<div class="text-red-400">Not logged in</div>')
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
l1_url = form.get("l1_url", "")
|
||||||
|
|
||||||
|
await db.detach_renderer(username, l1_url)
|
||||||
|
|
||||||
|
# Return updated row
|
||||||
|
display_name = l1_url.replace("https://", "").replace("http://", "")
|
||||||
|
token = request.cookies.get("auth_token", "")
|
||||||
|
attach_url = f"{l1_url}/auth?auth_token={token}"
|
||||||
|
row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-")
|
||||||
|
|
||||||
|
return HTMLResponse(f'''
|
||||||
|
<div id="renderer-{row_id}" class="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-white">{display_name}</div>
|
||||||
|
<div class="text-sm text-gray-400">{l1_url}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>
|
||||||
|
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors"
|
||||||
|
onclick="setTimeout(() => location.reload(), 2000)">
|
||||||
|
Attach
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
# ============ Client Download ============
|
# ============ Client Download ============
|
||||||
|
|
||||||
CLIENT_TARBALL = Path(__file__).parent / "artdag-client.tar.gz"
|
CLIENT_TARBALL = Path(__file__).parent / "artdag-client.tar.gz"
|
||||||
|
|||||||
Reference in New Issue
Block a user