// Client behaviors for the Vetting UI. Loaded in layout.templ with `defer` // so the DOM is parsed before any listeners fire. (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; } 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; } steps.forEach(function (step) { if (step === runningStep) { if (!step.open) { step.open = true; } return; } if (step.open && !step.querySelector('.substep-running')) { if (step.classList.contains('step-failed')) { return; } step.open = false; } }); } // --- 2. in-step search + match count -------------------------------- 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(); var matchCount = 0; 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'); matchCount++; } }); var counter = el.closest('.log-search-wrap').querySelector('.log-match-count'); if (counter) { if (!query) { counter.textContent = ''; } else if (matchCount === 0) { counter.textContent = 'No matches'; } else { counter.textContent = matchCount + (matchCount === 1 ? ' match' : ' matches'); } } }); // --- 3. live duration tick ------------------------------------------ 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; } var step = target.closest('.step'); if (step && !step.open) { step.open = true; } target.scrollIntoView({ block: 'center' }); } window.addEventListener('load', scrollToHash); window.addEventListener('hashchange', scrollToHash); 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(); } }); // --- 5. active nav indicator ---------------------------------------- (function setActiveNav() { var path = location.pathname; var nav = document.querySelector('[data-nav]'); if (!nav) { return; } nav.querySelectorAll('a').forEach(function (a) { var href = a.getAttribute('href'); if (href === '/hosts/new' && path === '/hosts/new') { a.classList.add('nav-active'); } else if (href === '/' && path !== '/hosts/new') { a.classList.add('nav-active'); } }); })(); // --- 6. copy-to-clipboard buttons ----------------------------------- document.body.addEventListener('click', function (ev) { var btn = ev.target.closest && ev.target.closest('.copy-btn'); if (!btn) { return; } var wrap = btn.closest('.copyable-wrap'); if (!wrap) { return; } var source = wrap.querySelector('.one-liner code, .hold-ssh'); if (!source) { return; } var text = source.textContent || ''; navigator.clipboard.writeText(text.trim()).then(function () { btn.textContent = 'Copied'; btn.classList.add('copied'); setTimeout(function () { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000); }); }); // --- 7. SSE connection health indicator ------------------------------ var lastSSE = 0; var HEARTBEAT_INTERVAL = 15000; var STALE_THRESHOLD = HEARTBEAT_INTERVAL * 2.5; document.body.addEventListener('htmx:sseMessage', function () { lastSSE = Date.now(); var hb = document.querySelector('.heartbeat'); if (hb) { hb.classList.add('heartbeat-live'); hb.classList.remove('heartbeat-stale'); } }); setInterval(function () { if (!lastSSE) { return; } var hb = document.querySelector('.heartbeat'); if (!hb) { return; } if (Date.now() - lastSSE > STALE_THRESHOLD) { hb.classList.remove('heartbeat-live'); hb.classList.add('heartbeat-stale'); } }, 5000); // --- 8. inline confirmation for destructive actions ------------------ document.body.addEventListener('submit', function (ev) { var form = ev.target; if (!form || !form.hasAttribute('data-confirm')) { return; } if (form.dataset.confirmed === 'yes') { form.removeAttribute('data-confirmed'); return; } ev.preventDefault(); if (form.querySelector('.confirm-overlay')) { return; } var msg = form.getAttribute('data-confirm'); var btn = form.querySelector('button[type="submit"]'); if (!btn) { return; } btn.style.display = 'none'; var overlay = document.createElement('div'); overlay.className = 'confirm-overlay'; overlay.innerHTML = '' + escapeHtml(msg) + '' + '' + ''; form.appendChild(overlay); overlay.querySelector('.confirm-yes').addEventListener('click', function () { form.dataset.confirmed = 'yes'; form.requestSubmit(); }); overlay.querySelector('.confirm-no').addEventListener('click', function () { overlay.remove(); btn.style.display = ''; }); }); function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } })();