[grade=A] Shipdeck fleet module and manual deploy flow
Adds validated fleet install/lifecycle routes, bridge-backed Git and OCI workflows, persistent service cards, and the Local-tab Shipdeck deployment UI while preserving catalog and External flows. Judge: urn:ump:if6udffdyelsf4qvskkr65ikhuprsjiajzij6h653fhf2zgyynzq
This commit is contained in:
@@ -121,6 +121,8 @@
|
||||
if (modalContent) modalContent.scrollTop = 0;
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
const createButton = document.getElementById('add-service-create');
|
||||
if (createButton) { createButton.textContent = 'Deploy with Shipdeck'; createButton.disabled = false; }
|
||||
|
||||
// Set smart SSL default
|
||||
const sslSelect = document.getElementById('ssl-type-select');
|
||||
@@ -170,14 +172,17 @@
|
||||
const tabExternal = document.getElementById('tab-external');
|
||||
|
||||
function switchServiceType() {
|
||||
const createButton = document.getElementById('add-service-create');
|
||||
if (localRadio.checked) {
|
||||
localConfig.style.display = 'grid';
|
||||
externalConfig.style.display = 'none';
|
||||
if (createButton) createButton.textContent = 'Deploy with Shipdeck';
|
||||
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
|
||||
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
|
||||
} else {
|
||||
localConfig.style.display = 'none';
|
||||
externalConfig.style.display = 'block';
|
||||
if (createButton) createButton.textContent = 'Create Service';
|
||||
if (tabExternal) { tabExternal.style.background = 'var(--accent)'; tabExternal.style.color = 'var(--bg)'; }
|
||||
if (tabLocal) { tabLocal.style.background = 'transparent'; tabLocal.style.color = 'var(--muted)'; }
|
||||
}
|
||||
@@ -389,8 +394,17 @@
|
||||
document.getElementById('service-name-input').value = '';
|
||||
document.getElementById('service-subdomain-input').value = '';
|
||||
document.getElementById('service-port-input').value = '';
|
||||
document.getElementById('service-ip-input').value = QUICK_IPS.lan || '';
|
||||
document.getElementById('service-ip-input').value = 'localhost';
|
||||
document.getElementById('service-logo-input').value = '';
|
||||
document.getElementById('service-source-url').value = '';
|
||||
document.getElementById('service-sha256-input').value = '';
|
||||
document.getElementById('service-git-token').value = '';
|
||||
const deployStatus = document.getElementById('shipdeck-deploy-status');
|
||||
if (deployStatus) deployStatus.textContent = '';
|
||||
const shipdeckPreview = document.getElementById('shipdeckfile-preview');
|
||||
if (shipdeckPreview) shipdeckPreview.removeAttribute('open');
|
||||
const shipdeckContent = document.getElementById('shipdeckfile-content');
|
||||
if (shipdeckContent) shipdeckContent.textContent = 'Deploy the service to render its immutable Shipdeckfile.';
|
||||
document.getElementById('dns-ttl-input').value = DC.DEFAULTS.TTL;
|
||||
document.getElementById('ssl-type-select').value = getSmartSslDefault();
|
||||
document.getElementById('ca-name-input').value = '';
|
||||
@@ -438,124 +452,88 @@
|
||||
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
|
||||
}
|
||||
|
||||
// ===== CREATE NEW SERVICE =====
|
||||
// ===== DEPLOY LOCAL SOURCE WITH SHIPDECK =====
|
||||
|
||||
async function loadShipdeckfile(name) {
|
||||
const content = document.getElementById('shipdeckfile-content');
|
||||
if (!name) {
|
||||
if (content) content.textContent = 'Enter a service name, then deploy to render its immutable Shipdeckfile.';
|
||||
return;
|
||||
}
|
||||
if (content) content.textContent = 'Loading Shipdeckfile\u2026';
|
||||
try {
|
||||
const response = await secureFetch(`/api/v1/fleet/shipdeckfile?id=${encodeURIComponent(name)}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Shipdeckfile is not available yet');
|
||||
if (content) content.textContent = result.shipdeckfile;
|
||||
} catch (error) {
|
||||
if (content) content.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewService() {
|
||||
const name = document.getElementById('service-name-input').value.trim();
|
||||
const subdomain = (document.getElementById('service-subdomain-input').value.trim() || deriveSubdomain(name)).toLowerCase();
|
||||
const port = document.getElementById('service-port-input').value.trim();
|
||||
const ip = document.getElementById('service-ip-input').value.trim();
|
||||
const nameLabel = document.getElementById('service-name-input').value.trim();
|
||||
const name = deriveSubdomain(nameLabel);
|
||||
const subdomain = document.getElementById('service-subdomain-input').value.trim().toLowerCase();
|
||||
const port = Number(document.getElementById('service-port-input').value);
|
||||
const repoUrl = document.getElementById('service-source-url').value.trim();
|
||||
const sha256 = document.getElementById('service-sha256-input').value.trim().toLowerCase();
|
||||
const token = document.getElementById('service-git-token').value;
|
||||
const ip = document.getElementById('service-ip-input').value.trim() || 'localhost';
|
||||
const logo = document.getElementById('service-logo-input').value.trim();
|
||||
const createDns = document.getElementById('create-dns-record').checked;
|
||||
const ttl = parseInt(document.getElementById('dns-ttl-input').value) || DC.DEFAULTS.TTL;
|
||||
const tailscaleOnly = document.getElementById('manual-tailscale-only')?.checked || false;
|
||||
const button = document.getElementById('add-service-create');
|
||||
const status = document.getElementById('shipdeck-deploy-status');
|
||||
|
||||
const sslType = document.getElementById('ssl-type-select')?.value || 'caddy-managed';
|
||||
const caName = document.getElementById('ca-name-input')?.value || '';
|
||||
const existingCa = document.getElementById('existing-ca-select')?.value || '';
|
||||
const enableAuth = document.getElementById('enable-auth')?.checked || false;
|
||||
const enableCors = document.getElementById('enable-cors')?.checked || false;
|
||||
const customHeaders = document.getElementById('custom-headers-input')?.value || '';
|
||||
const upstreamPath = document.getElementById('upstream-path-input')?.value || '/';
|
||||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||||
const timeout = document.getElementById('timeout-input')?.value || 30;
|
||||
|
||||
// Category is optional — pulled from either local or external select by the
|
||||
// openAddServiceModal reset. If user doesn't choose one, it stays undefined
|
||||
// and we don't send it (so the backend keeps the existing behavior).
|
||||
const categoryEl = document.getElementById('service-category-input')
|
||||
|| document.getElementById('external-service-category');
|
||||
const category = categoryEl?.value || '';
|
||||
|
||||
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
|
||||
|
||||
if (!name || !port || !ip) {
|
||||
showNotification('Please fill in Name, Port, and IP Address', 'warning');
|
||||
if (!nameLabel || !name || !subdomain || !Number.isInteger(port) || port < 1 || port > 65535 || !repoUrl) {
|
||||
showNotification('Name, Subdomain, Port, and Source URL are required.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!/^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(repoUrl)) {
|
||||
showNotification('Source URL must be a GitHub, Gitea, or Git HTTPS URL.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (sha256 && !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
showNotification('Sha256 pin must be 64 lowercase hex characters.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subdomain) {
|
||||
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (createDns && !dnsToken) {
|
||||
showNotification('DNS Admin token required. Configure it in the Tokens menu first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = { dns: null, caddy: null, dashboard: false };
|
||||
|
||||
const original = button.textContent;
|
||||
let deployed = false;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Building\u2026';
|
||||
if (status) status.textContent = 'Building';
|
||||
try {
|
||||
if (createDns) {
|
||||
try {
|
||||
await window.createDnsRecord(subdomain, ip, ttl);
|
||||
results.dns = 'created';
|
||||
} catch (error) {
|
||||
console.error('DNS creation failed:', error);
|
||||
results.dns = error.message;
|
||||
throw new Error(`DNS creation failed: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
results.dns = 'skipped';
|
||||
}
|
||||
|
||||
const caddyConfig = window.generateCaddyConfig({
|
||||
subdomain, port, ip, sslType, caName, existingCa,
|
||||
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout, tailscaleOnly
|
||||
const payload = { repo_url: repoUrl, name, subdomain, port };
|
||||
if (sha256) payload.sha256 = sha256;
|
||||
if (token) payload.token = token;
|
||||
const response = await secureFetch('/api/v1/fleet/from-git', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
try {
|
||||
const caddyResponse = await secureFetch('/api/v1/site', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: buildDomain(subdomain),
|
||||
upstream: `${ip}:${port}`,
|
||||
config: caddyConfig
|
||||
})
|
||||
});
|
||||
|
||||
const caddyResult = await caddyResponse.json();
|
||||
if (caddyResult.success) {
|
||||
results.caddy = 'added & reloaded';
|
||||
} else {
|
||||
console.error('Caddy configuration failed:', caddyResult.error);
|
||||
results.caddy = caddyResult.error || 'failed';
|
||||
throw new Error(`Caddy configuration failed: ${caddyResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Caddy API error:', error);
|
||||
results.caddy = error.message;
|
||||
throw new Error(`Caddy API error: ${error.message}`);
|
||||
}
|
||||
|
||||
const serviceConfig = {
|
||||
name, subdomain, port, ip,
|
||||
logo: logo || `/assets/${subdomain}.png`,
|
||||
tailscaleOnly: tailscaleOnly || false
|
||||
};
|
||||
// Only include category if user actually picked one
|
||||
if (category) serviceConfig.category = category;
|
||||
|
||||
await window.addServiceToConfig(serviceConfig);
|
||||
results.dashboard = true;
|
||||
|
||||
const statusParts = [
|
||||
`DNS: ${results.dns === 'created' ? '\u2713' : results.dns === 'skipped' ? '\u25CB' : '\u2717'}`,
|
||||
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
|
||||
`Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}`
|
||||
];
|
||||
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 ${buildServiceUrl(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
|
||||
|
||||
closeAddServiceModal();
|
||||
|
||||
button.textContent = 'Deploying\u2026';
|
||||
if (status) status.textContent = 'Building \u2192 Deploying';
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Shipdeck deployment failed');
|
||||
const service = { ...result.service, name: nameLabel, ip, logo: logo || result.service.logo };
|
||||
// /fleet/from-git has already committed this card through the API's
|
||||
// servicesStateManager. Only mirror it in this page's in-memory model;
|
||||
// a second /services write here would race and could overwrite peers.
|
||||
const existing = window.APPS.findIndex(app => app.id === service.id);
|
||||
if (existing >= 0) window.APPS[existing] = { ...window.APPS[existing], ...service };
|
||||
else window.APPS.push(service);
|
||||
await loadShipdeckfile(name);
|
||||
if (status) status.textContent = `Building \u2192 Deploying \u2192 Live \u00b7 journal ${result.journal_row_id || 'recorded'}`;
|
||||
button.textContent = 'Live';
|
||||
deployed = true;
|
||||
window.buildGrid();
|
||||
window.refreshAll();
|
||||
|
||||
showNotification(`Service "${nameLabel}" is live at ${buildServiceUrl(subdomain)} \u00b7 journal ${result.journal_row_id || 'recorded'}`, 'success', 7000);
|
||||
} catch (error) {
|
||||
console.error('Error creating service:', error);
|
||||
showNotification(`Error creating "${name}": ${error.message}`, 'error', 6000);
|
||||
if (status) status.textContent = `Deployment failed: ${error.message}`;
|
||||
showNotification(`Shipdeck deployment failed: ${error.message}`, 'error', 7000);
|
||||
} finally {
|
||||
document.getElementById('service-git-token').value = '';
|
||||
button.disabled = deployed;
|
||||
if (button.textContent !== 'Live') button.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +549,11 @@
|
||||
createNewService();
|
||||
}
|
||||
});
|
||||
document.getElementById('shipdeckfile-preview')?.addEventListener('toggle', (event) => {
|
||||
if (event.target.open) {
|
||||
loadShipdeckfile(deriveSubdomain(document.getElementById('service-name-input')?.value || ''));
|
||||
}
|
||||
});
|
||||
|
||||
setupServiceTypeSwitching();
|
||||
setupAutoSubdomain();
|
||||
|
||||
@@ -162,36 +162,53 @@
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-port-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Port</label>
|
||||
<input type="number" id="service-port-input" placeholder="e.g., 8096" style="font-size: 1rem;" />
|
||||
<label for="service-subdomain-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Subdomain</label>
|
||||
<input type="text" id="service-subdomain-input" placeholder="auto-derived from name" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-ip-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">IP Address</label>
|
||||
<input type="text" id="service-ip-input" placeholder="Auto-detected" style="font-size: 1rem;" />
|
||||
<div class="quick-ip-buttons" style="display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;">
|
||||
<button type="button" class="quick-ip-btn" data-ip="127.0.0.1" title="Localhost" style="font-size: 0.7rem; padding: 2px 6px;">localhost</button>
|
||||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-lan" title="LAN IP" style="font-size: 0.7rem; padding: 2px 6px;">LAN</button>
|
||||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-tailscale" title="Tailscale IP" style="font-size: 0.7rem; padding: 2px 6px;">Tailscale</button>
|
||||
</div>
|
||||
<label for="service-port-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Port</label>
|
||||
<input type="number" id="service-port-input" placeholder="e.g., 8096" min="1" max="65535" required style="font-size: 1rem;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="service-source-url" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Source URL</label>
|
||||
<input type="url" id="service-source-url" placeholder="https://github.com/owner/repo" required style="font-size: 1rem;" />
|
||||
</div>
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-sha256-input">Sha256 pin (optional)</label>
|
||||
<input type="text" id="service-sha256-input" maxlength="64" autocomplete="off" placeholder="64 lowercase hex characters" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-git-token">Token (optional)</label>
|
||||
<input type="password" id="service-git-token" maxlength="512" autocomplete="off" placeholder="Private repositories" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-ip-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Deployed Host / IP</label>
|
||||
<input type="text" id="service-ip-input" value="localhost" placeholder="localhost" style="font-size: 1rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-logo-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Logo URL</label>
|
||||
<input type="text" id="service-logo-input" placeholder="/assets/name.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details id="shipdeckfile-preview">
|
||||
<summary id="shipdeckfile-toggle" style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Show Shipdeckfile</summary>
|
||||
<pre id="shipdeckfile-content" style="white-space: pre-wrap; max-height: 220px; overflow: auto; font-size: 0.72rem; background: var(--card-bg); padding: 10px; border-radius: 6px;">Deploy the service to render its immutable Shipdeckfile.</pre>
|
||||
</details>
|
||||
<div id="shipdeck-deploy-status" aria-live="polite" style="font-size: 0.78rem; color: var(--accent); min-height: 1.2em;"></div>
|
||||
|
||||
<!-- Options (collapsed by default) -->
|
||||
<details id="local-advanced-options">
|
||||
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
|
||||
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-subdomain-input">Subdomain:</label>
|
||||
<input type="text" id="service-subdomain-input" placeholder="auto-derived from name" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-logo-input">Logo URL:</label>
|
||||
<input type="text" id="service-logo-input" placeholder="/assets/name.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: start;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="create-dns-record" checked />
|
||||
|
||||
Reference in New Issue
Block a user