Files
dashcaddy/status/js/deploys.js
T
DashCaddy Polish Loop f9cbb13a3d
CI / Test & Lint (push) Waiting to run
CI / Security audit (push) Waiting to run
[grade=B] DC-131/132/133 install from any Git host
Codex source gate: urn:ump:iw2tbbe6mssyl5divymmwo42ael65sbfrciztvyowo3zhrbtircq

Generated assets gate: urn:ump:hintflviuxfpeidth42ry5fi4lwqsjhkxipzspfmk7vrvfc2cnqq
2026-09-14 20:10:14 -07:00

410 lines
17 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.
// DC-133: pure repo-option builder. Remote Gitea fields are UNTRUSTED (a
// hostile instance controls full_name/description/url), so every
// interpolated value must be HTML-escaped before innerHTML. Exposed on
// window so status/tests can pin the escaping with hostile payloads.
window.__dc133_buildRepoOptions = function (repos, escFn) {
var e = escFn || function (s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
};
return '<option value="">— choose a repo —</option>' +
(repos || []).map(function (r) {
return '<option value="' + e(r.url) + '" data-id="' + e(r.id) + '">' +
e(r.full_name) + (r.description ? ' — ' + e(r.description) : '') + '</option>';
}).join('');
};
// Three-state token semantics, exposed for VM tests:
// anonymous=true -> explicit empty; typed value -> token; neither -> omitted.
window.__dc133_requestToken = function (anonymous, typed) {
if (anonymous) return '';
var t = String(typed || '').trim();
return t || undefined;
};
(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><button class="dep-tab" data-tab="install">Install</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>
<!-- INSTALL TAB (DC-131) -->
<div class="dep-panel" data-panel="install" style="display:none;">
<div style="display:grid;gap:10px;max-width:640px;">
<div style="display:grid;gap:4px;">
<span class="modal-subtitle" style="margin:0;">Gitea server (any instance)</span>
<div style="display:flex;gap:8px;">
<input id="dep-gh-gitea-host" placeholder="git.dashcaddy.net (default)" style="flex:1;" autocomplete="off" spellcheck="false" />
<input id="dep-gh-gitea-token" type="password" placeholder="token (private repos)" style="flex:1;" autocomplete="off" spellcheck="false" />
</div>
<label style="display:flex;gap:6px;align-items:center;font-size:12px;">
<input id="dep-gh-anonymous" type="checkbox" />
Anonymous — ignore the saved fleet token for this request
</label>
</div>
<label style="display:grid;gap:4px;">
<span class="modal-subtitle" style="margin:0;">Pick a repo</span>
<select id="dep-gh-gitea" style="min-width:280px;"><option value="">Loading…</option></select>
</label>
<label style="display:grid;gap:4px;">
<span class="modal-subtitle" style="margin:0;">…or paste any repo URL (GitHub, Gitea, or any https git host)</span>
<input id="dep-gh-url" placeholder="https://host/owner/repo" style="width:100%;" />
</label>
<label style="display:grid;gap:4px;">
<span class="modal-subtitle" style="margin:0;">Name (used as subdomain; card title falls back to repo name)</span>
<input id="dep-gh-service" placeholder="my-app" style="width:220px;" />
</label>
<label style="display:grid;gap:4px;">
<span class="modal-subtitle" style="margin:0;">Optional launch args (space-separated simple tokens, e.g. -text hi)</span>
<input id="dep-gh-args" placeholder="-text hello" style="width:100%;" />
</label>
<p class="modal-subtitle" style="margin:0;">
Go repos with a main package install automatically: build, systemd release,
Caddy gate (tailnet-only), DNS, verify, dashboard card. Takes ~30-60s.
</p>
<div><button class="btn btn-primary" id="dep-gh-btn">Install</button></div>
</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>';
}
async function loadGiteaRepos() {
const sel = document.getElementById('dep-gh-gitea');
const hostEl = document.getElementById('dep-gh-gitea-host');
const tokEl = document.getElementById('dep-gh-gitea-token');
const anonEl = document.getElementById('dep-gh-anonymous');
const payload = {};
if (hostEl && hostEl.value.trim()) payload.gitea_url = 'https://' + hostEl.value.trim().replace(/^https?:\/*/, '');
// Distinguish omitted (fleet fallback allowed) from explicit anonymous
// (empty token preserved on wire). A non-empty user token wins.
if (anonEl && anonEl.checked) payload.token = '';
else {
const t = window.__dc133_requestToken(false, tokEl && tokEl.value);
if (t !== undefined) payload.token = t;
}
try {
const { status, body } = await api('/gitea-repos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!body.success || !(body.repos || []).length) {
sel.innerHTML = '<option value="">' + esc(body.error || 'no repos found') + '</option>';
return;
}
// All remote fields are untrusted (hostile Gitea instance or repo
// metadata) — build options through the escaping helper (pinned by
// status/tests/deploys-install.test.js).
sel.innerHTML = window.__dc133_buildRepoOptions(body.repos, esc);
} catch (e) {
sel.innerHTML = '<option value="">unavailable</option>';
}
}
async function runInstall() {
const btn = document.getElementById('dep-gh-btn');
const repoUrl = document.getElementById('dep-gh-url').value.trim();
const service = (document.getElementById('dep-gh-service').value.trim() || '').toLowerCase();
const argsRaw = document.getElementById('dep-gh-args').value.trim();
if (!repoUrl || !service) { showOutput('Repo URL and name are required.'); return; }
const args = argsRaw ? argsRaw.split(/\s+/) : [];
setBusy(true, btn, 'Install');
showOutput('Installing ' + repoUrl + '\nCloning, building, gating and DNS-ing... (~30-60s)');
try {
const anonymous = !!(document.getElementById('dep-gh-anonymous') || {}).checked;
const typedToken = (document.getElementById('dep-gh-gitea-token') || { value: '' }).value;
const requestToken = window.__dc133_requestToken(anonymous, typedToken);
const { status, body } = await api('/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: repoUrl, service, args,
token: requestToken }),
});
if (!body.success) {
showOutput((body.output ? body.output + '\n' : '') + 'FAIL ' + (body.error || ('HTTP ' + status)));
setBusy(false, btn, 'Install');
return;
}
const svc = body.service || {};
const exists = (window.APPS || []).some((a) => a.id === svc.id);
if (!exists) {
const card = { id: svc.id, name: svc.name || svc.id, url: svc.url, logo: svc.logo, tailscaleOnly: true, isCustom: true };
try {
await fetch('/dashcaddy-api/api/v1/services', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(card),
});
window.APPS.push(card);
if (typeof window.renderApps === 'function') window.renderApps();
if (typeof window.renderGrid === 'function') window.renderGrid();
} catch (e) { /* card registration best-effort */ }
}
showOutput('INSTALLED ' + (svc.name || svc.id) + ' in ' + (svc.deploy_seconds || '?') + 's' +
'\nCard: ' + (svc.url || '') +
'\n' + (body.output || '').slice(-800));
loadServices();
} catch (e) {
showOutput('install error: ' + e.message);
}
setBusy(false, btn, 'Install');
}
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('');
loadGiteaRepos();
}
});
$('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-gh-btn').addEventListener('click', runInstall);
['dep-gh-gitea-host', 'dep-gh-gitea-token'].forEach((id) => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', () => loadGiteaRepos());
});
document.getElementById('dep-gh-gitea').addEventListener('change', (e) => {
const v = e.target.value;
if (v) {
document.getElementById('dep-gh-url').value = v;
const opt = e.target.selectedOptions[0];
const id = opt && opt.dataset ? opt.dataset.id : '';
const svc = document.getElementById('dep-gh-service');
if (id && !svc.value) svc.value = id;
}
});
$('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();
}
})();