Codex source gate: urn:ump:iw2tbbe6mssyl5divymmwo42ael65sbfrciztvyowo3zhrbtircq Generated assets gate: urn:ump:hintflviuxfpeidth42ry5fi4lwqsjhkxipzspfmk7vrvfc2cnqq
410 lines
17 KiB
JavaScript
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
|
}
|
|
})();
|