feat: add 7 new features — exec shell, SSE events, compose import, docker resources, resource limits, email notifications, auto-updates
- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards) - Live dashboard updates via SSE (resource alerts, health changes, update notices) - Docker Compose import with YAML parsing, preview, and dependency-ordered deploy - Volume & network management modal with disk usage overview - CPU/memory resource limits on deploy and live update - Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy - Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly) New deps: ws, js-yaml, nodemailer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
196
status/js/compose-import.js
Normal file
196
status/js/compose-import.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ========== DOCKER COMPOSE IMPORT ==========
|
||||
(function() {
|
||||
injectModal('compose-import-modal', `<div id="compose-import-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
|
||||
<h3>📦 Import Docker Compose</h3>
|
||||
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
|
||||
|
||||
<!-- Step 1: Paste YAML -->
|
||||
<div id="compose-step-paste">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label class="form-label-accent-sm">Stack Name</label>
|
||||
<input type="text" id="compose-stack-name" placeholder="my-stack" value="" style="width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label class="form-label-accent-sm">docker-compose.yml</label>
|
||||
<textarea id="compose-yaml" rows="14" placeholder="version: '3' services: web: image: nginx:latest ports: - '8080:80'" style="width: 100%; padding: 10px; font-family: monospace; font-size: 0.82rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); resize: vertical;"></textarea>
|
||||
<div style="margin-top: 6px;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--muted);">
|
||||
<input type="file" id="compose-file-upload" accept=".yml,.yaml" style="display: none;" />
|
||||
<span style="text-decoration: underline;">or upload a file</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="compose-parse-btn" class="btn-accent-solid" style="padding: 8px 20px;">Parse & Preview</button>
|
||||
<button id="compose-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Preview -->
|
||||
<div id="compose-step-preview" style="display: none;">
|
||||
<div id="compose-preview-content"></div>
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;">
|
||||
<button id="compose-deploy-btn" class="btn-accent-solid" style="padding: 8px 20px;">Deploy All</button>
|
||||
<button id="compose-back-btn">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Progress -->
|
||||
<div id="compose-step-progress" style="display: none;">
|
||||
<div id="compose-progress-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('compose-import-modal');
|
||||
const openBtn = document.getElementById('compose-import-btn');
|
||||
const cancelBtn = document.getElementById('compose-cancel');
|
||||
|
||||
wireModal(modal, cancelBtn);
|
||||
|
||||
let parsedData = null;
|
||||
|
||||
function showStep(step) {
|
||||
document.getElementById('compose-step-paste').style.display = step === 'paste' ? '' : 'none';
|
||||
document.getElementById('compose-step-preview').style.display = step === 'preview' ? '' : 'none';
|
||||
document.getElementById('compose-step-progress').style.display = step === 'progress' ? '' : 'none';
|
||||
}
|
||||
|
||||
openBtn?.addEventListener('click', () => {
|
||||
showStep('paste');
|
||||
parsedData = null;
|
||||
document.getElementById('compose-yaml').value = '';
|
||||
document.getElementById('compose-stack-name').value = '';
|
||||
modal?.classList.add('show');
|
||||
});
|
||||
|
||||
// File upload
|
||||
document.getElementById('compose-file-upload')?.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => { document.getElementById('compose-yaml').value = reader.result; };
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
// Parse
|
||||
document.getElementById('compose-parse-btn')?.addEventListener('click', async () => {
|
||||
const yamlStr = document.getElementById('compose-yaml').value.trim();
|
||||
const stackName = document.getElementById('compose-stack-name').value.trim() || 'stack';
|
||||
if (!yamlStr) { showNotification('Paste a docker-compose.yml', 'warning'); return; }
|
||||
|
||||
const btn = document.getElementById('compose-parse-btn');
|
||||
const origText = btn.textContent;
|
||||
btn.textContent = 'Parsing...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await postJSON('/api/v1/apps/import-compose', { yaml: yamlStr, stackName });
|
||||
parsedData = data;
|
||||
parsedData.stackName = stackName;
|
||||
renderPreview(data);
|
||||
showStep('preview');
|
||||
} catch (e) {
|
||||
showNotification('Parse failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function renderPreview(data) {
|
||||
const container = document.getElementById('compose-preview-content');
|
||||
let html = '';
|
||||
|
||||
if (data.networks && data.networks.length > 0) {
|
||||
html += `<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${data.networks.map(n => `<code>${escapeHtml(n)}</code>`).join(', ')}</div>`;
|
||||
}
|
||||
if (data.volumes && data.volumes.length > 0) {
|
||||
html += `<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${data.volumes.map(v => `<code>${escapeHtml(v)}</code>`).join(', ')}</div>`;
|
||||
}
|
||||
|
||||
html += `<div style="font-weight: 600; margin-bottom: 8px;">${data.services.length} service(s)</div>`;
|
||||
html += '<div class="scroll-container" style="max-height: 350px;">';
|
||||
|
||||
for (const svc of data.services) {
|
||||
const borderColor = svc.skip ? 'var(--bad-fg)' : 'var(--border)';
|
||||
html += `<div style="padding: 10px 14px; border: 1px solid ${borderColor}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`;
|
||||
html += `<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(svc.name)}`;
|
||||
if (svc.skip) html += ` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">— skipped: ${escapeHtml(svc.reason)}</span>`;
|
||||
html += `</div>`;
|
||||
|
||||
if (!svc.skip) {
|
||||
html += `<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(svc.image)}</code></div>`;
|
||||
if (svc.ports?.length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${svc.ports.map(p => `${p.host}:${p.container}`).join(', ')}</div>`;
|
||||
if (svc.volumes?.length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${svc.volumes.length}</div>`;
|
||||
if (Object.keys(svc.environment || {}).length) html += `<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(svc.environment).length}</div>`;
|
||||
if (svc.envFileWarning) html += `<div style="font-size: 0.78rem; color: var(--bad-fg);">⚠ ${escapeHtml(svc.envFileWarning)}</div>`;
|
||||
if (svc.resources?.cpus || svc.resources?.memory) {
|
||||
const parts = [];
|
||||
if (svc.resources.cpus) parts.push(`CPU: ${svc.resources.cpus}`);
|
||||
if (svc.resources.memory) parts.push(`Mem: ${svc.resources.memory}MB`);
|
||||
html += `<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${parts.join(', ')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Back button
|
||||
document.getElementById('compose-back-btn')?.addEventListener('click', () => showStep('paste'));
|
||||
|
||||
// Deploy
|
||||
document.getElementById('compose-deploy-btn')?.addEventListener('click', async () => {
|
||||
if (!parsedData) return;
|
||||
|
||||
const btn = document.getElementById('compose-deploy-btn');
|
||||
btn.textContent = 'Deploying...';
|
||||
btn.disabled = true;
|
||||
showStep('progress');
|
||||
|
||||
const progressEl = document.getElementById('compose-progress-content');
|
||||
progressEl.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';
|
||||
|
||||
try {
|
||||
const result = await postJSON('/api/v1/apps/deploy-compose', {
|
||||
services: parsedData.services,
|
||||
networks: parsedData.networks,
|
||||
stackName: parsedData.stackName
|
||||
});
|
||||
|
||||
let html = `<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(result.stackName)}" — Deployment Complete</div>`;
|
||||
html += '<div class="scroll-container" style="max-height: 350px;">';
|
||||
|
||||
for (const r of result.results) {
|
||||
const icon = r.status === 'deployed' || r.status === 'created' ? '✅' : r.status === 'exists' ? '⚡' : r.status === 'skipped' ? '⏭' : '❌';
|
||||
html += `<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">`;
|
||||
html += `${icon} <strong>${escapeHtml(r.name)}</strong> (${r.type}) — ${escapeHtml(r.status)}`;
|
||||
if (r.error) html += ` <span style="color: var(--bad-fg);">${escapeHtml(r.error)}</span>`;
|
||||
if (r.subdomain) html += ` → <code>${escapeHtml(r.subdomain)}</code>`;
|
||||
if (r.reason) html += ` <span style="color: var(--muted);">(${escapeHtml(r.reason)})</span>`;
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>';
|
||||
|
||||
progressEl.innerHTML = html;
|
||||
document.getElementById('compose-done-btn')?.addEventListener('click', () => {
|
||||
modal?.classList.remove('show');
|
||||
if (typeof window.loadServices === 'function') window.loadServices().then(() => { if (typeof window.buildGrid === 'function') window.buildGrid(); });
|
||||
});
|
||||
|
||||
showNotification(`Stack "${result.stackName}" deployed`, 'success');
|
||||
} catch (e) {
|
||||
progressEl.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Deployment failed: ${escapeHtml(e.message)}</div>
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`;
|
||||
document.getElementById('compose-retry-btn')?.addEventListener('click', () => showStep('paste'));
|
||||
} finally {
|
||||
btn.textContent = 'Deploy All';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user