- 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>
876 lines
39 KiB
HTML
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">← 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 %}
|