DC-130: deploys panel frontend — modal with services/deploy/journal tabs, rollback + status actions, wired into features bundle + top bar
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||
<button id="deploys-btn" aria-label="Deploys">🚚 Deploys</button>
|
||||
<button id="log-insights-btn" aria-label="Log Insights">🔍 Insights</button>
|
||||
<button onclick="openDiskSettings()" aria-label="Disk Safety">💾 Disk</button>
|
||||
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||
|
||||
@@ -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', `<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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user