Add DAG visualization for runs and recipes
- Add Cytoscape.js infrastructure for interactive DAG visualization
- New run sub-pages: /run/{id}/plan, /run/{id}/analysis, /run/{id}/artifacts
- Plan page shows execution DAG with cached/pending status
- Analysis page displays tempo, beats, energy per input
- Artifacts page lists all cached items with thumbnails
- New /recipe/{id}/dag page for recipe structure visualization
- Add sub-tabs navigation to run detail page
- Add JSON API endpoints for future WebSocket support
- Architecture designed for real-time updates (global Cytoscape instance)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
834
server.py
834
server.py
@@ -1212,6 +1212,9 @@ async def run_detail(run_id: str, request: Request):
|
|||||||
<div class="text-gray-200">{run.completed_at[:19].replace('T', ' ')}</div>
|
<div class="text-gray-200">{run.completed_at[:19].replace('T', ' ')}</div>
|
||||||
</div>'''
|
</div>'''
|
||||||
|
|
||||||
|
# Sub-navigation tabs for run detail pages
|
||||||
|
sub_tabs_html = render_run_sub_tabs(run_id, active="overview")
|
||||||
|
|
||||||
content = f'''
|
content = f'''
|
||||||
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
|
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -1220,6 +1223,8 @@ async def run_detail(run_id: str, request: Request):
|
|||||||
Back to runs
|
Back to runs
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{sub_tabs_html}
|
||||||
|
|
||||||
<div class="bg-dark-700 rounded-lg p-6">
|
<div class="bg-dark-700 rounded-lg p-6">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -1279,6 +1284,485 @@ async def run_detail(run_id: str, request: Request):
|
|||||||
return run.model_dump()
|
return run.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
# Plan/Analysis cache directories (match tasks/orchestrate.py)
|
||||||
|
PLAN_CACHE_DIR = CACHE_DIR / 'plans'
|
||||||
|
ANALYSIS_CACHE_DIR = CACHE_DIR / 'analysis'
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/run/{run_id}/plan", response_class=HTMLResponse)
|
||||||
|
async def run_plan_visualization(run_id: str, request: Request):
|
||||||
|
"""Visualize execution plan as interactive DAG."""
|
||||||
|
ctx = await get_user_context_from_cookie(request)
|
||||||
|
if not ctx:
|
||||||
|
content = '<p class="text-gray-400 py-8 text-center">Not logged in.</p>'
|
||||||
|
return HTMLResponse(render_page("Login Required", content, None, active_tab="runs"), status_code=401)
|
||||||
|
|
||||||
|
run = await asyncio.to_thread(load_run, run_id)
|
||||||
|
if not run:
|
||||||
|
content = f'<p class="text-red-400">Run not found: {run_id}</p>'
|
||||||
|
return HTMLResponse(render_page("Not Found", content, ctx.actor_id, active_tab="runs"), status_code=404)
|
||||||
|
|
||||||
|
# Check user owns this run
|
||||||
|
if run.username not in (ctx.username, ctx.actor_id):
|
||||||
|
content = '<p class="text-red-400 py-8 text-center">Access denied.</p>'
|
||||||
|
return HTMLResponse(render_page("Access Denied", content, ctx.actor_id, active_tab="runs"), status_code=403)
|
||||||
|
|
||||||
|
# Try to load existing plan from cache
|
||||||
|
plan_data = None
|
||||||
|
PLAN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Look for plan file matching this run
|
||||||
|
for plan_file in PLAN_CACHE_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(plan_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
# Check if this plan matches our run inputs
|
||||||
|
plan_inputs = data.get("input_hashes", {})
|
||||||
|
if set(plan_inputs.values()) == set(run.inputs):
|
||||||
|
plan_data = data
|
||||||
|
break
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build sub-navigation tabs
|
||||||
|
tabs_html = render_run_sub_tabs(run_id, active="plan")
|
||||||
|
|
||||||
|
if not plan_data:
|
||||||
|
content = f'''
|
||||||
|
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to runs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{tabs_html}
|
||||||
|
|
||||||
|
<div class="bg-dark-700 rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-bold text-white mb-4">Execution Plan</h2>
|
||||||
|
<p class="text-gray-400">No execution plan available for this run.</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">Plans are generated when using recipe-based runs with the v2 API.</p>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
return HTMLResponse(render_page_with_cytoscape(f"Plan: {run_id[:16]}...", content, ctx.actor_id, active_tab="runs"))
|
||||||
|
|
||||||
|
# Build Cytoscape nodes and edges from plan
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
steps = plan_data.get("steps", [])
|
||||||
|
|
||||||
|
for step in steps:
|
||||||
|
node_type = step.get("node_type", "EFFECT")
|
||||||
|
color = NODE_COLORS.get(node_type, NODE_COLORS["default"])
|
||||||
|
cached = step.get("cached", False)
|
||||||
|
status = "cached" if cached else "pending"
|
||||||
|
|
||||||
|
# Shorter label for display
|
||||||
|
step_id = step.get("step_id", "")
|
||||||
|
label = step_id[:12] + "..." if len(step_id) > 12 else step_id
|
||||||
|
|
||||||
|
nodes.append({
|
||||||
|
"data": {
|
||||||
|
"id": step_id,
|
||||||
|
"label": label,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"level": step.get("level", 0),
|
||||||
|
"cacheId": step.get("cache_id", ""),
|
||||||
|
"status": status,
|
||||||
|
"color": color,
|
||||||
|
"config": step.get("config")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build edges from the full plan JSON if available
|
||||||
|
if "plan_json" in plan_data:
|
||||||
|
try:
|
||||||
|
full_plan = json.loads(plan_data["plan_json"])
|
||||||
|
for step in full_plan.get("steps", []):
|
||||||
|
step_id = step.get("step_id", "")
|
||||||
|
for input_step in step.get("input_steps", []):
|
||||||
|
edges.append({
|
||||||
|
"data": {
|
||||||
|
"source": input_step,
|
||||||
|
"target": step_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
nodes_json = json.dumps(nodes)
|
||||||
|
edges_json = json.dumps(edges)
|
||||||
|
|
||||||
|
dag_html = render_dag_cytoscape(nodes_json, edges_json)
|
||||||
|
|
||||||
|
# Stats summary
|
||||||
|
total = plan_data.get("total_steps", len(steps))
|
||||||
|
cached = plan_data.get("cached_steps", sum(1 for s in steps if s.get("cached")))
|
||||||
|
pending = plan_data.get("pending_steps", total - cached)
|
||||||
|
|
||||||
|
content = f'''
|
||||||
|
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to runs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{tabs_html}
|
||||||
|
|
||||||
|
<div class="bg-dark-700 rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-bold text-white mb-4">Execution Plan</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-dark-600 rounded-lg p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-white">{total}</div>
|
||||||
|
<div class="text-sm text-gray-400">Total Steps</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-dark-600 rounded-lg p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-400">{cached}</div>
|
||||||
|
<div class="text-sm text-gray-400">Cached</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-dark-600 rounded-lg p-4 text-center">
|
||||||
|
<div class="text-2xl font-bold text-yellow-400">{pending}</div>
|
||||||
|
<div class="text-sm text-gray-400">Executed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex gap-4 text-sm flex-wrap">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded" style="background-color: #3b82f6"></span> SOURCE
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded" style="background-color: #22c55e"></span> EFFECT
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded" style="background-color: #6366f1"></span> _LIST
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded border-2 border-green-500 bg-dark-600"></span> Cached
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dag_html}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
return HTMLResponse(render_page_with_cytoscape(f"Plan: {run_id[:16]}...", content, ctx.actor_id, active_tab="runs"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/run/{run_id}/analysis", response_class=HTMLResponse)
|
||||||
|
async def run_analysis_page(run_id: str, request: Request):
|
||||||
|
"""Show analysis results for run inputs."""
|
||||||
|
ctx = await get_user_context_from_cookie(request)
|
||||||
|
if not ctx:
|
||||||
|
content = '<p class="text-gray-400 py-8 text-center">Not logged in.</p>'
|
||||||
|
return HTMLResponse(render_page("Login Required", content, None, active_tab="runs"), status_code=401)
|
||||||
|
|
||||||
|
run = await asyncio.to_thread(load_run, run_id)
|
||||||
|
if not run:
|
||||||
|
content = f'<p class="text-red-400">Run not found: {run_id}</p>'
|
||||||
|
return HTMLResponse(render_page("Not Found", content, ctx.actor_id, active_tab="runs"), status_code=404)
|
||||||
|
|
||||||
|
# Check user owns this run
|
||||||
|
if run.username not in (ctx.username, ctx.actor_id):
|
||||||
|
content = '<p class="text-red-400 py-8 text-center">Access denied.</p>'
|
||||||
|
return HTMLResponse(render_page("Access Denied", content, ctx.actor_id, active_tab="runs"), status_code=403)
|
||||||
|
|
||||||
|
tabs_html = render_run_sub_tabs(run_id, active="analysis")
|
||||||
|
|
||||||
|
# Load analysis results for each input
|
||||||
|
analysis_html = ""
|
||||||
|
ANALYSIS_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for i, input_hash in enumerate(run.inputs):
|
||||||
|
analysis_path = ANALYSIS_CACHE_DIR / f"{input_hash}.json"
|
||||||
|
analysis_data = None
|
||||||
|
|
||||||
|
if analysis_path.exists():
|
||||||
|
try:
|
||||||
|
with open(analysis_path) as f:
|
||||||
|
analysis_data = json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
input_name = f"Input {i + 1}"
|
||||||
|
|
||||||
|
if analysis_data:
|
||||||
|
tempo = analysis_data.get("tempo", "N/A")
|
||||||
|
if isinstance(tempo, float):
|
||||||
|
tempo = f"{tempo:.1f}"
|
||||||
|
beat_times = analysis_data.get("beat_times", [])
|
||||||
|
beat_count = len(beat_times)
|
||||||
|
energy = analysis_data.get("energy")
|
||||||
|
|
||||||
|
# Beat visualization (simple bar chart showing beat positions)
|
||||||
|
beat_bars = ""
|
||||||
|
if beat_times and len(beat_times) > 0:
|
||||||
|
# Show first 50 beats as vertical bars
|
||||||
|
display_beats = beat_times[:50]
|
||||||
|
max_time = max(display_beats) if display_beats else 1
|
||||||
|
for bt in display_beats:
|
||||||
|
# Normalize to percentage
|
||||||
|
pos = (bt / max_time) * 100 if max_time > 0 else 0
|
||||||
|
beat_bars += f'<div class="w-1 bg-blue-500" style="height: 100%; margin-left: {pos * 0.8}%"></div>'
|
||||||
|
|
||||||
|
energy_bar = ""
|
||||||
|
if energy is not None:
|
||||||
|
try:
|
||||||
|
energy_pct = min(float(energy) * 100, 100)
|
||||||
|
energy_bar = f'''
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="text-xs text-gray-400 mb-1">Energy Level</div>
|
||||||
|
<div class="w-full bg-dark-500 rounded-full h-3">
|
||||||
|
<div class="bg-gradient-to-r from-green-500 to-yellow-500 h-3 rounded-full" style="width: {energy_pct}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">{energy_pct:.1f}%</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
analysis_html += f'''
|
||||||
|
<div class="bg-dark-700 rounded-lg p-6 mb-4">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-white">{input_name}</h3>
|
||||||
|
<a href="/cache/{input_hash}" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{input_hash[:24]}...</a>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-1 bg-green-600 text-white text-xs rounded-full">Analyzed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div class="bg-dark-600 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-bold text-white">{tempo}</div>
|
||||||
|
<div class="text-sm text-gray-400">BPM (Tempo)</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-dark-600 rounded-lg p-4">
|
||||||
|
<div class="text-2xl font-bold text-white">{beat_count}</div>
|
||||||
|
<div class="text-sm text-gray-400">Beats Detected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{energy_bar}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="text-xs text-gray-400 mb-2">Beat Timeline (first 50 beats)</div>
|
||||||
|
<div class="relative h-8 bg-dark-600 rounded overflow-hidden">
|
||||||
|
<div class="absolute inset-0 flex items-end">
|
||||||
|
{beat_bars if beat_bars else '<span class="text-gray-500 text-xs p-2">No beats detected</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
else:
|
||||||
|
analysis_html += f'''
|
||||||
|
<div class="bg-dark-700 rounded-lg p-6 mb-4">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-white">{input_name}</h3>
|
||||||
|
<a href="/cache/{input_hash}" class="text-blue-400 hover:text-blue-300 font-mono text-xs">{input_hash[:24]}...</a>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">Not Analyzed</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 mt-4">No analysis data available for this input.</p>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">Analysis is performed when using recipe-based runs.</p>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
if not run.inputs:
|
||||||
|
analysis_html = '<p class="text-gray-400">No inputs found for this run.</p>'
|
||||||
|
|
||||||
|
content = f'''
|
||||||
|
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to runs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{tabs_html}
|
||||||
|
|
||||||
|
<h2 class="text-xl font-bold text-white mb-4">Analysis Results</h2>
|
||||||
|
{analysis_html}
|
||||||
|
'''
|
||||||
|
|
||||||
|
return HTMLResponse(render_page(f"Analysis: {run_id[:16]}...", content, ctx.actor_id, active_tab="runs"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/run/{run_id}/artifacts", response_class=HTMLResponse)
|
||||||
|
async def run_artifacts_page(run_id: str, request: Request):
|
||||||
|
"""Show all cached artifacts produced by this run."""
|
||||||
|
ctx = await get_user_context_from_cookie(request)
|
||||||
|
if not ctx:
|
||||||
|
content = '<p class="text-gray-400 py-8 text-center">Not logged in.</p>'
|
||||||
|
return HTMLResponse(render_page("Login Required", content, None, active_tab="runs"), status_code=401)
|
||||||
|
|
||||||
|
run = await asyncio.to_thread(load_run, run_id)
|
||||||
|
if not run:
|
||||||
|
content = f'<p class="text-red-400">Run not found: {run_id}</p>'
|
||||||
|
return HTMLResponse(render_page("Not Found", content, ctx.actor_id, active_tab="runs"), status_code=404)
|
||||||
|
|
||||||
|
# Check user owns this run
|
||||||
|
if run.username not in (ctx.username, ctx.actor_id):
|
||||||
|
content = '<p class="text-red-400 py-8 text-center">Access denied.</p>'
|
||||||
|
return HTMLResponse(render_page("Access Denied", content, ctx.actor_id, active_tab="runs"), status_code=403)
|
||||||
|
|
||||||
|
tabs_html = render_run_sub_tabs(run_id, active="artifacts")
|
||||||
|
|
||||||
|
# Collect all artifacts: inputs + output
|
||||||
|
artifacts = []
|
||||||
|
|
||||||
|
# Add inputs
|
||||||
|
for i, content_hash in enumerate(run.inputs):
|
||||||
|
cache_path = get_cache_path(content_hash)
|
||||||
|
if cache_path and cache_path.exists():
|
||||||
|
size = cache_path.stat().st_size
|
||||||
|
media_type = detect_media_type(cache_path)
|
||||||
|
artifacts.append({
|
||||||
|
"hash": content_hash,
|
||||||
|
"path": cache_path,
|
||||||
|
"size": size,
|
||||||
|
"media_type": media_type,
|
||||||
|
"role": "input",
|
||||||
|
"role_color": "blue",
|
||||||
|
"name": f"Input {i + 1}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add output
|
||||||
|
if run.output_hash:
|
||||||
|
cache_path = get_cache_path(run.output_hash)
|
||||||
|
if cache_path and cache_path.exists():
|
||||||
|
size = cache_path.stat().st_size
|
||||||
|
media_type = detect_media_type(cache_path)
|
||||||
|
artifacts.append({
|
||||||
|
"hash": run.output_hash,
|
||||||
|
"path": cache_path,
|
||||||
|
"size": size,
|
||||||
|
"media_type": media_type,
|
||||||
|
"role": "output",
|
||||||
|
"role_color": "green",
|
||||||
|
"name": "Output",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build artifacts HTML
|
||||||
|
artifacts_html = ""
|
||||||
|
for artifact in artifacts:
|
||||||
|
size_kb = artifact["size"] / 1024
|
||||||
|
if size_kb < 1024:
|
||||||
|
size_str = f"{size_kb:.1f} KB"
|
||||||
|
else:
|
||||||
|
size_str = f"{size_kb/1024:.1f} MB"
|
||||||
|
|
||||||
|
# Thumbnail for media
|
||||||
|
thumb = ""
|
||||||
|
if artifact["media_type"] == "video":
|
||||||
|
thumb = f'<video src="/cache/{artifact["hash"]}/raw" class="w-16 h-16 object-cover rounded" muted></video>'
|
||||||
|
elif artifact["media_type"] == "image":
|
||||||
|
thumb = f'<img src="/cache/{artifact["hash"]}/raw" class="w-16 h-16 object-cover rounded" alt="">'
|
||||||
|
else:
|
||||||
|
thumb = '<div class="w-16 h-16 bg-dark-500 rounded flex items-center justify-center text-gray-400 text-xs">File</div>'
|
||||||
|
|
||||||
|
role_color = artifact["role_color"]
|
||||||
|
artifacts_html += f'''
|
||||||
|
<div class="bg-dark-700 rounded-lg p-4 flex items-center gap-4">
|
||||||
|
{thumb}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm text-gray-300 mb-1">{artifact["name"]}</div>
|
||||||
|
<a href="/cache/{artifact["hash"]}" class="text-blue-400 hover:text-blue-300 font-mono text-xs truncate block">{artifact["hash"][:32]}...</a>
|
||||||
|
<div class="flex gap-4 mt-1 text-xs text-gray-400">
|
||||||
|
<span>{size_str}</span>
|
||||||
|
<span>{artifact["media_type"]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-1 bg-{role_color}-600 text-white text-xs rounded-full">{artifact["role"]}</span>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
if not artifacts:
|
||||||
|
artifacts_html = '<p class="text-gray-400">No cached artifacts found for this run.</p>'
|
||||||
|
|
||||||
|
content = f'''
|
||||||
|
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back to runs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{tabs_html}
|
||||||
|
|
||||||
|
<h2 class="text-xl font-bold text-white mb-4">Cached Artifacts</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{artifacts_html}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
return HTMLResponse(render_page(f"Artifacts: {run_id[:16]}...", content, ctx.actor_id, active_tab="runs"))
|
||||||
|
|
||||||
|
|
||||||
|
# JSON API endpoints for future WebSocket support
|
||||||
|
@app.get("/api/run/{run_id}/plan")
|
||||||
|
async def api_run_plan(run_id: str, request: Request):
|
||||||
|
"""Get execution plan data as JSON for programmatic access."""
|
||||||
|
ctx = await get_user_context_from_cookie(request)
|
||||||
|
if not ctx:
|
||||||
|
raise HTTPException(401, "Not logged in")
|
||||||
|
|
||||||
|
run = await asyncio.to_thread(load_run, run_id)
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(404, f"Run {run_id} not found")
|
||||||
|
|
||||||
|
if run.username not in (ctx.username, ctx.actor_id):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
|
||||||
|
# Look for plan in cache
|
||||||
|
PLAN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
for plan_file in PLAN_CACHE_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(plan_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
plan_inputs = data.get("input_hashes", {})
|
||||||
|
if set(plan_inputs.values()) == set(run.inputs):
|
||||||
|
return data
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {"status": "not_found", "message": "No plan found for this run"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/run/{run_id}/analysis")
|
||||||
|
async def api_run_analysis(run_id: str, request: Request):
|
||||||
|
"""Get analysis data as JSON for programmatic access."""
|
||||||
|
ctx = await get_user_context_from_cookie(request)
|
||||||
|
if not ctx:
|
||||||
|
raise HTTPException(401, "Not logged in")
|
||||||
|
|
||||||
|
run = await asyncio.to_thread(load_run, run_id)
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(404, f"Run {run_id} not found")
|
||||||
|
|
||||||
|
if run.username not in (ctx.username, ctx.actor_id):
|
||||||
|
raise HTTPException(403, "Access denied")
|
||||||
|
|
||||||
|
ANALYSIS_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
results = {}
|
||||||
|
for input_hash in run.inputs:
|
||||||
|
analysis_path = ANALYSIS_CACHE_DIR / f"{input_hash}.json"
|
||||||
|
if analysis_path.exists():
|
||||||
|
try:
|
||||||
|
with open(analysis_path) as f:
|
||||||
|
results[input_hash] = json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
results[input_hash] = None
|
||||||
|
else:
|
||||||
|
results[input_hash] = None
|
||||||
|
|
||||||
|
return {"run_id": run_id, "inputs": run.inputs, "analysis": results}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/runs")
|
@app.get("/runs")
|
||||||
async def list_runs(request: Request, page: int = 1, limit: int = 20):
|
async def list_runs(request: Request, page: int = 1, limit: int = 20):
|
||||||
"""List runs. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
|
"""List runs. HTML for browsers (with infinite scroll), JSON for APIs (with pagination)."""
|
||||||
@@ -1868,11 +2352,12 @@ async def recipe_detail_page(recipe_id: str, request: Request):
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-dark-700 rounded-lg p-6 mb-6">
|
<div class="bg-dark-700 rounded-lg p-6 mb-6">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
<h2 class="text-2xl font-bold text-white">{recipe.name}</h2>
|
<h2 class="text-2xl font-bold text-white">{recipe.name}</h2>
|
||||||
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">v{recipe.version}</span>
|
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">v{recipe.version}</span>
|
||||||
{pinned_badge}
|
{pinned_badge}
|
||||||
{l2_link_html}
|
{l2_link_html}
|
||||||
|
<a href="/recipe/{recipe.recipe_id}/dag" class="px-2 py-1 bg-purple-600 text-white text-xs rounded-full hover:bg-purple-700 transition-colors">View DAG</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-400 mb-4">{recipe.description or 'No description'}</p>
|
<p class="text-gray-400 mb-4">{recipe.description or 'No description'}</p>
|
||||||
<div class="text-xs text-gray-500 font-mono mb-4">{recipe.recipe_id}</div>
|
<div class="text-xs text-gray-500 font-mono mb-4">{recipe.recipe_id}</div>
|
||||||
@@ -1900,6 +2385,131 @@ async def recipe_detail_page(recipe_id: str, request: Request):
|
|||||||
return HTMLResponse(render_page(f"Recipe: {recipe.name}", content, ctx.actor_id if ctx else None, active_tab="recipes"))
|
return HTMLResponse(render_page(f"Recipe: {recipe.name}", content, ctx.actor_id if ctx else None, active_tab="recipes"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/recipe/{recipe_id}/dag", response_class=HTMLResponse)
|
||||||
|
async def recipe_dag_visualization(recipe_id: str, request: Request):
|
||||||
|
"""Visualize recipe structure as DAG."""
|
||||||
|
ctx = await get_user_context_from_cookie(request)
|
||||||
|
recipe = load_recipe(recipe_id)
|
||||||
|
|
||||||
|
if not recipe:
|
||||||
|
return HTMLResponse(render_page_with_cytoscape(
|
||||||
|
"Recipe Not Found",
|
||||||
|
f'<p class="text-red-400">Recipe {recipe_id} not found.</p>',
|
||||||
|
ctx.actor_id if ctx else None,
|
||||||
|
active_tab="recipes"
|
||||||
|
), status_code=404)
|
||||||
|
|
||||||
|
# Load recipe YAML
|
||||||
|
recipe_path = cache_manager.get_by_content_hash(recipe_id)
|
||||||
|
if not recipe_path or not recipe_path.exists():
|
||||||
|
return HTMLResponse(render_page_with_cytoscape(
|
||||||
|
"Recipe Not Found",
|
||||||
|
'<p class="text-red-400">Recipe file not found in cache.</p>',
|
||||||
|
ctx.actor_id if ctx else None,
|
||||||
|
active_tab="recipes"
|
||||||
|
), status_code=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe_yaml = recipe_path.read_text()
|
||||||
|
config = yaml.safe_load(recipe_yaml)
|
||||||
|
except Exception as e:
|
||||||
|
return HTMLResponse(render_page_with_cytoscape(
|
||||||
|
"Error",
|
||||||
|
f'<p class="text-red-400">Failed to parse recipe: {e}</p>',
|
||||||
|
ctx.actor_id if ctx else None,
|
||||||
|
active_tab="recipes"
|
||||||
|
), status_code=500)
|
||||||
|
|
||||||
|
dag_config = config.get("dag", {})
|
||||||
|
dag_nodes = dag_config.get("nodes", [])
|
||||||
|
output_node = dag_config.get("output")
|
||||||
|
|
||||||
|
# Build Cytoscape nodes and edges
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
|
||||||
|
for node_def in dag_nodes:
|
||||||
|
node_id = node_def.get("id", "")
|
||||||
|
node_type = node_def.get("type", "EFFECT")
|
||||||
|
node_config = node_def.get("config", {})
|
||||||
|
input_names = node_def.get("inputs", [])
|
||||||
|
|
||||||
|
# Determine if this is the output node
|
||||||
|
is_output = node_id == output_node
|
||||||
|
if is_output:
|
||||||
|
color = NODE_COLORS.get("OUTPUT", NODE_COLORS["default"])
|
||||||
|
else:
|
||||||
|
color = NODE_COLORS.get(node_type, NODE_COLORS["default"])
|
||||||
|
|
||||||
|
# Get effect name if it's an effect node
|
||||||
|
label = node_id
|
||||||
|
if node_type == "EFFECT" and "effect" in node_config:
|
||||||
|
label = node_config["effect"]
|
||||||
|
|
||||||
|
nodes.append({
|
||||||
|
"data": {
|
||||||
|
"id": node_id,
|
||||||
|
"label": label,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"isOutput": is_output,
|
||||||
|
"color": color,
|
||||||
|
"config": node_config
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create edges from inputs
|
||||||
|
for input_name in input_names:
|
||||||
|
edges.append({
|
||||||
|
"data": {
|
||||||
|
"source": input_name,
|
||||||
|
"target": node_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nodes_json = json.dumps(nodes)
|
||||||
|
edges_json = json.dumps(edges)
|
||||||
|
|
||||||
|
dag_html = render_dag_cytoscape(nodes_json, edges_json)
|
||||||
|
|
||||||
|
content = f'''
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/recipe/{recipe_id}" class="text-blue-400 hover:text-blue-300 text-sm">← Back to recipe</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-dark-700 rounded-lg p-6 mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-white">{recipe.name}</h2>
|
||||||
|
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">v{recipe.version}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 mb-4">{recipe.description or 'No description'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-dark-700 rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">DAG Structure</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex gap-4 text-sm flex-wrap">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded" style="background-color: #3b82f6"></span> SOURCE
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded" style="background-color: #22c55e"></span> EFFECT
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded" style="background-color: #a855f7"></span> OUTPUT
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-400 mb-4">Click on a node to see its configuration. The purple-bordered node is the output.</p>
|
||||||
|
|
||||||
|
{dag_html}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
return HTMLResponse(render_page_with_cytoscape(f"DAG: {recipe.name}", content, ctx.actor_id if ctx else None, active_tab="recipes"))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/ui/recipes/{recipe_id}/run", response_class=HTMLResponse)
|
@app.post("/ui/recipes/{recipe_id}/run", response_class=HTMLResponse)
|
||||||
async def ui_run_recipe(recipe_id: str, request: Request):
|
async def ui_run_recipe(recipe_id: str, request: Request):
|
||||||
"""HTMX handler: run a recipe with form inputs."""
|
"""HTMX handler: run a recipe with form inputs."""
|
||||||
@@ -3803,6 +4413,228 @@ TAILWIND_CONFIG = '''
|
|||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
# Cytoscape.js for DAG visualization (extends TAILWIND_CONFIG)
|
||||||
|
CYTOSCAPE_CONFIG = TAILWIND_CONFIG + '''
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Node colors for DAG visualization
|
||||||
|
NODE_COLORS = {
|
||||||
|
"SOURCE": "#3b82f6", # Blue
|
||||||
|
"EFFECT": "#22c55e", # Green
|
||||||
|
"OUTPUT": "#a855f7", # Purple
|
||||||
|
"ANALYSIS": "#f59e0b", # Amber
|
||||||
|
"_LIST": "#6366f1", # Indigo
|
||||||
|
"default": "#6b7280" # Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_run_sub_tabs(run_id: str, active: str = "overview") -> str:
|
||||||
|
"""Render sub-navigation tabs for run detail pages."""
|
||||||
|
tabs = [
|
||||||
|
("overview", "Overview", f"/run/{run_id}"),
|
||||||
|
("plan", "Plan", f"/run/{run_id}/plan"),
|
||||||
|
("analysis", "Analysis", f"/run/{run_id}/analysis"),
|
||||||
|
("artifacts", "Artifacts", f"/run/{run_id}/artifacts"),
|
||||||
|
]
|
||||||
|
|
||||||
|
html = '<div class="flex gap-4 mb-6 border-b border-dark-500">'
|
||||||
|
for tab_id, label, url in tabs:
|
||||||
|
if tab_id == active:
|
||||||
|
active_class = "border-b-2 border-blue-500 text-white"
|
||||||
|
else:
|
||||||
|
active_class = "text-gray-400 hover:text-white"
|
||||||
|
html += f'<a href="{url}" class="pb-3 px-2 font-medium transition-colors {active_class}">{label}</a>'
|
||||||
|
html += '</div>'
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "cy") -> str:
|
||||||
|
"""Render Cytoscape.js DAG visualization HTML with WebSocket-ready architecture."""
|
||||||
|
return f'''
|
||||||
|
<div id="{container_id}" class="w-full h-96 bg-dark-800 rounded-lg border border-dark-500"></div>
|
||||||
|
<div id="{container_id}-details" class="mt-4 p-4 bg-dark-700 rounded-lg hidden">
|
||||||
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Node Details</h4>
|
||||||
|
<div id="{container_id}-node-info" class="text-gray-200 font-mono text-xs space-y-1"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {{
|
||||||
|
// Register dagre layout
|
||||||
|
if (typeof cytoscape !== 'undefined' && typeof cytoscapeDagre !== 'undefined') {{
|
||||||
|
cytoscape.use(cytoscapeDagre);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Global instance for future WebSocket updates
|
||||||
|
window.artdagCy = cytoscape({{
|
||||||
|
container: document.getElementById('{container_id}'),
|
||||||
|
elements: {{
|
||||||
|
nodes: {nodes_json},
|
||||||
|
edges: {edges_json}
|
||||||
|
}},
|
||||||
|
style: [
|
||||||
|
{{
|
||||||
|
selector: 'node',
|
||||||
|
style: {{
|
||||||
|
'label': 'data(label)',
|
||||||
|
'text-valign': 'center',
|
||||||
|
'text-halign': 'center',
|
||||||
|
'background-color': 'data(color)',
|
||||||
|
'color': '#fff',
|
||||||
|
'font-size': '10px',
|
||||||
|
'text-outline-color': '#000',
|
||||||
|
'text-outline-width': 1,
|
||||||
|
'width': 80,
|
||||||
|
'height': 40,
|
||||||
|
'shape': 'roundrectangle',
|
||||||
|
'border-width': 2,
|
||||||
|
'border-color': '#444'
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
selector: 'node[status="cached"]',
|
||||||
|
style: {{
|
||||||
|
'border-color': '#22c55e',
|
||||||
|
'border-width': 3
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
selector: 'node[status="running"]',
|
||||||
|
style: {{
|
||||||
|
'border-color': '#eab308',
|
||||||
|
'border-width': 3
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
selector: 'node[isOutput]',
|
||||||
|
style: {{
|
||||||
|
'border-color': '#a855f7',
|
||||||
|
'border-width': 3
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
selector: 'edge',
|
||||||
|
style: {{
|
||||||
|
'width': 2,
|
||||||
|
'line-color': '#666',
|
||||||
|
'target-arrow-color': '#666',
|
||||||
|
'target-arrow-shape': 'triangle',
|
||||||
|
'curve-style': 'bezier'
|
||||||
|
}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
selector: ':selected',
|
||||||
|
style: {{
|
||||||
|
'border-color': '#3b82f6',
|
||||||
|
'border-width': 4
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
layout: {{
|
||||||
|
name: 'dagre',
|
||||||
|
rankDir: 'TB',
|
||||||
|
nodeSep: 50,
|
||||||
|
rankSep: 80,
|
||||||
|
padding: 20
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
window.artdagCy.on('tap', 'node', function(evt) {{
|
||||||
|
var node = evt.target;
|
||||||
|
var details = document.getElementById('{container_id}-details');
|
||||||
|
var info = document.getElementById('{container_id}-node-info');
|
||||||
|
details.classList.remove('hidden');
|
||||||
|
|
||||||
|
var configStr = node.data('config') ? JSON.stringify(node.data('config'), null, 2) : 'N/A';
|
||||||
|
info.innerHTML =
|
||||||
|
'<div><span class="text-gray-400">ID:</span> ' + node.data('id') + '</div>' +
|
||||||
|
'<div><span class="text-gray-400">Type:</span> ' + (node.data('nodeType') || 'N/A') + '</div>' +
|
||||||
|
'<div><span class="text-gray-400">Level:</span> ' + (node.data('level') !== undefined ? node.data('level') : 'N/A') + '</div>' +
|
||||||
|
'<div><span class="text-gray-400">Cache ID:</span> ' + (node.data('cacheId') || 'N/A') + '</div>' +
|
||||||
|
'<div><span class="text-gray-400">Status:</span> ' + (node.data('status') || 'N/A') + '</div>' +
|
||||||
|
(node.data('config') ? '<div class="mt-2"><span class="text-gray-400">Config:</span><pre class="mt-1 text-xs bg-dark-600 p-2 rounded overflow-x-auto">' + configStr + '</pre></div>' : '');
|
||||||
|
}});
|
||||||
|
|
||||||
|
window.artdagCy.on('tap', function(evt) {{
|
||||||
|
if (evt.target === window.artdagCy) {{
|
||||||
|
document.getElementById('{container_id}-details').classList.add('hidden');
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Future WebSocket update function
|
||||||
|
window.updateNodeStatus = function(stepId, status, cacheId) {{
|
||||||
|
if (!window.artdagCy) return;
|
||||||
|
var node = window.artdagCy.getElementById(stepId);
|
||||||
|
if (node && node.length) {{
|
||||||
|
node.data('status', status);
|
||||||
|
if (cacheId) node.data('cacheId', cacheId);
|
||||||
|
}}
|
||||||
|
}};
|
||||||
|
}});
|
||||||
|
</script>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_with_cytoscape(title: str, content: str, actor_id: Optional[str] = None, active_tab: str = None) -> str:
|
||||||
|
"""Render a page with Cytoscape.js support for DAG visualization."""
|
||||||
|
user_info = ""
|
||||||
|
if actor_id:
|
||||||
|
parts = actor_id.lstrip("@").split("@")
|
||||||
|
username = parts[0] if parts else actor_id
|
||||||
|
domain = parts[1] if len(parts) > 1 else ""
|
||||||
|
l2_user_url = f"https://{domain}/users/{username}" if domain else "#"
|
||||||
|
user_info = f'''
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-400">
|
||||||
|
Logged in as <a href="{l2_user_url}" class="text-white hover:text-blue-300">{actor_id}</a>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
else:
|
||||||
|
user_info = '''
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
Not logged in
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
runs_active = "border-b-2 border-blue-500 text-white" if active_tab == "runs" else "text-gray-400 hover:text-white"
|
||||||
|
recipes_active = "border-b-2 border-blue-500 text-white" if active_tab == "recipes" else "text-gray-400 hover:text-white"
|
||||||
|
media_active = "border-b-2 border-blue-500 text-white" if active_tab == "media" else "text-gray-400 hover:text-white"
|
||||||
|
storage_active = "border-b-2 border-blue-500 text-white" if active_tab == "storage" else "text-gray-400 hover:text-white"
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{title} | Art DAG L1 Server</title>
|
||||||
|
{CYTOSCAPE_CONFIG}
|
||||||
|
</head>
|
||||||
|
<body class="bg-dark-900 text-gray-100 min-h-screen">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<header class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
<a href="/" class="text-white hover:text-gray-200">Art DAG L1 Server</a>
|
||||||
|
</h1>
|
||||||
|
{user_info}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="flex gap-6 mb-6 border-b border-dark-500 pb-0">
|
||||||
|
<a href="/runs" class="pb-3 px-1 font-medium transition-colors {runs_active}">Runs</a>
|
||||||
|
<a href="/recipes" class="pb-3 px-1 font-medium transition-colors {recipes_active}">Recipes</a>
|
||||||
|
<a href="/media" class="pb-3 px-1 font-medium transition-colors {media_active}">Media</a>
|
||||||
|
<a href="/storage" class="pb-3 px-1 font-medium transition-colors {storage_active}">Storage</a>
|
||||||
|
<a href="/download/client" class="pb-3 px-1 font-medium transition-colors text-gray-400 hover:text-white ml-auto" title="Download CLI client">Download Client</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{content}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def render_page(title: str, content: str, actor_id: Optional[str] = None, active_tab: str = None) -> str:
|
def render_page(title: str, content: str, actor_id: Optional[str] = None, active_tab: str = None) -> str:
|
||||||
"""Render a page with nav bar and content. Used for clean URL pages.
|
"""Render a page with nav bar and content. Used for clean URL pages.
|
||||||
|
|||||||
Reference in New Issue
Block a user