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'
|
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)
|
@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."""
|
"""Visualize execution plan as interactive DAG."""
|
||||||
ctx = await get_user_context_from_cookie(request)
|
ctx = await get_user_context_from_cookie(request)
|
||||||
if not ctx:
|
if not ctx:
|
||||||
@@ -1504,7 +1655,7 @@ async def run_plan_visualization(run_id: str, request: Request):
|
|||||||
nodes_json = json.dumps(nodes)
|
nodes_json = json.dumps(nodes)
|
||||||
edges_json = json.dumps(edges)
|
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
|
# Stats summary - count from built nodes to reflect actual execution status
|
||||||
total = len(nodes)
|
total = len(nodes)
|
||||||
@@ -4712,34 +4863,82 @@ def render_run_sub_tabs(run_id: str, active: str = "overview") -> str:
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "cy") -> str:
|
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 WebSocket-ready architecture."""
|
"""Render Cytoscape.js DAG visualization HTML with HTMX SPA-style navigation."""
|
||||||
return f'''
|
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}" 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 id="{container_id}-details" class="mt-4 p-4 bg-dark-700 rounded-lg {"" if initial_node else "hidden"}">
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div id="{container_id}-details-content">
|
||||||
<div>
|
{"Loading..." if initial_node else ""}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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() {{
|
document.addEventListener('DOMContentLoaded', function() {{
|
||||||
// Register dagre layout
|
// Register dagre layout
|
||||||
if (typeof cytoscape !== 'undefined' && typeof cytoscapeDagre !== 'undefined') {{
|
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
|
// Node click handler - SPA style with URL update
|
||||||
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();
|
|
||||||
}});
|
|
||||||
|
|
||||||
window.artdagCy.on('tap', 'node', function(evt) {{
|
window.artdagCy.on('tap', 'node', function(evt) {{
|
||||||
var node = evt.target;
|
var node = evt.target;
|
||||||
var details = document.getElementById('{container_id}-details');
|
var stepId = node.data('id');
|
||||||
var info = document.getElementById('{container_id}-node-info');
|
loadNodeDetail(stepId);
|
||||||
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>' : '');
|
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
// Click on background closes detail
|
||||||
window.artdagCy.on('tap', function(evt) {{
|
window.artdagCy.on('tap', function(evt) {{
|
||||||
if (evt.target === window.artdagCy) {{
|
if (evt.target === window.artdagCy) {{
|
||||||
document.getElementById('{container_id}-details').classList.add('hidden');
|
closeNodeDetail();
|
||||||
var player = document.getElementById('{container_id}-video-player');
|
|
||||||
if (player) player.pause();
|
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
// Load initial node from URL if specified
|
||||||
|
if (currentNodeId) {{
|
||||||
|
loadNodeDetail(currentNodeId);
|
||||||
|
}}
|
||||||
|
|
||||||
// WebSocket update function for real-time status updates
|
// WebSocket update function for real-time status updates
|
||||||
window.updateNodeStatus = function(stepId, status, cacheId, hasCached) {{
|
window.updateNodeStatus = function(stepId, status, cacheId, hasCached) {{
|
||||||
if (!window.artdagCy) return;
|
if (!window.artdagCy) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user