- 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>
158 lines
4.6 KiB
JavaScript
158 lines
4.6 KiB
JavaScript
// ========== 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;
|
|
})();
|