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"),
|
"type": node.get("type", "EFFECT"),
|
||||||
"status": "completed", # Run completed
|
"status": "completed", # Run completed
|
||||||
"inputs": node.get("inputs", []),
|
"inputs": node.get("inputs", []),
|
||||||
|
"config": node.get("config", {}),
|
||||||
})
|
})
|
||||||
elif isinstance(nodes, dict):
|
elif isinstance(nodes, dict):
|
||||||
for node_id, node in nodes.items():
|
for node_id, node in nodes.items():
|
||||||
@@ -142,6 +143,7 @@ async def get_run(
|
|||||||
"type": node.get("type", "EFFECT"),
|
"type": node.get("type", "EFFECT"),
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"inputs": node.get("inputs", []),
|
"inputs": node.get("inputs", []),
|
||||||
|
"config": node.get("config", {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if steps:
|
if steps:
|
||||||
@@ -174,6 +176,25 @@ async def get_run(
|
|||||||
"media_type": media_type or "application/octet-stream",
|
"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
|
# Build DAG elements for visualization
|
||||||
dag_elements = []
|
dag_elements = []
|
||||||
if plan and plan.get("steps"):
|
if plan and plan.get("steps"):
|
||||||
@@ -209,6 +230,7 @@ async def get_run(
|
|||||||
run=run,
|
run=run,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
|
run_inputs=run_inputs,
|
||||||
dag_elements=dag_elements,
|
dag_elements=dag_elements,
|
||||||
active_tab="runs",
|
active_tab="runs",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,12 +77,57 @@
|
|||||||
<!-- Plan Tab -->
|
<!-- Plan Tab -->
|
||||||
<div id="tab-plan" class="tab-content">
|
<div id="tab-plan" class="tab-content">
|
||||||
{% if plan %}
|
{% 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">
|
<div class="space-y-2">
|
||||||
{% for step in plan.steps %}
|
{% 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')) %}
|
{% 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 justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
<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">
|
<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 %}
|
{% if step.cache_id %}
|
||||||
<div class="mt-2 ml-9 flex items-center space-x-2">
|
<div class="mt-2 ml-9 flex items-center space-x-2">
|
||||||
<span class="text-gray-500 text-xs">Output:</span>
|
<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">
|
<a href="/cache/{{ step.cache_id }}" class="font-mono text-xs text-blue-400 hover:text-blue-300" onclick="event.stopPropagation()">
|
||||||
{{ step.cache_id }}
|
{{ step.cache_id[:24] }}...
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +288,52 @@
|
|||||||
|
|
||||||
<!-- Inputs Tab -->
|
<!-- Inputs Tab -->
|
||||||
<div id="tab-inputs" class="tab-content hidden">
|
<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">
|
<div class="space-y-2">
|
||||||
{% for input_hash in run.inputs %}
|
{% for input_hash in run.inputs %}
|
||||||
<a href="/cache/{{ input_hash }}"
|
<a href="/cache/{{ input_hash }}"
|
||||||
@@ -302,9 +380,138 @@ function showTab(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{% if plan %}
|
{% 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
|
// Initialize DAG
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const cy = cytoscape({
|
cy = cytoscape({
|
||||||
container: document.getElementById('dag-container'),
|
container: document.getElementById('dag-container'),
|
||||||
style: [
|
style: [
|
||||||
{ selector: 'node', style: {
|
{ selector: 'node', style: {
|
||||||
@@ -314,7 +521,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
'text-valign': 'center',
|
'text-valign': 'center',
|
||||||
'font-size': '10px',
|
'font-size': '10px',
|
||||||
'width': 40,
|
'width': 40,
|
||||||
'height': 40
|
'height': 40,
|
||||||
|
'border-width': 0
|
||||||
}},
|
}},
|
||||||
{ selector: 'edge', style: {
|
{ selector: 'edge', style: {
|
||||||
'width': 2,
|
'width': 2,
|
||||||
@@ -327,6 +535,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
elements: {{ dag_elements | tojson }},
|
elements: {{ dag_elements | tojson }},
|
||||||
layout: { name: 'dagre', rankDir: 'LR', padding: 30 }
|
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 %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user