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

157
status/js/container-exec.js Normal file
View File

@@ -0,0 +1,157 @@
// ========== CONTAINER EXEC / SHELL (WebSocket + xterm.js) ==========
(function() {
injectModal('exec-modal', `<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 8px; padding-bottom: 12px;">
<button id="exec-close">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('exec-modal');
const termEl = document.getElementById('exec-terminal');
const closeBtn = document.getElementById('exec-close');
let term = null;
let ws = null;
let fitAddon = null;
function cleanup() {
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
if (term) { try { term.dispose(); } catch (_) {} term = null; }
fitAddon = null;
termEl.innerHTML = '';
}
function openExec(containerId, containerName) {
cleanup();
document.getElementById('exec-title').textContent = `Terminal — ${containerName || containerId}`;
modal?.classList.add('show');
// Ensure xterm is available
if (typeof Terminal === 'undefined') {
termEl.innerHTML = '<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';
return;
}
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#aeafad',
selectionBackground: '#264f78',
},
scrollback: 5000,
});
// Fit addon
if (typeof FitAddon !== 'undefined') {
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
}
term.open(termEl);
if (fitAddon) {
// Small delay for DOM to settle
setTimeout(() => fitAddon.fit(), 50);
}
// Connect WebSocket
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}/ws/exec/${encodeURIComponent(containerId)}`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
term.writeln('\x1b[32mConnecting...\x1b[0m');
// Send initial resize
if (fitAddon) {
const dims = fitAddon.proposeDimensions();
if (dims) {
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
}
}
};
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'connected') {
term.writeln(`\x1b[32mConnected (${msg.shell})\x1b[0m\r\n`);
return;
}
if (msg.type === 'error') {
term.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`);
return;
}
if (msg.type === 'exit') {
term.writeln('\r\n\x1b[33mSession ended.\x1b[0m');
return;
}
} catch (_) {}
// Plain text
term.write(e.data);
} else {
// Binary data
term.write(new Uint8Array(e.data));
}
};
ws.onclose = () => {
if (term) term.writeln('\r\n\x1b[33mDisconnected.\x1b[0m');
};
ws.onerror = () => {
if (term) term.writeln('\r\n\x1b[31mConnection error.\x1b[0m');
};
// Terminal input → WebSocket
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Handle resize
term.onResize(({ cols, rows }) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
// Re-fit on window resize
const resizeHandler = () => { if (fitAddon) fitAddon.fit(); };
window.addEventListener('resize', resizeHandler);
// Store handler for cleanup
modal._resizeHandler = resizeHandler;
}
closeBtn?.addEventListener('click', () => {
cleanup();
if (modal._resizeHandler) {
window.removeEventListener('resize', modal._resizeHandler);
}
modal?.classList.remove('show');
});
// Also close on backdrop click
modal?.addEventListener('click', (e) => {
if (e.target === modal) {
cleanup();
if (modal._resizeHandler) {
window.removeEventListener('resize', modal._resizeHandler);
}
modal?.classList.remove('show');
}
});
// Export
window.openExecModal = openExec;
})();