Add interactive plan node selection with media previews

- Click plan nodes (in DAG or list) to see details in side panel
- URL updates to #node-{id} for direct linking
- Node detail panel shows: type, status, inputs, output, config
- Inputs can be clicked to navigate to that node
- Inputs tab now shows media previews (image/video/audio)
- Steps include config data for display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-11 23:30:43 +00:00
parent d73592fbe2
commit 8e70a9b9f2
2 changed files with 275 additions and 19 deletions

View File

@@ -133,6 +133,7 @@ async def get_run(
"type": node.get("type", "EFFECT"),
"status": "completed", # Run completed
"inputs": node.get("inputs", []),
"config": node.get("config", {}),
})
elif isinstance(nodes, dict):
for node_id, node in nodes.items():
@@ -142,6 +143,7 @@ async def get_run(
"type": node.get("type", "EFFECT"),
"status": "completed",
"inputs": node.get("inputs", []),
"config": node.get("config", {}),
})
if steps:
@@ -174,6 +176,25 @@ async def get_run(
"media_type": media_type or "application/octet-stream",
})
# Build inputs list with media types
run_inputs = []
if run.get("inputs"):
import mimetypes
cache_manager = get_cache_manager()
for i, input_hash in enumerate(run["inputs"]):
media_type = None
try:
cache_path = cache_manager.get_path(input_hash)
if cache_path and cache_path.exists():
media_type, _ = mimetypes.guess_type(str(cache_path))
except Exception:
pass
run_inputs.append({
"hash": input_hash,
"name": f"Input {i + 1}",
"media_type": media_type,
})
# Build DAG elements for visualization
dag_elements = []
if plan and plan.get("steps"):
@@ -209,6 +230,7 @@ async def get_run(
run=run,
plan=plan,
artifacts=artifacts,
run_inputs=run_inputs,
dag_elements=dag_elements,
active_tab="runs",
)

View File

@@ -77,12 +77,57 @@
<!-- Plan Tab -->
<div id="tab-plan" class="tab-content">
{% if plan %}
<div id="dag-container" class="bg-gray-900 rounded-lg border border-gray-700 h-96 mb-4"></div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
<!-- DAG Visualization -->
<div class="lg:col-span-2">
<div id="dag-container" class="bg-gray-900 rounded-lg border border-gray-700 h-96"></div>
</div>
<!-- Node Detail Panel -->
<div id="node-detail" class="bg-gray-800 rounded-lg border border-gray-700 p-4 h-96 overflow-y-auto">
<div id="node-detail-empty" class="h-full flex items-center justify-center text-gray-500">
Click a node to view details
</div>
<div id="node-detail-content" class="hidden">
<div class="flex items-center justify-between mb-4">
<h3 id="node-name" class="text-lg font-semibold"></h3>
<span id="node-type" class="text-sm px-2 py-1 rounded"></span>
</div>
<div id="node-status" class="mb-4"></div>
<!-- Inputs -->
<div id="node-inputs-section" class="mb-4">
<h4 class="text-gray-400 text-sm mb-2">Inputs</h4>
<div id="node-inputs" class="space-y-2"></div>
</div>
<!-- Output -->
<div id="node-output-section">
<h4 class="text-gray-400 text-sm mb-2">Output</h4>
<div id="node-output"></div>
</div>
<!-- Config -->
<div id="node-config-section" class="mt-4 hidden">
<h4 class="text-gray-400 text-sm mb-2">Config</h4>
<pre id="node-config" class="text-xs bg-gray-900 rounded p-2 overflow-x-auto"></pre>
</div>
</div>
</div>
</div>
<!-- Step List -->
<div class="space-y-2">
{% for step in plan.steps %}
{% set step_color = 'green' if step.status == 'completed' or step.cache_id else ('purple' if step.cached else ('blue' if step.status == 'running' else 'gray')) %}
<div class="bg-gray-800 rounded p-3">
<div class="step-item bg-gray-800 rounded p-3 cursor-pointer hover:bg-gray-750 transition-colors"
data-step-id="{{ step.id }}"
data-step-name="{{ step.name }}"
data-step-type="{{ step.type }}"
data-step-status="{{ step.status or 'pending' }}"
data-step-inputs="{{ step.inputs | tojson }}"
data-step-cache-id="{{ step.cache_id or '' }}"
data-step-config="{{ (step.config or {}) | tojson }}"
onclick="selectStep('{{ step.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="w-6 h-6 rounded-full bg-{{ step_color }}-600 flex items-center justify-center text-xs">
@@ -102,23 +147,11 @@
{% if step.cache_id %}
<div class="mt-2 ml-9 flex items-center space-x-2">
<span class="text-gray-500 text-xs">Output:</span>
<a href="/cache/{{ step.cache_id }}" class="font-mono text-xs text-blue-400 hover:text-blue-300">
{{ step.cache_id }}
<a href="/cache/{{ step.cache_id }}" class="font-mono text-xs text-blue-400 hover:text-blue-300" onclick="event.stopPropagation()">
{{ step.cache_id[:24] }}...
</a>
</div>
{% endif %}
{% if step.outputs and step.outputs | length > 1 %}
<div class="mt-1 ml-9">
<span class="text-gray-500 text-xs">Additional outputs:</span>
{% for output in step.outputs %}
{% if output != step.cache_id %}
<a href="/cache/{{ output }}" class="block font-mono text-xs text-gray-400 hover:text-white ml-2">
{{ output[:32] }}...
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
@@ -255,7 +288,52 @@
<!-- Inputs Tab -->
<div id="tab-inputs" class="tab-content hidden">
{% if run.inputs %}
{% if run_inputs %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for input in run_inputs %}
<div class="bg-gray-800 rounded-lg overflow-hidden">
<!-- Media Preview -->
{% if input.media_type and input.media_type.startswith('image/') %}
<a href="/cache/{{ input.hash }}" class="block">
<img src="/cache/{{ input.hash }}/raw" alt="{{ input.name or 'Input' }}"
class="w-full h-48 object-cover">
</a>
{% elif input.media_type and input.media_type.startswith('video/') %}
<a href="/cache/{{ input.hash }}" class="block">
<video src="/cache/{{ input.hash }}/raw"
class="w-full h-48 object-cover" muted controls></video>
</a>
{% elif input.media_type and input.media_type.startswith('audio/') %}
<div class="p-4 bg-gray-900">
<audio src="/cache/{{ input.hash }}/raw" controls class="w-full"></audio>
</div>
{% else %}
<a href="/cache/{{ input.hash }}" class="block">
<div class="w-full h-48 bg-gray-900 flex items-center justify-center text-gray-600">
<div class="text-center">
<div class="text-4xl mb-2">📄</div>
<div>{{ input.media_type or 'Unknown type' }}</div>
</div>
</div>
</a>
{% endif %}
<!-- Info -->
<div class="p-3">
{% if input.name %}
<div class="font-medium text-white mb-1">{{ input.name }}</div>
{% endif %}
<a href="/cache/{{ input.hash }}" class="font-mono text-xs text-blue-400 hover:text-blue-300 block truncate">
{{ input.hash }}
</a>
{% if input.media_type %}
<div class="text-xs text-gray-500 mt-1">{{ input.media_type }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% elif run.inputs %}
<!-- Fallback to simple list if run_inputs not available -->
<div class="space-y-2">
{% for input_hash in run.inputs %}
<a href="/cache/{{ input_hash }}"
@@ -302,9 +380,138 @@ function showTab(name) {
}
{% if plan %}
// Store step data for quick lookup
const stepData = {};
document.querySelectorAll('.step-item').forEach(el => {
const id = el.dataset.stepId;
stepData[id] = {
id: id,
name: el.dataset.stepName,
type: el.dataset.stepType,
status: el.dataset.stepStatus,
inputs: JSON.parse(el.dataset.stepInputs || '[]'),
cacheId: el.dataset.stepCacheId,
config: JSON.parse(el.dataset.stepConfig || '{}')
};
});
let cy = null;
let selectedNode = null;
function selectStep(stepId) {
// Update URL hash
history.pushState(null, '', '#node-' + stepId);
showNodeDetail(stepId);
}
function showNodeDetail(stepId) {
const step = stepData[stepId];
if (!step) return;
selectedNode = stepId;
// Update step list selection
document.querySelectorAll('.step-item').forEach(el => {
el.classList.remove('ring-2', 'ring-blue-500');
if (el.dataset.stepId === stepId) {
el.classList.add('ring-2', 'ring-blue-500');
}
});
// Update cytoscape selection
if (cy) {
cy.nodes().removeClass('selected');
cy.nodes().style('border-width', 0);
const node = cy.$('#' + stepId);
if (node.length) {
node.style('border-width', 3);
node.style('border-color', '#3b82f6');
}
}
// Show detail panel
document.getElementById('node-detail-empty').classList.add('hidden');
document.getElementById('node-detail-content').classList.remove('hidden');
// Populate node info
document.getElementById('node-name').textContent = step.name;
const typeEl = document.getElementById('node-type');
typeEl.textContent = step.type;
const typeColors = {
'SOURCE': 'bg-blue-600',
'EFFECT': 'bg-purple-600',
'SEQUENCE': 'bg-pink-600',
'transform': 'bg-green-600',
'output': 'bg-yellow-600'
};
typeEl.className = 'text-sm px-2 py-1 rounded ' + (typeColors[step.type] || 'bg-gray-600');
// Status
const statusEl = document.getElementById('node-status');
const statusColors = {
'completed': 'text-green-400',
'running': 'text-blue-400',
'pending': 'text-gray-400',
'cached': 'text-purple-400'
};
statusEl.innerHTML = `<span class="${statusColors[step.status] || 'text-gray-400'}">${step.status}</span>`;
// Inputs
const inputsEl = document.getElementById('node-inputs');
if (step.inputs.length > 0) {
document.getElementById('node-inputs-section').classList.remove('hidden');
inputsEl.innerHTML = step.inputs.map(inp => {
const inputStep = stepData[inp];
const inputCacheId = inputStep ? inputStep.cacheId : '';
if (inputCacheId) {
return `<div class="bg-gray-900 rounded p-2">
<div class="text-sm text-gray-300 cursor-pointer hover:text-blue-400" onclick="selectStep('${inp}')">${inp}</div>
<a href="/cache/${inputCacheId}" class="font-mono text-xs text-blue-400 hover:text-blue-300 block mt-1" onclick="event.stopPropagation()">
${inputCacheId.substring(0, 24)}...
</a>
</div>`;
} else {
return `<div class="bg-gray-900 rounded p-2">
<div class="text-sm text-gray-300 cursor-pointer hover:text-blue-400" onclick="selectStep('${inp}')">${inp}</div>
<span class="text-xs text-gray-500">No cached output</span>
</div>`;
}
}).join('');
} else {
document.getElementById('node-inputs-section').classList.add('hidden');
}
// Output
const outputEl = document.getElementById('node-output');
if (step.cacheId) {
outputEl.innerHTML = `
<div class="bg-gray-900 rounded p-2">
<a href="/cache/${step.cacheId}" class="font-mono text-xs text-blue-400 hover:text-blue-300 block">
${step.cacheId}
</a>
<a href="/cache/${step.cacheId}/raw" target="_blank" class="text-xs text-gray-500 hover:text-gray-300 mt-1 inline-block">
View raw
</a>
</div>`;
} else {
outputEl.innerHTML = '<span class="text-gray-500 text-sm">No output yet</span>';
}
// Config
const configSection = document.getElementById('node-config-section');
const configEl = document.getElementById('node-config');
if (step.config && Object.keys(step.config).length > 0) {
configSection.classList.remove('hidden');
configEl.textContent = JSON.stringify(step.config, null, 2);
} else {
configSection.classList.add('hidden');
}
}
// Initialize DAG
document.addEventListener('DOMContentLoaded', function() {
const cy = cytoscape({
cy = cytoscape({
container: document.getElementById('dag-container'),
style: [
{ selector: 'node', style: {
@@ -314,7 +521,8 @@ document.addEventListener('DOMContentLoaded', function() {
'text-valign': 'center',
'font-size': '10px',
'width': 40,
'height': 40
'height': 40,
'border-width': 0
}},
{ selector: 'edge', style: {
'width': 2,
@@ -327,6 +535,32 @@ document.addEventListener('DOMContentLoaded', function() {
elements: {{ dag_elements | tojson }},
layout: { name: 'dagre', rankDir: 'LR', padding: 30 }
});
// Node click handler
cy.on('tap', 'node', function(evt) {
const nodeId = evt.target.id();
selectStep(nodeId);
});
// Handle initial hash
const hash = window.location.hash;
if (hash && hash.startsWith('#node-')) {
const nodeId = hash.substring(6);
if (stepData[nodeId]) {
showNodeDetail(nodeId);
}
}
});
// Handle hash changes
window.addEventListener('hashchange', function() {
const hash = window.location.hash;
if (hash && hash.startsWith('#node-')) {
const nodeId = hash.substring(6);
if (stepData[nodeId]) {
showNodeDetail(nodeId);
}
}
});
{% endif %}
</script>