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:
2026-04-05 16:15:14 -07:00
parent b60e7e40d0
commit bdf3f247b1
30 changed files with 2423 additions and 313 deletions

View File

@@ -226,29 +226,47 @@
async function loadAutoConfig() {
try {
autoContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
// Get running containers to show auto-update toggles
const res = await fetch('/api/v1/stats/containers');
const data = await res.json();
const containers = data.success && data.stats ? data.stats : [];
// Fetch containers and saved auto-update config in parallel
const [containersRes, configRes] = await Promise.all([
fetch('/api/v1/stats/containers'),
fetch('/api/v1/updates/auto-update')
]);
const containersData = await containersRes.json();
const configData = await configRes.json();
const containers = containersData.success && containersData.stats ? containersData.stats : [];
const savedConfig = configData.success && configData.config ? configData.config : {};
if (containers.length === 0) {
autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>';
return;
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Schedule</th><th style="padding: 8px; text-align: left;">Auto-Rollback</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
let html = '<div style="margin-bottom: 12px; font-size: 0.8rem; color: var(--muted);">Auto-updates run during maintenance window (default 2AM-4AM). Daily = every day, Weekly = Sundays, Monthly = 1st of month.</div>';
html += '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Schedule</th><th style="padding: 8px; text-align: left;">Window</th><th style="padding: 8px; text-align: left;">Rollback</th><th style="padding: 8px; text-align: left;">Last Run</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
for (const c of containers) {
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
const cid = c.containerId || c.Id;
const saved = savedConfig[cid] || {};
const scheduleVal = saved.enabled ? (saved.schedule || 'weekly') : '';
const rollbackVal = saved.autoRollback !== false;
const windowVal = saved.maintenanceWindow || '';
const lastRun = saved.lastAutoUpdate ? timeAgo(saved.lastAutoUpdate) : 'Never';
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(cid)}">`;
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(name)}</td>`;
html += `<td style="padding: 8px;">
<select class="auto-schedule" data-id="${escapeHtml(cid)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value="">Disabled</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value=""${!scheduleVal ? ' selected' : ''}>Disabled</option>
<option value="daily"${scheduleVal === 'daily' ? ' selected' : ''}>Daily</option>
<option value="weekly"${scheduleVal === 'weekly' ? ' selected' : ''}>Weekly</option>
<option value="monthly"${scheduleVal === 'monthly' ? ' selected' : ''}>Monthly</option>
</select></td>`;
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}" checked /></td>`;
html += `<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(cid)}" value="${escapeHtml(windowVal)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`;
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}"${rollbackVal ? ' checked' : ''} /></td>`;
html += `<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${lastRun}</td>`;
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(cid)}" data-name="${escapeHtml(name)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
html += '</tr>';
}
@@ -262,13 +280,14 @@
const row = btn.closest('tr');
const schedule = row.querySelector('.auto-schedule').value;
const rollback = row.querySelector('.auto-rollback').checked;
const window = row.querySelector('.auto-window').value.trim();
btn.textContent = 'Saving...';
btn.disabled = true;
try {
const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback })
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback, maintenanceWindow: window || undefined })
});
const d = await r.json();
if (d.success) {