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:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user