All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Cascading regex replacements corrupted their own output: the string regex matched CSS class names inside previously-generated span tags. Replaced with a single-pass character tokenizer that never re-processes its own HTML output. Also added highlighting to recipe detail page (previously had none). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1074 lines
48 KiB
HTML
1074 lines
48 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', 'paused': 'yellow'} %}
|
|
{% 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 %}
|
|
{% if run.checkpoint_frame %}
|
|
<span class="text-gray-400 text-sm ml-2">
|
|
Checkpoint: {{ run.checkpoint_frame }}{% if run.total_frames %} / {{ run.total_frames }}{% endif %} frames
|
|
</span>
|
|
{% endif %}
|
|
<div class="flex-grow"></div>
|
|
|
|
<!-- Pause button for running renders -->
|
|
{% if run.status == 'running' %}
|
|
<button hx-post="/runs/{{ run.run_id }}/pause"
|
|
hx-target="#action-result"
|
|
hx-swap="innerHTML"
|
|
class="bg-yellow-600 hover:bg-yellow-700 px-3 py-1 rounded text-sm font-medium">
|
|
Pause
|
|
</button>
|
|
{% endif %}
|
|
|
|
<!-- Resume/Restart buttons for failed/paused renders -->
|
|
{% if run.status in ['failed', 'paused'] %}
|
|
{% if run.checkpoint_frame %}
|
|
<button hx-post="/runs/{{ run.run_id }}/resume"
|
|
hx-target="#action-result"
|
|
hx-swap="innerHTML"
|
|
class="bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm font-medium">
|
|
Resume{% if run.total_frames %} ({{ ((run.checkpoint_frame / run.total_frames) * 100)|round|int }}%){% endif %}
|
|
</button>
|
|
{% endif %}
|
|
<button hx-post="/runs/{{ run.run_id }}/restart"
|
|
hx-target="#action-result"
|
|
hx-swap="innerHTML"
|
|
hx-confirm="Discard progress and start over?"
|
|
class="bg-yellow-600 hover:bg-yellow-700 px-3 py-1 rounded text-sm font-medium">
|
|
Restart
|
|
</button>
|
|
{% endif %}
|
|
|
|
{% 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>
|
|
|
|
<!-- Unified HLS Player (shown during rendering, for paused/failed runs with checkpoint, OR for completed HLS streams) -->
|
|
{% if run.status == 'rendering' or run.ipfs_playlist_cid or (run.status in ['paused', 'failed'] and run.checkpoint_frame) %}
|
|
<div id="hls-player-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">
|
|
{% if run.status == 'rendering' %}
|
|
<span id="live-indicator" class="w-3 h-3 bg-red-500 rounded-full mr-2 animate-pulse"></span>
|
|
<span id="player-title">Live Preview</span>
|
|
{% elif run.status == 'paused' %}
|
|
<span id="live-indicator" class="w-3 h-3 bg-yellow-500 rounded-full mr-2"></span>
|
|
<span id="player-title">Partial Output (Paused)</span>
|
|
{% elif run.status == 'failed' and run.checkpoint_frame %}
|
|
<span id="live-indicator" class="w-3 h-3 bg-red-500 rounded-full mr-2"></span>
|
|
<span id="player-title">Partial Output (Failed)</span>
|
|
{% else %}
|
|
<span id="live-indicator" class="w-3 h-3 bg-green-500 rounded-full mr-2 hidden"></span>
|
|
<span id="player-title">Video</span>
|
|
{% endif %}
|
|
</h3>
|
|
<div class="flex items-center space-x-4">
|
|
<!-- Mode toggle -->
|
|
<div class="flex items-center space-x-2 text-sm">
|
|
<button id="mode-replay" onclick="setPlayerMode('replay')"
|
|
class="px-2 py-1 rounded {% if run.status != 'rendering' %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:bg-gray-600{% endif %}">
|
|
From Start
|
|
</button>
|
|
<button id="mode-live" onclick="setPlayerMode('live')"
|
|
class="px-2 py-1 rounded {% if run.status == 'rendering' %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:bg-gray-600{% endif %}">
|
|
Live Edge
|
|
</button>
|
|
</div>
|
|
<div id="stream-status" class="text-sm text-gray-400">Connecting...</div>
|
|
</div>
|
|
</div>
|
|
<div class="relative bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
|
|
<video id="hls-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 flex items-center justify-between text-xs text-gray-500">
|
|
<span>Stream: <code class="bg-gray-900 px-1 rounded">/runs/{{ run.run_id }}/playlist.m3u8</code></span>
|
|
<span id="stream-info"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
const video = document.getElementById('hls-video');
|
|
const statusEl = document.getElementById('stream-status');
|
|
const loadingEl = document.getElementById('stream-loading');
|
|
const streamInfoEl = document.getElementById('stream-info');
|
|
const liveIndicator = document.getElementById('live-indicator');
|
|
const playerTitle = document.getElementById('player-title');
|
|
const modeReplayBtn = document.getElementById('mode-replay');
|
|
const modeLiveBtn = document.getElementById('mode-live');
|
|
|
|
const baseUrl = '/runs/{{ run.run_id }}/playlist.m3u8';
|
|
const isRendering = {{ 'true' if run.status == 'rendering' else 'false' }};
|
|
const isPausedOrFailed = {{ 'true' if run.status in ['paused', 'failed'] else 'false' }};
|
|
|
|
let hls = null;
|
|
let retryCount = 0;
|
|
const maxRetries = 120;
|
|
let segmentsLoaded = 0;
|
|
// Start in replay mode for paused/failed (shows partial output from start)
|
|
// Start in live mode for rendering (follows the render progress)
|
|
let currentMode = isRendering ? 'live' : 'replay';
|
|
|
|
function getHlsUrl() {
|
|
return baseUrl + '?_t=' + Date.now();
|
|
}
|
|
|
|
// 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 getHlsConfig(mode) {
|
|
const baseConfig = {
|
|
maxBufferLength: 120,
|
|
maxMaxBufferLength: 180,
|
|
maxBufferSize: 100 * 1024 * 1024,
|
|
maxBufferHole: 0.5,
|
|
backBufferLength: 60,
|
|
manifestLoadingTimeOut: 10000,
|
|
manifestLoadingMaxRetry: 4,
|
|
levelLoadingTimeOut: 10000,
|
|
levelLoadingMaxRetry: 4,
|
|
fragLoadingTimeOut: 20000,
|
|
fragLoadingMaxRetry: 6,
|
|
startLevel: 0,
|
|
abrEwmaDefaultEstimate: 500000,
|
|
};
|
|
|
|
if (mode === 'live') {
|
|
// Live mode: follow the edge, cache-bust playlists
|
|
return {
|
|
...baseConfig,
|
|
pLoader: CacheBustingPlaylistLoader,
|
|
liveSyncDurationCount: 10,
|
|
liveMaxLatencyDurationCount: 20,
|
|
liveDurationInfinity: true,
|
|
};
|
|
} else {
|
|
// Replay mode: start from beginning, no live sync
|
|
return {
|
|
...baseConfig,
|
|
pLoader: CacheBustingPlaylistLoader, // Still bust cache for fresh playlist
|
|
startPosition: 0,
|
|
liveDurationInfinity: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
function updateModeUI(mode) {
|
|
currentMode = mode;
|
|
if (mode === 'live') {
|
|
modeLiveBtn.classList.add('bg-blue-600', 'text-white');
|
|
modeLiveBtn.classList.remove('bg-gray-700', 'text-gray-400');
|
|
modeReplayBtn.classList.remove('bg-blue-600', 'text-white');
|
|
modeReplayBtn.classList.add('bg-gray-700', 'text-gray-400');
|
|
liveIndicator.classList.remove('hidden', 'bg-green-500');
|
|
liveIndicator.classList.add('bg-red-500', 'animate-pulse');
|
|
playerTitle.textContent = isRendering ? 'Live Preview' : 'Live Edge';
|
|
} else {
|
|
modeReplayBtn.classList.add('bg-blue-600', 'text-white');
|
|
modeReplayBtn.classList.remove('bg-gray-700', 'text-gray-400');
|
|
modeLiveBtn.classList.remove('bg-blue-600', 'text-white');
|
|
modeLiveBtn.classList.add('bg-gray-700', 'text-gray-400');
|
|
liveIndicator.classList.add('hidden');
|
|
liveIndicator.classList.remove('animate-pulse');
|
|
playerTitle.textContent = 'Replay';
|
|
}
|
|
}
|
|
|
|
window.setPlayerMode = function(mode) {
|
|
if (mode === currentMode) return;
|
|
|
|
const currentTime = video.currentTime;
|
|
const wasPlaying = !video.paused;
|
|
|
|
// Destroy current HLS instance
|
|
if (hls) {
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
|
|
updateModeUI(mode);
|
|
segmentsLoaded = 0;
|
|
retryCount = 0;
|
|
|
|
// Reinitialize with new config
|
|
initHls(mode, mode === 'replay' ? 0 : null); // Start from 0 in replay, live edge in live
|
|
};
|
|
|
|
function initHls(mode, startPosition) {
|
|
mode = mode || currentMode;
|
|
|
|
if (Hls.isSupported()) {
|
|
const config = getHlsConfig(mode);
|
|
if (startPosition !== null && startPosition !== undefined) {
|
|
config.startPosition = startPosition;
|
|
}
|
|
hls = new Hls(config);
|
|
|
|
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');
|
|
streamInfoEl.textContent = `${data.levels.length} quality level(s)`;
|
|
video.play().catch(() => {});
|
|
});
|
|
|
|
hls.on(Hls.Events.FRAG_LOADED, function(event, data) {
|
|
retryCount = 0;
|
|
segmentsLoaded++;
|
|
const modeLabel = currentMode === 'live' ? 'Live' : 'Replay';
|
|
statusEl.textContent = `${modeLabel} (${segmentsLoaded} segments)`;
|
|
statusEl.classList.remove('text-yellow-400', 'text-gray-400');
|
|
statusEl.classList.add('text-green-400');
|
|
});
|
|
|
|
hls.on(Hls.Events.BUFFER_APPENDED, function() {
|
|
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');
|
|
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 {
|
|
if (data.details === 'bufferStalledError') {
|
|
statusEl.textContent = 'Buffering...';
|
|
statusEl.classList.remove('text-green-400');
|
|
statusEl.classList.add('text-yellow-400');
|
|
}
|
|
}
|
|
});
|
|
|
|
video.addEventListener('waiting', function() {
|
|
if (currentMode === 'live' && hls && hls.liveSyncPosition) {
|
|
const liveEdge = hls.liveSyncPosition;
|
|
const behindLive = liveEdge - video.currentTime;
|
|
if (behindLive < 8) {
|
|
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() {
|
|
const modeLabel = currentMode === 'live' ? 'Live' : 'Replay';
|
|
statusEl.textContent = `${modeLabel} (${segmentsLoaded} segments)`;
|
|
statusEl.classList.remove('text-yellow-400');
|
|
statusEl.classList.add('text-green-400');
|
|
});
|
|
|
|
// Live mode: periodic check for catching up to live edge
|
|
if (currentMode === 'live') {
|
|
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 bufferAhead = bufferEnd - video.currentTime;
|
|
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')) {
|
|
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');
|
|
}
|
|
}
|
|
|
|
// Initialize with appropriate mode
|
|
updateModeUI(currentMode);
|
|
initHls(currentMode);
|
|
|
|
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>
|
|
// Single-pass S-expression syntax highlighter (avoids regex corruption)
|
|
function highlightSexp(text) {
|
|
const SPECIAL = new Set(['plan','recipe','def','->','stream','let','lambda','if','cond','define']);
|
|
const PRIMS = new Set(['source','effect','sequence','segment','resize','transform','layer','blend','mux','analyze','fused-pipeline']);
|
|
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
function span(cls, s) { return '<span class="' + cls + '">' + esc(s) + '</span>'; }
|
|
|
|
let out = '', i = 0, len = text.length;
|
|
while (i < len) {
|
|
// Comments
|
|
if (text[i] === ';' && i + 1 < len && text[i+1] === ';') {
|
|
let end = text.indexOf('\n', i);
|
|
if (end === -1) end = len;
|
|
out += span('text-gray-500', text.slice(i, end));
|
|
i = end;
|
|
}
|
|
// Strings
|
|
else if (text[i] === '"') {
|
|
let j = i + 1;
|
|
while (j < len && text[j] !== '"') { if (text[j] === '\\') j++; j++; }
|
|
if (j < len) j++; // closing quote
|
|
out += span('text-green-400', text.slice(i, j));
|
|
i = j;
|
|
}
|
|
// Keywords (:keyword)
|
|
else if (text[i] === ':' && i + 1 < len && /[a-zA-Z_-]/.test(text[i+1])) {
|
|
let j = i + 1;
|
|
while (j < len && /[a-zA-Z0-9_-]/.test(text[j])) j++;
|
|
out += span('text-purple-400', text.slice(i, j));
|
|
i = j;
|
|
}
|
|
// Open paren - check for primitive/special after it
|
|
else if (text[i] === '(') {
|
|
out += span('text-yellow-500', '(');
|
|
i++;
|
|
// Skip whitespace after paren
|
|
let ws = '';
|
|
while (i < len && (text[i] === ' ' || text[i] === '\t')) { ws += text[i]; i++; }
|
|
out += esc(ws);
|
|
// Check if next word is a special form or primitive
|
|
if (i < len && /[a-zA-Z_>-]/.test(text[i])) {
|
|
let j = i;
|
|
while (j < len && /[a-zA-Z0-9_>-]/.test(text[j])) j++;
|
|
let word = text.slice(i, j);
|
|
if (SPECIAL.has(word)) out += span('text-pink-400 font-semibold', word);
|
|
else if (PRIMS.has(word)) out += span('text-blue-400', word);
|
|
else out += esc(word);
|
|
i = j;
|
|
}
|
|
}
|
|
// Close paren
|
|
else if (text[i] === ')') {
|
|
out += span('text-yellow-500', ')');
|
|
i++;
|
|
}
|
|
// Numbers
|
|
else if (/[0-9]/.test(text[i]) && (i === 0 || /[\s(]/.test(text[i-1]))) {
|
|
let j = i;
|
|
while (j < len && /[0-9.]/.test(text[j])) j++;
|
|
out += span('text-orange-300', text.slice(i, j));
|
|
i = j;
|
|
}
|
|
// Regular text
|
|
else {
|
|
let j = i;
|
|
while (j < len && !'(;":)'.includes(text[j])) {
|
|
if (text[j] === ':' && j + 1 < len && /[a-zA-Z_-]/.test(text[j+1])) break;
|
|
if (/[0-9]/.test(text[j]) && (j === 0 || /[\s(]/.test(text[j-1]))) break;
|
|
j++;
|
|
}
|
|
if (j === i) { out += esc(text[i]); i++; } // safety: advance at least 1 char
|
|
else { out += esc(text.slice(i, j)); i = j; }
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
document.querySelectorAll('.sexp-code').forEach(el => {
|
|
el.innerHTML = highlightSexp(el.textContent);
|
|
});
|
|
</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/') %}
|
|
{# HLS streams use the unified player above; show direct video for non-HLS #}
|
|
{% if run.ipfs_playlist_cid %}
|
|
<div class="text-gray-400 text-sm py-4">
|
|
HLS stream available in player above. Use "From Start" to watch from beginning or "Live Edge" to follow rendering progress.
|
|
</div>
|
|
{% else %}
|
|
{# Direct video file #}
|
|
<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>
|
|
{% endif %}
|
|
{% 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>
|
|
<div class="flex items-center space-x-4">
|
|
{% if run.ipfs_playlist_cid %}
|
|
<a href="/ipfs/{{ run.ipfs_playlist_cid }}"
|
|
class="text-gray-400 hover:text-white text-sm">
|
|
HLS Playlist
|
|
</a>
|
|
{% endif %}
|
|
{% 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>
|
|
</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 %}
|