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:
giles
2026-01-10 23:27:28 +00:00
parent 1feecc8c05
commit e7d3a5ed6c

834
server.py
View File

@@ -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>'''
# Sub-navigation tabs for run detail pages
sub_tabs_html = render_run_sub_tabs(run_id, active="overview")
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">
@@ -1220,6 +1223,8 @@ async def run_detail(run_id: str, request: Request):
Back to runs
</a>
{sub_tabs_html}
<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 items-center gap-3">
@@ -1279,6 +1284,485 @@ async def run_detail(run_id: str, request: Request):
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")
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)."""
@@ -1868,11 +2352,12 @@ async def recipe_detail_page(recipe_id: str, request: Request):
</div>
<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>
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">v{recipe.version}</span>
{pinned_badge}
{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>
<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>
@@ -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"))
@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">&larr; 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)
async def ui_run_recipe(recipe_id: str, request: Request):
"""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>
'''
# 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:
"""Render a page with nav bar and content. Used for clean URL pages.