Files
dashcaddy/status/js/deploys.js
T

246 lines
9.4 KiB
JavaScript

// ========== 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', `<div id="deploys-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 860px; max-width: 1200px;">
<h3>🚚 Deploys</h3>
<p class="modal-subtitle">
Source deploys via shipdeck: build → package → systemd release → Caddy gate → DNS → verify.
</p>
<div class="sec-tabs" style="display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);">
<button class="dep-tab active" data-tab="services">Services</button>
<button class="dep-tab" data-tab="deploy">Deploy</button>
<button class="dep-tab" data-tab="journal">Journal</button>
</div>
<!-- SERVICES TAB -->
<div class="dep-panel" data-panel="services">
<div id="dep-services" class="scroll-container" style="max-height:320px;">Loading…</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;">
<input id="dep-status-name" placeholder="service name" style="width:220px;" />
<button class="btn" id="dep-status-btn">Check status</button>
</div>
</div>
<!-- DEPLOY TAB -->
<div class="dep-panel" data-panel="deploy" style="display:none;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">
<select id="dep-repo-select" style="min-width:280px;"><option>Loading…</option></select>
<button class="btn btn-primary" id="dep-deploy-btn">Deploy</button>
</div>
<p class="modal-subtitle">Deploys are serialized fleet-side; a run takes ~30-60s. Output streams below when it finishes.</p>
</div>
<!-- JOURNAL TAB -->
<div class="dep-panel" data-panel="journal" style="display:none;">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px;">
<input id="dep-journal-name" placeholder="service (empty = all)" style="width:220px;" />
<button class="btn" id="dep-journal-btn">Refresh</button>
</div>
<div id="dep-journal" class="scroll-container" style="max-height:320px;">Loading…</div>
</div>
<!-- OUTPUT -->
<pre id="dep-output" class="scroll-container" style="display:none;max-height:260px;margin-top:12px;background:var(--bg-2,#111);padding:10px;border-radius:8px;white-space:pre-wrap;"></pre>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:14px;">
<button class="btn" id="dep-close">Close</button>
</div>
</div>
</div>`);
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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 = '<em>' + esc(body.error || 'unavailable') + '</em>';
return;
}
const rows = body.services || [];
if (!rows.length) {
el.innerHTML = '<em>No shipdeck deployments on record yet.</em>';
return;
}
el.innerHTML = '<table class="table" style="width:100%;"><thead><tr>' +
'<th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th>' +
'</tr></thead><tbody>' +
rows.map((s) =>
'<tr>' +
'<td><strong>' + esc(s.name) + '</strong></td>' +
'<td>' + esc(s.last_action) + '</td>' +
'<td>' + esc(s.last_time) + '</td>' +
'<td>' + esc(s.last_epoch) + '</td>' +
'<td><button class="btn btn-sm dep-rollback" data-service="' + esc(s.name) + '">Rollback</button> ' +
'<button class="btn btn-sm dep-status" data-service="' + esc(s.name) + '">Status</button></td>' +
'</tr>'
).join('') + '</tbody></table>';
}
async function loadRepos() {
const sel = $('dep-repo-select');
const { body } = await api('/repos');
if (!body.success) {
sel.innerHTML = '<option>' + esc(body.error || 'unavailable') + '</option>';
return;
}
const repos = body.repos || [];
sel.innerHTML = repos.length
? repos.map((r) => '<option value="' + esc(r.dir) + '">' + esc(r.name) + '</option>').join('')
: '<option value="">(no repos with a Shipdeckfile found)</option>';
}
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 = '<em>No journal rows.</em>';
return;
}
el.innerHTML = '<table class="table" style="width:100%;"><thead><tr>' +
'<th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th>' +
'</tr></thead><tbody>' +
rows.map((r) =>
'<tr><td>' + esc(r.time) + '</td><td>' + esc(r.service) + '</td><td>' + esc(r.action) +
'</td><td>' + esc(r.epoch) + '</td><td>' + esc(r.duration || '—') + '</td></tr>'
).join('') + '</tbody></table>';
}
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();
}
})();