// Detail-page client behaviors. Loaded in layout.templ with `defer` so the // DOM is parsed before any listeners fire. Three jobs: // // 1. Auto-advance: when a substep-* SSE event lands with state=running, // open the parent step panel and collapse any previously-running step // that's now completed. Keeps the operator's attention on the thing // that's currently moving without manual clicks. // 2. In-step search: filter `.log-line` rows inside the current step by // substring match. Client-side only — the log pane's `
` ancestor // scopes the filter naturally. // 3. Permalink scroll + highlight: when the URL carries `#L{run}-{stage}-{ord}` // on load, scroll that log line into view; anchor clicks update // `location.hash` without a reload. (function () { 'use strict'; // --- 1. auto-advance on substep SSE --------------------------------- document.body.addEventListener('htmx:sseMessage', function (ev) { var name = ev.detail && ev.detail.type; if (!name || name.indexOf('substep-') !== 0) { return; } // After htmx has applied the swap, check which step the just-updated // substep belongs to. We scan *after* the swap so we see the new // class ("substep-running" / "substep-passed") rather than the old. setTimeout(function () { autoAdvance(); }, 0); }); function autoAdvance() { var steps = document.querySelectorAll('.step[data-stage]'); var runningStep = null; steps.forEach(function (step) { if (step.querySelector('.substep-running')) { runningStep = step; } }); if (!runningStep) { return; } // Open the running step; collapse any other open step that no longer // has a running substep. The default-open step picked server-side // stays open if nothing is running yet. steps.forEach(function (step) { if (step === runningStep) { if (!step.open) { step.open = true; } return; } if (step.open && !step.querySelector('.substep-running')) { // Leave the "currently-failed" step open even when we // auto-advance — operator still wants to see what broke. if (step.classList.contains('step-failed')) { return; } step.open = false; } }); } // --- 2. in-step search ---------------------------------------------- document.body.addEventListener('input', function (ev) { var el = ev.target; if (!el.classList || !el.classList.contains('log-search')) { return; } var step = el.closest('.step'); if (!step) { return; } var query = el.value.trim().toLowerCase(); step.querySelectorAll('.log-line').forEach(function (line) { if (!query) { line.style.display = ''; line.classList.remove('log-hit'); return; } var text = (line.textContent || '').toLowerCase(); if (text.indexOf(query) === -1) { line.style.display = 'none'; line.classList.remove('log-hit'); } else { line.style.display = ''; line.classList.add('log-hit'); } }); }); // --- 3. live duration tick ------------------------------------------ // // .run-duration spans carry data-started-at (RFC3339) while the run is // non-terminal. Every second we rewrite their text with the current // elapsed so the header timer ticks between SSE pushes. When an SSE // swap drops the attribute (run finished), the tick silently skips it // and the server-rendered final value stays put. function formatDuration(ms) { if (ms < 0) { ms = 0; } if (ms < 1000) { return Math.floor(ms) + 'ms'; } if (ms < 10000) { return (ms / 1000).toFixed(1) + 's'; } if (ms < 60000) { return Math.floor(ms / 1000) + 's'; } if (ms < 3600000) { var m = Math.floor(ms / 60000); var s = Math.floor((ms % 60000) / 1000); return m + 'm ' + s + 's'; } var h = Math.floor(ms / 3600000); var mm = Math.floor((ms % 3600000) / 60000); return h + 'h ' + mm + 'm'; } function tickDurations() { var now = Date.now(); var nodes = document.querySelectorAll('.run-duration[data-started-at]'); nodes.forEach(function (el) { var startMs = Date.parse(el.getAttribute('data-started-at')); if (isNaN(startMs)) { return; } el.textContent = formatDuration(now - startMs); }); } setInterval(tickDurations, 1000); // --- 4. permalink scroll + highlight on load ------------------------ function scrollToHash() { var hash = (location.hash || '').replace(/^#/, ''); if (!hash) { return; } var target = document.getElementById(hash); if (!target) { return; } // Open the enclosing step so the target is actually visible. var step = target.closest('.step'); if (step && !step.open) { step.open = true; } target.scrollIntoView({ block: 'center' }); } window.addEventListener('load', scrollToHash); window.addEventListener('hashchange', scrollToHash); // Anchor clicks update location.hash without triggering navigation; // the hashchange listener above handles the scroll + highlight. document.body.addEventListener('click', function (ev) { var a = ev.target.closest && ev.target.closest('.log-anchor'); if (!a) { return; } ev.preventDefault(); var href = a.getAttribute('href') || ''; if (href.indexOf('#') === 0) { history.replaceState(null, '', href); scrollToHash(); } }); })();