Files
celery/app/templates/runs/detail.html
giles d6c575760b Add Delete and Run Again buttons to run detail page
- Add "Run Again" button that reruns the recipe with same parameters
- Add "Delete" button with confirmation to delete run and artifacts
- Consolidate result display into single #action-result span
- Implement POST /runs/rerun/{recipe_id} endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:18:16 +00:00

876 lines
39 KiB
HTML

{% extends "base.html" %}
{% block title %}Run {{ run.run_id[:12] }} - Art-DAG L1{% endblock %}
{% block head %}
{{ super() }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.23.0/cytoscape.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script>
{% endblock %}
{% block content %}
{% set status_colors = {'completed': 'green', 'running': 'blue', 'pending': 'yellow', 'failed': 'red'} %}
{% set color = status_colors.get(run.status, 'gray') %}
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="flex items-center space-x-4 mb-6">
<a href="/runs" class="text-gray-400 hover:text-white">&larr; Runs</a>
<h1 class="text-2xl font-bold font-mono">{{ run.run_id[:16] }}...</h1>
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-3 py-1 rounded text-sm uppercase">
{{ run.status }}
</span>
{% if run.cached %}
<span class="bg-purple-900 text-purple-300 px-3 py-1 rounded text-sm">Cached</span>
{% endif %}
{% if run.error %}
<span class="text-red-400 text-sm ml-2">{{ run.error }}</span>
{% endif %}
<div class="flex-grow"></div>
{% if run.recipe %}
<button hx-post="/runs/rerun/{{ run.recipe }}"
hx-target="#action-result"
hx-swap="innerHTML"
class="bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded text-sm font-medium">
Run Again
</button>
{% endif %}
<button hx-post="/runs/{{ run.run_id }}/publish"
hx-target="#action-result"
class="bg-purple-600 hover:bg-purple-700 px-3 py-1 rounded text-sm font-medium">
Share to L2
</button>
<button hx-delete="/runs/{{ run.run_id }}/ui"
hx-target="#action-result"
hx-confirm="Delete this run and all its artifacts? This cannot be undone."
class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm font-medium">
Delete
</button>
<span id="action-result"></span>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Recipe</div>
<div class="text-white font-medium">
{% if run.recipe %}
<a href="/recipes/{{ run.recipe }}" class="hover:text-blue-400">
{{ run.recipe_name or (run.recipe[:16] ~ '...') }}
</a>
{% else %}
Unknown
{% endif %}
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Steps</div>
<div class="text-white font-medium">
{% if run.recipe == 'streaming' %}
{% if run.status == 'completed' %}1 / 1{% else %}0 / 1{% endif %}
{% else %}
{{ run.executed or 0 }} / {{ run.total_steps or (plan.steps|length if plan and plan.steps else '?') }}
{% endif %}
{% if run.cached_steps %}
<span class="text-purple-400 text-sm">({{ run.cached_steps }} cached)</span>
{% endif %}
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Created</div>
<div class="text-white font-medium">{{ run.created_at }}</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">User</div>
<div class="text-white font-medium">{{ run.username or 'Unknown' }}</div>
</div>
</div>
<!-- Live Stream Player (shown during rendering) -->
{% if run.status == 'rendering' %}
<div id="live-stream-container" class="mb-6 bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold flex items-center">
<span class="w-3 h-3 bg-red-500 rounded-full mr-2 animate-pulse"></span>
Live Preview
</h3>
<div id="stream-status" class="text-sm text-gray-400">Connecting...</div>
</div>
<div class="relative bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<video id="live-video" class="w-full h-full" controls autoplay muted playsinline></video>
<div id="stream-loading" class="absolute inset-0 flex items-center justify-center bg-gray-900/80">
<div class="text-center">
<div class="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
<div class="text-gray-400">Waiting for stream...</div>
</div>
</div>
</div>
<div class="mt-2 text-xs text-gray-500">
Stream URL: <code class="bg-gray-900 px-1 rounded">/runs/{{ run.run_id }}/hls/stream.m3u8</code>
</div>
</div>
<script>
(function() {
const video = document.getElementById('live-video');
const statusEl = document.getElementById('stream-status');
const loadingEl = document.getElementById('stream-loading');
// Use dynamic playlist endpoint with cache busting
const baseUrl = '/runs/{{ run.run_id }}/playlist.m3u8';
function getHlsUrl() {
return baseUrl + '?_t=' + Date.now();
}
let hls = null;
let retryCount = 0;
const maxRetries = 120; // Try for up to 4 minutes
let segmentsLoaded = 0;
// Custom playlist loader that adds cache-busting to every request
class CacheBustingPlaylistLoader extends Hls.DefaultConfig.loader {
load(context, config, callbacks) {
if (context.type === 'manifest' || context.type === 'level') {
const url = new URL(context.url, window.location.origin);
url.searchParams.set('_t', Date.now());
context.url = url.toString();
}
super.load(context, config, callbacks);
}
}
function initHls() {
if (Hls.isSupported()) {
hls = new Hls({
// Custom loader to bust cache on playlist requests
pLoader: CacheBustingPlaylistLoader,
// Stay far behind live edge - rendering is slow (~0.1x speed)
// 10 segments = 40s of buffer before catching up
liveSyncDurationCount: 10, // Stay 10 segments behind live edge
liveMaxLatencyDurationCount: 20, // Allow up to 20 segments behind
liveDurationInfinity: true, // Treat as infinite live stream
// Large buffers to absorb rendering speed variations
maxBufferLength: 120, // Buffer up to 120s ahead
maxMaxBufferLength: 180, // Allow even more if needed
maxBufferSize: 100 * 1024 * 1024, // 100MB buffer
maxBufferHole: 0.5, // Tolerate small gaps
// Back buffer for smooth seeking
backBufferLength: 60,
// Playlist reload settings - check frequently for new segments
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 4,
levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 4,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
// Start at lowest quality for faster start
startLevel: 0,
// Enable smooth level switching
abrEwmaDefaultEstimate: 500000,
});
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
loadingEl.classList.add('hidden');
statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-gray-400');
statusEl.classList.add('text-yellow-400');
video.play().catch(() => {});
});
hls.on(Hls.Events.FRAG_LOADED, function(event, data) {
retryCount = 0;
segmentsLoaded++;
statusEl.textContent = `Playing (${segmentsLoaded} segments)`;
statusEl.classList.remove('text-yellow-400', 'text-gray-400');
statusEl.classList.add('text-green-400');
});
hls.on(Hls.Events.BUFFER_APPENDED, function() {
// Hide loading once we have buffered content
loadingEl.classList.add('hidden');
});
hls.on(Hls.Events.ERROR, function(event, data) {
console.log('HLS error:', data.type, data.details, data.fatal);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (retryCount < maxRetries) {
retryCount++;
statusEl.textContent = `Waiting for stream... (${retryCount})`;
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
// Exponential backoff with jitter
const delay = Math.min(1000 * Math.pow(1.5, Math.min(retryCount, 6)), 10000);
setTimeout(() => {
hls.loadSource(getHlsUrl());
}, delay + Math.random() * 1000);
} else {
statusEl.textContent = 'Stream unavailable';
statusEl.classList.add('text-red-400');
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, attempting recovery');
hls.recoverMediaError();
break;
default:
statusEl.textContent = 'Stream error';
statusEl.classList.add('text-red-400');
break;
}
} else {
// Non-fatal error - just log it
if (data.details === 'bufferStalledError') {
statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
}
}
});
// Handle video stalls - check if we've caught up to live edge
video.addEventListener('waiting', function() {
// Check if we're near the live edge (within 2 segments)
if (hls && hls.liveSyncPosition) {
const liveEdge = hls.liveSyncPosition;
const currentTime = video.currentTime;
const behindLive = liveEdge - currentTime;
if (behindLive < 8) { // Less than 2 segments behind
statusEl.textContent = 'Waiting for rendering...';
} else {
statusEl.textContent = 'Buffering...';
}
} else {
statusEl.textContent = 'Buffering...';
}
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
});
video.addEventListener('playing', function() {
statusEl.textContent = `Playing (${segmentsLoaded} segments)`;
statusEl.classList.remove('text-yellow-400');
statusEl.classList.add('text-green-400');
});
// Periodic check for catching up to live edge
setInterval(function() {
if (hls && !video.paused && hls.levels && hls.levels.length > 0) {
const buffered = video.buffered;
if (buffered.length > 0) {
const bufferEnd = buffered.end(buffered.length - 1);
const currentTime = video.currentTime;
const bufferAhead = bufferEnd - currentTime;
// If less than 4 seconds buffered, show warning
if (bufferAhead < 4) {
statusEl.textContent = 'Waiting for rendering...';
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
}
}
}
}, 1000);
hls.loadSource(getHlsUrl());
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
video.src = getHlsUrl();
video.addEventListener('loadedmetadata', function() {
loadingEl.classList.add('hidden');
statusEl.textContent = 'Playing';
video.play().catch(() => {});
});
} else {
statusEl.textContent = 'HLS not supported';
statusEl.classList.add('text-red-400');
}
}
// Start trying to connect
initHls();
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (hls) hls.destroy();
});
})();
</script>
{% endif %}
<!-- Tabs -->
<div class="border-b border-gray-700 mb-6">
<nav class="flex space-x-8">
<a href="#plan" class="tab-link border-b-2 border-blue-500 text-white pb-3 px-1"
onclick="showTab('plan')">Plan</a>
<a href="#artifacts" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
onclick="showTab('artifacts')">Artifacts</a>
{% if analysis %}
<a href="#analysis" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
onclick="showTab('analysis')">Analysis</a>
{% endif %}
<a href="#inputs" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
onclick="showTab('inputs')">Inputs</a>
</nav>
</div>
<!-- Plan Tab -->
<div id="tab-plan" class="tab-content">
{% if plan %}
<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="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">
{{ loop.index }}
</span>
<span class="font-medium">{{ step.name }}</span>
<span class="text-gray-500 text-sm">{{ step.type }}</span>
</div>
<div class="flex items-center space-x-3">
{% if step.cached %}
<span class="text-purple-400 text-sm">cached</span>
{% elif step.status == 'completed' %}
<span class="text-green-400 text-sm">completed</span>
{% endif %}
</div>
</div>
{% 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" onclick="event.stopPropagation()">
{{ step.cache_id[:24] }}...
</a>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Recipe/Plan S-expression -->
{% if plan_sexp %}
<details class="mt-6" open>
<summary class="cursor-pointer text-gray-400 hover:text-white text-sm mb-2 flex items-center justify-between">
<span>Recipe (S-expression)</span>
{% if recipe_ipfs_cid %}
<a href="https://ipfs.io/ipfs/{{ recipe_ipfs_cid }}"
target="_blank"
onclick="event.stopPropagation()"
class="text-blue-400 hover:text-blue-300 text-xs font-mono ml-4">
ipfs://{{ recipe_ipfs_cid[:16] }}...
</a>
{% endif %}
</summary>
<div class="bg-gray-900 rounded-lg border border-gray-700 p-4 overflow-x-auto">
<pre class="text-sm font-mono sexp-code">{{ plan_sexp }}</pre>
</div>
</details>
{% endif %}
<style>
.sexp-code {
line-height: 1.6;
}
</style>
<script>
// Syntax highlight S-expressions
document.querySelectorAll('.sexp-code').forEach(el => {
let html = el.textContent;
// Comments
html = html.replace(/(;;.*)/g, '<span class="text-gray-500">$1</span>');
// Keywords (:keyword)
html = html.replace(/(:[a-zA-Z_-]+)/g, '<span class="text-purple-400">$1</span>');
// Strings
html = html.replace(/("(?:[^"\\]|\\.)*")/g, '<span class="text-green-400">$1</span>');
// Special forms
html = html.replace(/\b(plan|recipe|def|->)\b/g, '<span class="text-pink-400 font-semibold">$1</span>');
// Primitives
html = html.replace(/\((source|effect|sequence|segment|resize|transform|layer|blend|mux|analyze)\b/g,
'(<span class="text-blue-400">$1</span>');
// Parentheses
html = html.replace(/(\(|\))/g, '<span class="text-yellow-500">$1</span>');
el.innerHTML = html;
});
</script>
{% else %}
<p class="text-gray-500">No plan available for this run.</p>
{% endif %}
</div>
<!-- Artifacts Tab -->
<div id="tab-artifacts" class="tab-content hidden">
{% if artifacts %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for artifact in artifacts %}
<a href="/cache/{{ artifact.cid }}"
class="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors">
{% if artifact.media_type and artifact.media_type.startswith('image/') %}
<img src="/cache/{{ artifact.cid }}/raw" alt=""
class="w-full h-32 object-cover rounded mb-2">
{% elif artifact.media_type and artifact.media_type.startswith('video/') %}
<video src="/cache/{{ artifact.cid }}/raw"
class="w-full h-32 object-cover rounded mb-2" muted></video>
{% else %}
<div class="w-full h-32 bg-gray-900 rounded mb-2 flex items-center justify-center text-gray-600">
{{ artifact.media_type or 'Unknown' }}
</div>
{% endif %}
<div class="font-mono text-xs text-gray-500 truncate">{{ artifact.cid[:16] }}...</div>
<div class="text-sm text-gray-400">{{ artifact.step_name }}</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500">No artifacts generated yet.</p>
{% endif %}
</div>
<!-- Analysis Tab -->
<div id="tab-analysis" class="tab-content hidden">
{% if analysis %}
<div class="space-y-6">
{% for item in analysis %}
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">{{ item.input_name }}</h3>
<a href="/cache/{{ item.input_cid }}" class="font-mono text-xs text-blue-400 hover:text-blue-300">
{{ item.input_cid[:16] }}...
</a>
</div>
{% if item.has_analysis %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{% if item.tempo %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Tempo</div>
<div class="text-2xl font-bold text-pink-400">{{ item.tempo | round(1) }}</div>
<div class="text-gray-500 text-xs">BPM</div>
</div>
{% endif %}
{% if item.beat_times %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Beats</div>
<div class="text-2xl font-bold text-blue-400">{{ item.beat_times | length }}</div>
<div class="text-gray-500 text-xs">detected</div>
</div>
{% endif %}
{% if item.energy is not none %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Energy</div>
<div class="text-2xl font-bold text-green-400">{{ (item.energy * 100) | round(1) }}%</div>
<div class="text-gray-500 text-xs">average</div>
</div>
{% endif %}
{% if item.beat_times %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Duration</div>
<div class="text-2xl font-bold text-purple-400">
{{ (item.beat_times[-1] | default(0)) | round(1) }}s
</div>
<div class="text-gray-500 text-xs">analyzed</div>
</div>
{% endif %}
</div>
<!-- Beat visualization -->
{% if item.beat_times and item.beat_times | length > 0 %}
<div class="mt-4">
<div class="text-gray-500 text-sm mb-2">Beat Timeline</div>
<div class="bg-gray-900 rounded h-8 relative overflow-hidden">
{% set max_time = item.beat_times[-1] if item.beat_times else 1 %}
{% for beat_time in item.beat_times[:100] %}
<div class="absolute top-0 bottom-0 w-px bg-pink-500 opacity-60"
style="left: {{ (beat_time / max_time * 100) | round(2) }}%"></div>
{% endfor %}
</div>
<div class="flex justify-between text-xs text-gray-600 mt-1">
<span>0s</span>
<span>{{ max_time | round(1) }}s</span>
</div>
</div>
{% endif %}
<!-- Raw data toggle -->
<details class="mt-4">
<summary class="cursor-pointer text-gray-400 hover:text-white text-sm">
Show Raw Analysis Data
</summary>
<div class="bg-gray-900 rounded-lg border border-gray-700 p-4 mt-2 overflow-x-auto">
<pre class="text-sm text-gray-300 whitespace-pre-wrap">{{ item.raw | tojson(indent=2) }}</pre>
</div>
</details>
{% else %}
<p class="text-gray-500">No analysis data available for this input.</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500">No inputs with analysis data.</p>
{% endif %}
</div>
<!-- Inputs Tab -->
<div id="tab-inputs" class="tab-content hidden">
{% 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.cid }}" class="block">
<img src="/cache/{{ input.cid }}/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.cid }}" class="block">
<video src="/cache/{{ input.cid }}/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.cid }}/raw" controls class="w-full"></audio>
</div>
{% else %}
<a href="/cache/{{ input.cid }}" 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.cid }}" class="font-mono text-xs text-blue-400 hover:text-blue-300 block truncate">
{{ input.cid }}
</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 }}"
class="block bg-gray-800 rounded p-3 font-mono text-sm hover:bg-gray-750">
{{ input_hash }}
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500">No inputs recorded.</p>
{% endif %}
</div>
<!-- Output -->
{% if run.output_cid %}
<div class="mt-8 bg-gray-800 rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Output</h3>
{# Inline media preview - prefer IPFS URLs when available #}
<div class="mb-4">
{% if output_media_type and output_media_type.startswith('image/') %}
<a href="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}{% endif %}" class="block">
<img src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" alt="Output"
class="max-w-full max-h-96 rounded-lg mx-auto">
</a>
{% elif output_media_type and output_media_type.startswith('video/') %}
<video src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" controls
class="max-w-full max-h-96 rounded-lg mx-auto"></video>
{% elif output_media_type and output_media_type.startswith('audio/') %}
<audio src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" controls class="w-full"></audio>
{% else %}
<div class="bg-gray-900 rounded-lg p-8 text-center text-gray-500">
<div class="text-4xl mb-2">?</div>
<div>{{ output_media_type or 'Unknown media type' }}</div>
</div>
{% endif %}
</div>
<div class="flex items-center justify-between">
<a href="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}{% endif %}"
class="font-mono text-sm text-blue-400 hover:text-blue-300">
{% if run.ipfs_cid %}{{ run.ipfs_cid }}{% else %}{{ run.output_cid }}{% endif %}
</a>
{% if run.ipfs_cid %}
<a href="https://ipfs.io/ipfs/{{ run.ipfs_cid }}"
target="_blank"
class="text-gray-400 hover:text-white text-sm">
View on IPFS Gateway
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<script>
function showTab(name) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-link').forEach(el => {
el.classList.remove('border-blue-500', 'text-white');
el.classList.add('border-transparent', 'text-gray-400');
});
document.getElementById('tab-' + name).classList.remove('hidden');
event.target.classList.add('border-blue-500', 'text-white');
event.target.classList.remove('border-transparent', 'text-gray-400');
}
{% 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 || '{}')
};
});
console.log('stepData loaded:', Object.keys(stepData).length, 'steps');
console.log('dag_elements:', {{ dag_elements | tojson }});
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() {
cy = cytoscape({
container: document.getElementById('dag-container'),
style: [
{ selector: 'node', style: {
'label': 'data(label)',
'background-color': 'data(color)',
'color': '#fff',
'text-valign': 'center',
'font-size': '10px',
'width': 40,
'height': 40,
'border-width': 0
}},
{ selector: 'edge', style: {
'width': 2,
'line-color': '#4b5563',
'target-arrow-color': '#4b5563',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier'
}}
],
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>
{% endblock %}