diff --git a/status/build.js b/status/build.js index e805d40..4470cde 100644 --- a/status/build.js +++ b/status/build.js @@ -78,6 +78,7 @@ const bundles = { JS('container-exec.js'), JS('audit-log.js'), JS('security-center.js'), + JS('deploys.js'), JS('weather.js'), JS('clock.js'), JS('card-badges.js'), diff --git a/status/index.html b/status/index.html index 62f4e7d..b3df2d9 100644 --- a/status/index.html +++ b/status/index.html @@ -207,6 +207,7 @@ + diff --git a/status/js/deploys.js b/status/js/deploys.js new file mode 100644 index 0000000..714cbfb --- /dev/null +++ b/status/js/deploys.js @@ -0,0 +1,245 @@ +// ========== SHIPDECK DEPLOYS (DC-130) ========== +// Source deploys panel: drives the shipdeck CLI via the DashCaddy +// /api/v1/deploys bridge proxy. Modal lists deployable repos + deployed +// services, shows the journal, and runs deploy/rollback with live output. +// +// Follows the security-center.js modal pattern (injectModal + button in the +// top bar) and the weather-modal visual language. + +(function () { + injectModal('deploys-modal', `
+
+

🚚 Deploys

+ + +
+ + + +
+ + +
+
Loading…
+
+ + +
+
+ + + + + + + + + + +
+ +
+
+
`); + + const $ = (id) => document.getElementById(id); + let openBtn = null; + let loadedOnce = false; + + function esc(s) { + return window.escapeHtml ? window.escapeHtml(String(s)) : String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + + async function api(path, opts) { + const r = await fetch('/dashcaddy-api/api/v1/deploys' + path, Object.assign({ credentials: 'include' }, opts || {})); + let body = null; + try { body = await r.json(); } catch (e) { body = { success: false, error: 'non-JSON response' }; } + return { status: r.status, body }; + } + + function showOutput(text) { + const el = $('dep-output'); + el.style.display = 'block'; + el.textContent = text || '(no output)'; + el.scrollTop = el.scrollHeight; + } + + async function loadServices() { + const el = $('dep-services'); + const { body } = await api('/services'); + if (!body.success) { + el.innerHTML = '' + esc(body.error || 'unavailable') + ''; + return; + } + const rows = body.services || []; + if (!rows.length) { + el.innerHTML = 'No shipdeck deployments on record yet.'; + return; + } + el.innerHTML = '' + + '' + + '' + + rows.map((s) => + '' + + '' + + '' + + '' + + '' + + '' + + '' + ).join('') + '
ServiceLast actionWhenRelease
' + esc(s.name) + '' + esc(s.last_action) + '' + esc(s.last_time) + '' + esc(s.last_epoch) + ' ' + + '
'; + } + + async function loadRepos() { + const sel = $('dep-repo-select'); + const { body } = await api('/repos'); + if (!body.success) { + sel.innerHTML = ''; + return; + } + const repos = body.repos || []; + sel.innerHTML = repos.length + ? repos.map((r) => '').join('') + : ''; + } + + async function loadJournal(service) { + const el = $('dep-journal'); + const qs = service ? '?service=' + encodeURIComponent(service) : ''; + const { body } = await api('/journal' + qs); + const rows = (body.rows || []); + if (!rows.length) { + el.innerHTML = 'No journal rows.'; + return; + } + el.innerHTML = '' + + '' + + '' + + rows.map((r) => + '' + ).join('') + '
TimeServiceActionReleaseDuration
' + esc(r.time) + '' + esc(r.service) + '' + esc(r.action) + + '' + esc(r.epoch) + '' + esc(r.duration || '—') + '
'; + } + + function setBusy(busy, btn, label) { + if (!btn) return; + btn.disabled = busy; + if (label) btn.textContent = busy ? 'Working…' : label; + } + + async function runDeploy() { + const btn = $('dep-deploy-btn'); + const dir = $('dep-repo-select').value; + if (!dir) return; + setBusy(true, btn, 'Deploy'); + showOutput('Deploying ' + dir + '\nThis can take ~30-60s…'); + try { + const { body } = await api('/deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dir }), + }); + showOutput((body.output || body.error || '') + (body.success ? '\n✅ SUCCESS' : '\n❌ FAILED')); + loadServices(); + } catch (e) { + showOutput('deploy error: ' + e.message); + } + setBusy(false, btn, 'Deploy'); + } + + async function runRollback(service, btn) { + if (!confirm('Roll back ' + service + ' to the previous release?')) return; + setBusy(true, btn, 'Rollback'); + showOutput('Rolling back ' + service + '…'); + try { + const { body } = await api('/rollback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service }), + }); + showOutput((body.output || body.error || '') + (body.success ? '\n✅ ROLLED BACK' : '\n❌ FAILED')); + loadServices(); + } catch (e) { + showOutput('rollback error: ' + e.message); + } + setBusy(false, btn, 'Rollback'); + } + + async function runStatus(service, btn) { + setBusy(true, btn, 'Status'); + try { + const { body } = await api('/status?service=' + encodeURIComponent(service)); + showOutput(body.output || body.error || '(no output)'); + } catch (e) { + showOutput('status error: ' + e.message); + } + setBusy(false, btn, 'Status'); + } + + function wire() { + openBtn = document.getElementById('deploys-btn'); + if (!openBtn) return; + + openBtn.addEventListener('click', async () => { + $('deploys-modal').classList.add('open'); + if (!loadedOnce) { + loadedOnce = true; + loadServices(); + loadRepos(); + loadJournal(''); + } + }); + + $('dep-close').addEventListener('click', () => { + $('deploys-modal').classList.remove('open'); + }); + + // tab switching + document.querySelectorAll('.dep-tab').forEach((tab) => { + tab.addEventListener('click', () => { + document.querySelectorAll('.dep-tab').forEach((t) => t.classList.remove('active')); + tab.classList.add('active'); + document.querySelectorAll('#deploys-modal .dep-panel').forEach((p) => { + p.style.display = p.dataset.panel === tab.dataset.tab ? 'block' : 'none'; + }); + }); + }); + + $('dep-deploy-btn').addEventListener('click', runDeploy); + + $('dep-journal-btn').addEventListener('click', () => loadJournal($('dep-journal-name').value.trim())); + $('dep-status-btn').addEventListener('click', () => { + const name = $('dep-status-name').value.trim(); + if (name) runStatus(name); + }); + + // dynamic buttons (service table) + $('dep-services').addEventListener('click', (e) => { + const rb = e.target.closest('.dep-rollback'); + if (rb) return runRollback(rb.dataset.service, rb); + const sb = e.target.closest('.dep-status'); + if (sb) return runStatus(sb.dataset.service, sb); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', wire); + } else { + wire(); + } +})();