Add SPA-style navigation for plan nodes

- Add /run/{run_id}/plan/node/{step_id} endpoint for node details
- Node click updates URL without full page reload (pushState)
- Browser back/forward works correctly
- Refreshing page preserves selected node via ?node= parameter
- Node details loaded via fetch with partial HTML response

🤖 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-11 09:30:21 +00:00
parent 0634142576
commit f11cec9d48

321
server.py
View File

@@ -1335,8 +1335,159 @@ PLAN_CACHE_DIR = CACHE_DIR / 'plans'
ANALYSIS_CACHE_DIR = CACHE_DIR / 'analysis'
@app.get("/run/{run_id}/plan/node/{step_id}", response_class=HTMLResponse)
async def run_plan_node_detail(run_id: str, step_id: str, request: Request):
"""HTMX partial: Get node detail HTML fragment."""
ctx = await get_user_context_from_cookie(request)
if not ctx:
return HTMLResponse('<p class="text-red-400">Login required</p>', status_code=401)
run = await asyncio.to_thread(load_run, run_id)
if not run:
return HTMLResponse(f'<p class="text-red-400">Run not found</p>', status_code=404)
# Load plan data
plan_data = None
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):
plan_data = data
break
except (json.JSONDecodeError, IOError):
continue
if not plan_data:
return HTMLResponse('<p class="text-gray-400">Plan not found</p>')
# Find the step
step = None
for s in plan_data.get("steps", []):
if s.get("step_id") == step_id:
step = s
break
if not step:
return HTMLResponse(f'<p class="text-gray-400">Step {step_id} not found</p>')
# Get step info
step_name = step.get("name", step_id[:20])
node_type = step.get("node_type", "EFFECT")
cache_id = step.get("cache_id", "")
config = step.get("config", {})
level = step.get("level", 0)
input_steps = step.get("input_steps", [])
# Check for IPFS CID
step_cid = None
if run.step_results:
res = run.step_results.get(step_id)
if isinstance(res, dict) and res.get("cid"):
step_cid = res["cid"]
has_cached = cache_manager.has_content(cache_id) if cache_id else False
color = NODE_COLORS.get(node_type, NODE_COLORS["default"])
# Build preview HTML
preview_html = ""
if has_cached and cache_id:
media_type = detect_media_type(get_cache_path(cache_id))
if media_type == "video":
preview_html = f'''
<div class="mb-4">
<video src="/cache/{cache_id}/raw" controls muted class="w-full max-h-64 rounded-lg"></video>
</div>'''
elif media_type == "image":
preview_html = f'''
<div class="mb-4">
<img src="/cache/{cache_id}/raw" class="w-full max-h-64 rounded-lg object-contain">
</div>'''
elif step_cid:
ipfs_gateway = IPFS_GATEWAY_URL.rstrip('/') if IPFS_GATEWAY_URL else "https://ipfs.io/ipfs"
preview_html = f'''
<div class="mb-4">
<video src="{ipfs_gateway}/{step_cid}" controls muted class="w-full max-h-64 rounded-lg"></video>
</div>'''
# Build output link
output_html = ""
if step_cid:
output_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output (IPFS)</h5>
<a href="/ipfs/{step_cid}" class="flex items-center justify-between bg-dark-600 rounded p-3 hover:bg-dark-500 transition-colors">
<span class="font-mono text-xs text-gray-300 truncate">{step_cid}</span>
<span class="px-3 py-1 bg-blue-600 text-white text-xs rounded ml-2">View</span>
</a>
</div>'''
elif has_cached and cache_id:
output_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output</h5>
<a href="/cache/{cache_id}" class="flex items-center justify-between bg-dark-600 rounded p-3 hover:bg-dark-500 transition-colors">
<span class="font-mono text-xs text-gray-300 truncate">{cache_id}</span>
<span class="px-3 py-1 bg-blue-600 text-white text-xs rounded ml-2">View</span>
</a>
</div>'''
# Config display
config_html = ""
if config:
config_json = json.dumps(config, indent=2)
config_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Config</h5>
<pre class="text-xs bg-dark-600 p-3 rounded overflow-x-auto max-h-40">{config_json}</pre>
</div>'''
# Input steps
inputs_html = ""
if input_steps:
inputs_list = "".join([
f'<span class="px-2 py-1 bg-dark-500 rounded text-xs font-mono">{inp[:16]}...</span>'
for inp in input_steps
])
inputs_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Input Steps</h5>
<div class="flex flex-wrap gap-2">{inputs_list}</div>
</div>'''
status = "cached" if (has_cached or step_cid) else ("completed" if run.status == "completed" else "pending")
status_color = "green" if status in ("cached", "completed") else "yellow"
return HTMLResponse(f'''
<div class="flex justify-between items-start mb-4">
<div>
<h4 class="text-lg font-semibold text-white">{step_name}</h4>
<div class="flex items-center gap-2 mt-1">
<span class="px-2 py-0.5 rounded text-xs" style="background-color: {color}; color: white">{node_type}</span>
<span class="text-{status_color}-400 text-xs">{status}</span>
<span class="text-gray-500 text-xs">Level {level}</span>
</div>
</div>
<button onclick="closeNodeDetail()" class="text-gray-400 hover:text-white p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{preview_html}
<div class="text-sm space-y-2">
<div><span class="text-gray-400">Step ID:</span> <span class="font-mono text-xs">{step_id}</span></div>
<div><span class="text-gray-400">Cache ID:</span> <span class="font-mono text-xs">{cache_id[:32] if cache_id else "N/A"}...</span></div>
</div>
{output_html}
{inputs_html}
{config_html}
''')
@app.get("/run/{run_id}/plan", response_class=HTMLResponse)
async def run_plan_visualization(run_id: str, request: Request):
async def run_plan_visualization(run_id: str, request: Request, node: Optional[str] = None):
"""Visualize execution plan as interactive DAG."""
ctx = await get_user_context_from_cookie(request)
if not ctx:
@@ -1504,7 +1655,7 @@ async def run_plan_visualization(run_id: str, request: Request):
nodes_json = json.dumps(nodes)
edges_json = json.dumps(edges)
dag_html = render_dag_cytoscape(nodes_json, edges_json)
dag_html = render_dag_cytoscape(nodes_json, edges_json, run_id=run_id, initial_node=node or "")
# Stats summary - count from built nodes to reflect actual execution status
total = len(nodes)
@@ -4712,34 +4863,82 @@ def render_run_sub_tabs(run_id: str, active: str = "overview") -> str:
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."""
def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "cy", run_id: str = "", initial_node: str = "") -> str:
"""Render Cytoscape.js DAG visualization HTML with HTMX SPA-style navigation."""
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">
<div class="flex justify-between items-start mb-3">
<div>
<h4 id="{container_id}-node-name" class="text-lg font-semibold text-white"></h4>
<span id="{container_id}-node-type" class="text-xs text-gray-400"></span>
</div>
<button id="{container_id}-close-btn" class="text-gray-400 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="{container_id}-artifact-preview" class="mb-4 hidden">
<div class="bg-dark-800 rounded-lg overflow-hidden">
<video id="{container_id}-video-player" class="w-full max-h-64" controls muted></video>
</div>
</div>
<div id="{container_id}-node-info" class="text-gray-200 font-mono text-xs space-y-1"></div>
<div id="{container_id}-outputs-list" class="mt-4 hidden">
<h5 class="text-sm font-medium text-gray-400 mb-2">Outputs</h5>
<div id="{container_id}-outputs-container" class="space-y-2"></div>
<div id="{container_id}-details" class="mt-4 p-4 bg-dark-700 rounded-lg {"" if initial_node else "hidden"}">
<div id="{container_id}-details-content">
{"Loading..." if initial_node else ""}
</div>
</div>
<script>
// SPA-style node detail functions
var currentRunId = '{run_id}';
var currentNodeId = '{initial_node}';
function loadNodeDetail(stepId) {{
if (!currentRunId || !stepId) return;
currentNodeId = stepId;
var detailsEl = document.getElementById('{container_id}-details');
var contentEl = document.getElementById('{container_id}-details-content');
detailsEl.classList.remove('hidden');
contentEl.innerHTML = '<div class="text-gray-400">Loading...</div>';
// Update URL without full page reload
var newUrl = '/run/' + currentRunId + '/plan?node=' + encodeURIComponent(stepId);
history.pushState({{ node: stepId }}, '', newUrl);
// Fetch node details via HTMX-style fetch
fetch('/run/' + currentRunId + '/plan/node/' + encodeURIComponent(stepId))
.then(function(response) {{ return response.text(); }})
.then(function(html) {{
contentEl.innerHTML = html;
// Trigger HTMX to process any dynamic content
if (typeof htmx !== 'undefined') {{
htmx.process(contentEl);
}}
}})
.catch(function(err) {{
contentEl.innerHTML = '<p class="text-red-400">Error loading node details</p>';
}});
// Select the node in cytoscape
if (window.artdagCy) {{
window.artdagCy.nodes().unselect();
var node = window.artdagCy.getElementById(stepId);
if (node && node.length) {{
node.select();
}}
}}
}}
function closeNodeDetail() {{
var detailsEl = document.getElementById('{container_id}-details');
detailsEl.classList.add('hidden');
currentNodeId = '';
// Update URL to remove node parameter
var newUrl = '/run/' + currentRunId + '/plan';
history.pushState({{ node: null }}, '', newUrl);
// Unselect in cytoscape
if (window.artdagCy) {{
window.artdagCy.nodes().unselect();
}}
}}
// Handle browser back/forward
window.addEventListener('popstate', function(event) {{
if (event.state && event.state.node) {{
loadNodeDetail(event.state.node);
}} else {{
closeNodeDetail();
}}
}});
document.addEventListener('DOMContentLoaded', function() {{
// Register dagre layout
if (typeof cytoscape !== 'undefined' && typeof cytoscapeDagre !== 'undefined') {{
@@ -4827,77 +5026,25 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
}}
}});
// Close button handler
document.getElementById('{container_id}-close-btn').addEventListener('click', function() {{
document.getElementById('{container_id}-details').classList.add('hidden');
var player = document.getElementById('{container_id}-video-player');
if (player) player.pause();
}});
// Node click handler - SPA style with URL update
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');
var nameEl = document.getElementById('{container_id}-node-name');
var typeEl = document.getElementById('{container_id}-node-type');
var previewEl = document.getElementById('{container_id}-artifact-preview');
var videoPlayer = document.getElementById('{container_id}-video-player');
var outputsList = document.getElementById('{container_id}-outputs-list');
var outputsContainer = document.getElementById('{container_id}-outputs-container');
details.classList.remove('hidden');
// Set name and type
var nodeName = node.data('name') || node.data('label') || node.data('id');
nameEl.textContent = nodeName;
typeEl.textContent = node.data('nodeType') || '';
// Handle artifact preview
var cacheId = node.data('cacheId');
var status = node.data('status');
var hasCached = node.data('hasCached');
// Show video preview if artifact is cached
if (hasCached && cacheId) {{
previewEl.classList.remove('hidden');
videoPlayer.src = '/cache/' + cacheId + '/raw';
}} else {{
previewEl.classList.add('hidden');
videoPlayer.src = '';
}}
// Show artifact link if cached
if (hasCached && cacheId) {{
outputsList.classList.remove('hidden');
outputsContainer.innerHTML = '<div class="flex items-center justify-between bg-dark-600 rounded p-2">' +
'<div class="flex-1 min-w-0">' +
'<div class="text-sm text-gray-200">Output Artifact</div>' +
'<div class="text-xs text-gray-500 font-mono truncate">' + cacheId.substring(0, 16) + '...</div>' +
'</div>' +
'<a href="/cache/' + cacheId + '" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">View</a>' +
'</div>';
}} else {{
outputsList.classList.add('hidden');
}}
// Show basic info
var configStr = node.data('config') ? JSON.stringify(node.data('config'), null, 2) : null;
info.innerHTML =
'<div><span class="text-gray-400">ID:</span> ' + node.data('id').substring(0, 24) + '...</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> ' + (cacheId ? cacheId.substring(0, 24) + '...' : 'N/A') + '</div>' +
'<div><span class="text-gray-400">Status:</span> <span class="' + (status === 'cached' || status === 'completed' ? 'text-green-400' : 'text-yellow-400') + '">' + (status || 'N/A') + '</span></div>' +
(configStr ? '<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 max-h-32">' + configStr + '</pre></div>' : '');
var stepId = node.data('id');
loadNodeDetail(stepId);
}});
// Click on background closes detail
window.artdagCy.on('tap', function(evt) {{
if (evt.target === window.artdagCy) {{
document.getElementById('{container_id}-details').classList.add('hidden');
var player = document.getElementById('{container_id}-video-player');
if (player) player.pause();
closeNodeDetail();
}}
}});
// Load initial node from URL if specified
if (currentNodeId) {{
loadNodeDetail(currentNodeId);
}}
// WebSocket update function for real-time status updates
window.updateNodeStatus = function(stepId, status, cacheId, hasCached) {{
if (!window.artdagCy) return;