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:
157
status/js/container-exec.js
Normal file
157
status/js/container-exec.js
Normal 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user