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:
321
server.py
321
server.py
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user