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:
@@ -181,6 +181,22 @@
|
||||
</div>
|
||||
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<label class="form-label-accent-sm">⚙️ Resource Limits</label>
|
||||
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
||||
Optional CPU and memory constraints. Leave at 0 for unlimited.
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||
<div>
|
||||
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
|
||||
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
|
||||
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -920,7 +936,11 @@
|
||||
tailscaleOnly: document.getElementById('deploy-tailscale-only').checked,
|
||||
mediaPath: mediaPath || null,
|
||||
plexClaimToken: document.getElementById('deploy-plex-claim')?.value.trim() || null,
|
||||
customVolumes: customVolumes.length > 0 ? customVolumes : null
|
||||
customVolumes: customVolumes.length > 0 ? customVolumes : null,
|
||||
resources: {
|
||||
cpus: parseFloat(document.getElementById('deploy-cpu-limit').value) || 0,
|
||||
memory: parseFloat(document.getElementById('deploy-memory-limit').value) || 0,
|
||||
}
|
||||
};
|
||||
|
||||
// Validate subdomain
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
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;
|
||||
})();
|
||||
@@ -211,6 +211,15 @@
|
||||
window.updateContainer(s.containerId, s.name, s.id);
|
||||
};
|
||||
btnRow.appendChild(updateBtn);
|
||||
|
||||
// Terminal exec button (subtle — visible on hover)
|
||||
const execBtn = el('button', 'exec-btn', '>_');
|
||||
execBtn.title = 'Open terminal';
|
||||
execBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.openExecModal) window.openExecModal(s.containerId, s.name);
|
||||
};
|
||||
btnRow.appendChild(execBtn);
|
||||
}
|
||||
|
||||
// Add logs button for services with logPath (native apps)
|
||||
|
||||
228
status/js/docker-resources.js
Normal file
228
status/js/docker-resources.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// ========== DOCKER RESOURCES (Volumes, Networks, Disk Usage) ==========
|
||||
(function() {
|
||||
injectModal('docker-resources-modal', `<div id="docker-resources-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>🐳 Docker Resources</h3>
|
||||
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" data-panel="dr-volumes">Volumes</button>
|
||||
<button class="panel-tab" data-panel="dr-networks">Networks</button>
|
||||
<button class="panel-tab" data-panel="dr-disk">Disk Usage</button>
|
||||
</div>
|
||||
|
||||
<!-- Volumes -->
|
||||
<div id="dr-volumes" class="panel-section active">
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||
<input type="text" id="dr-vol-name" placeholder="Volume name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
||||
<button id="dr-vol-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
||||
</div>
|
||||
<div id="dr-vol-list" class="scroll-container" style="max-height: 400px;">
|
||||
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Networks -->
|
||||
<div id="dr-networks" class="panel-section">
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
||||
<input type="text" id="dr-net-name" placeholder="Network name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
||||
<select id="dr-net-driver" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
|
||||
<option value="bridge">bridge</option>
|
||||
<option value="overlay">overlay</option>
|
||||
<option value="host">host</option>
|
||||
</select>
|
||||
<button id="dr-net-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
||||
</div>
|
||||
<div id="dr-net-list" class="scroll-container" style="max-height: 400px;">
|
||||
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disk Usage -->
|
||||
<div id="dr-disk" class="panel-section">
|
||||
<div id="dr-disk-content">
|
||||
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="dr-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('docker-resources-modal');
|
||||
const openBtn = document.getElementById('docker-resources-btn');
|
||||
const closeBtn = document.getElementById('dr-close');
|
||||
|
||||
function fmtBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// ===== VOLUMES =====
|
||||
async function loadVolumes() {
|
||||
const container = document.getElementById('dr-vol-list');
|
||||
try {
|
||||
const data = await getJSON('/api/v1/docker/volumes');
|
||||
const vols = data.volumes || [];
|
||||
if (vols.length === 0) {
|
||||
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">📦</span>No volumes found.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';
|
||||
for (const v of vols) {
|
||||
const isSystem = v.name === 'buildkit' || v.name.length === 64;
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(v.name)}">${escapeHtml(v.name.length > 40 ? v.name.substring(0, 37) + '...' : v.name)}</td>`;
|
||||
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(v.driver)}</td>`;
|
||||
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(v.scope)}</td>`;
|
||||
html += `<td style="padding: 6px; text-align: right;">`;
|
||||
if (!isSystem) {
|
||||
html += `<button class="dr-vol-del" data-name="${escapeHtml(v.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`;
|
||||
}
|
||||
html += `</td></tr>`;
|
||||
}
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll('.dr-vol-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete volume "${btn.dataset.name}"? Data will be lost.`)) return;
|
||||
btn.textContent = '...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(btn.dataset.name)}?force=true`);
|
||||
loadVolumes();
|
||||
} catch (e) {
|
||||
showNotification('Delete failed: ' + e.message, 'error');
|
||||
btn.textContent = 'Delete';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('dr-vol-create')?.addEventListener('click', async () => {
|
||||
const nameInput = document.getElementById('dr-vol-name');
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { showNotification('Enter a volume name', 'warning'); return; }
|
||||
try {
|
||||
await postJSON('/api/v1/docker/volumes', { name });
|
||||
nameInput.value = '';
|
||||
showNotification(`Volume "${name}" created`, 'success');
|
||||
loadVolumes();
|
||||
} catch (e) {
|
||||
showNotification('Create failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== NETWORKS =====
|
||||
async function loadNetworks() {
|
||||
const container = document.getElementById('dr-net-list');
|
||||
try {
|
||||
const data = await getJSON('/api/v1/docker/networks');
|
||||
const nets = data.networks || [];
|
||||
if (nets.length === 0) {
|
||||
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">🌐</span>No networks found.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';
|
||||
for (const n of nets) {
|
||||
const isSystem = ['bridge', 'host', 'none'].includes(n.name);
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(n.name)}</td>`;
|
||||
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(n.driver)}</td>`;
|
||||
html += `<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(n.scope)}</td>`;
|
||||
html += `<td style="padding: 6px; text-align: center;">${n.containers}</td>`;
|
||||
html += `<td style="padding: 6px; text-align: right;">`;
|
||||
if (!isSystem) {
|
||||
html += `<button class="dr-net-del" data-id="${escapeHtml(n.id)}" data-name="${escapeHtml(n.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`;
|
||||
}
|
||||
html += `</td></tr>`;
|
||||
}
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll('.dr-net-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete network "${btn.dataset.name}"?`)) return;
|
||||
btn.textContent = '...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(btn.dataset.id)}`);
|
||||
loadNetworks();
|
||||
} catch (e) {
|
||||
showNotification('Delete failed: ' + e.message, 'error');
|
||||
btn.textContent = 'Delete';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('dr-net-create')?.addEventListener('click', async () => {
|
||||
const nameInput = document.getElementById('dr-net-name');
|
||||
const driverSelect = document.getElementById('dr-net-driver');
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { showNotification('Enter a network name', 'warning'); return; }
|
||||
try {
|
||||
await postJSON('/api/v1/docker/networks', { name, driver: driverSelect.value });
|
||||
nameInput.value = '';
|
||||
showNotification(`Network "${name}" created`, 'success');
|
||||
loadNetworks();
|
||||
} catch (e) {
|
||||
showNotification('Create failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== DISK USAGE =====
|
||||
async function loadDiskUsage() {
|
||||
const container = document.getElementById('dr-disk-content');
|
||||
try {
|
||||
const data = await getJSON('/api/v1/docker/disk-usage');
|
||||
const sections = [
|
||||
{ label: 'Images', icon: '📀', count: data.images.count, size: data.images.size, reclaimable: data.images.reclaimable },
|
||||
{ label: 'Containers', icon: '📦', count: data.containers.count, size: data.containers.size, extra: `${data.containers.running} running` },
|
||||
{ label: 'Volumes', icon: '💾', count: data.volumes.count, size: data.volumes.size, reclaimable: data.volumes.reclaimable },
|
||||
{ label: 'Build Cache', icon: '🔧', count: data.buildCache.count, size: data.buildCache.size, reclaimable: data.buildCache.reclaimable },
|
||||
];
|
||||
|
||||
let html = `<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 16px;">Total: ${fmtBytes(data.totalSize)}</div>`;
|
||||
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">';
|
||||
for (const s of sections) {
|
||||
html += `<div style="padding: 14px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border);">`;
|
||||
html += `<div style="font-weight: 600; margin-bottom: 6px;">${s.icon} ${s.label} <span style="color: var(--muted); font-weight: 400; font-size: 0.82rem;">(${s.count})</span></div>`;
|
||||
html += `<div style="font-size: 1.1rem; font-weight: 600; color: var(--accent);">${fmtBytes(s.size)}</div>`;
|
||||
if (s.reclaimable > 0) html += `<div style="font-size: 0.78rem; color: var(--muted);">Reclaimable: ${fmtBytes(s.reclaimable)}</div>`;
|
||||
if (s.extra) html += `<div style="font-size: 0.78rem; color: var(--muted);">${s.extra}</div>`;
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal events
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal?.classList.add('show');
|
||||
loadVolumes();
|
||||
});
|
||||
wireModal(modal, closeBtn);
|
||||
|
||||
// Lazy-load tabs
|
||||
document.querySelector('[data-panel="dr-networks"]')?.addEventListener('click', loadNetworks);
|
||||
document.querySelector('[data-panel="dr-disk"]')?.addEventListener('click', loadDiskUsage);
|
||||
})();
|
||||
115
status/js/live-events.js
Normal file
115
status/js/live-events.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ========== LIVE DASHBOARD EVENTS (SSE) ==========
|
||||
(function() {
|
||||
let es = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT = 30000;
|
||||
|
||||
function connect() {
|
||||
if (es) { try { es.close(); } catch (_) {} }
|
||||
|
||||
es = new EventSource('/api/v1/events/stream');
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
reconnectDelay = 1000; // reset backoff
|
||||
console.log('[SSE] Connected to event stream');
|
||||
});
|
||||
|
||||
// Health status changes → update card dots/badges in real time
|
||||
es.addEventListener('status-change', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.serviceId && typeof window.setBadge === 'function') {
|
||||
const up = d.status === 'up' || d.status === 'healthy';
|
||||
window.setBadge(d.serviceId, up, d.responseTime || null);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Resource alerts → toast notification
|
||||
es.addEventListener('resource-alert', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
const msg = `${d.containerName || d.containerId}: ${d.metric} at ${d.value}% (threshold: ${d.threshold}%)`;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(msg, 'warning');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Container auto-restart
|
||||
es.addEventListener('auto-restart', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`Container "${d.containerName}" was auto-restarted`, 'info');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Update available → show notification dot on Updates button
|
||||
es.addEventListener('update-available', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
const updatesBtn = document.getElementById('updates-btn');
|
||||
if (updatesBtn && !updatesBtn.querySelector('.sse-dot')) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'sse-dot';
|
||||
dot.style.cssText = 'display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;';
|
||||
updatesBtn.appendChild(dot);
|
||||
}
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`Update available for ${d.containerName || d.containerId}`, 'info');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Update start/complete/failed
|
||||
es.addEventListener('update-complete', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`Update completed: ${d.containerName || d.containerId}`, 'success');
|
||||
}
|
||||
// Trigger a dashboard refresh
|
||||
if (typeof window.refreshAll === 'function') window.refreshAll();
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
es.addEventListener('update-failed', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`Update failed: ${d.containerName || d.containerId} — ${d.error || 'unknown error'}`, 'error');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Incidents
|
||||
es.addEventListener('incident', (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (typeof showNotification === 'function') {
|
||||
if (d.type === 'created') {
|
||||
showNotification(`Incident: ${d.message || d.serviceId}`, 'error');
|
||||
} else if (d.type === 'resolved') {
|
||||
showNotification(`Resolved: ${d.serviceId || 'incident'}`, 'success');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Reconnect on error
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
console.warn(`[SSE] Disconnected, reconnecting in ${reconnectDelay / 1000}s...`);
|
||||
setTimeout(connect, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT);
|
||||
};
|
||||
}
|
||||
|
||||
// Start on page load
|
||||
connect();
|
||||
|
||||
// Expose for debugging
|
||||
window._sseReconnect = connect;
|
||||
})();
|
||||
@@ -80,6 +80,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="notification-provider provider-card">
|
||||
<div class="provider-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="email-enabled" />
|
||||
<span class="fw-500">Email (SMTP)</span>
|
||||
</label>
|
||||
<button id="email-test" class="test-btn btn-xs">Test</button>
|
||||
</div>
|
||||
<div id="email-config" style="display: none;">
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">
|
||||
<div>
|
||||
<label class="field-label-sm">SMTP Host:</label>
|
||||
<input type="text" id="email-host" placeholder="smtp.gmail.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label-sm">Port:</label>
|
||||
<input type="number" id="email-port" value="587" placeholder="587" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
||||
<div>
|
||||
<label class="field-label-sm">Username:</label>
|
||||
<input type="text" id="email-user" placeholder="user@gmail.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label-sm">Password:</label>
|
||||
<input type="password" id="email-pass" placeholder="app password" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label class="field-label-sm">From:</label>
|
||||
<input type="text" id="email-from" placeholder="DashCaddy <noreply@example.com>" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label-sm">To:</label>
|
||||
<input type="text" id="email-to" placeholder="admin@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<label style="display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 0.8rem;">
|
||||
<input type="checkbox" id="email-secure" /> Use TLS (port 465)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Check Settings -->
|
||||
<h4 class="section-heading">Health Monitoring</h4>
|
||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
@@ -143,7 +189,7 @@
|
||||
const cancelBtn = document.getElementById('notifications-cancel');
|
||||
|
||||
// Provider toggle handlers
|
||||
['discord', 'telegram', 'ntfy'].forEach(provider => {
|
||||
['discord', 'telegram', 'ntfy', 'email'].forEach(provider => {
|
||||
const checkbox = document.getElementById(`${provider}-enabled`);
|
||||
const config = document.getElementById(`${provider}-config`);
|
||||
|
||||
@@ -175,17 +221,23 @@
|
||||
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
|
||||
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
|
||||
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false;
|
||||
document.getElementById('email-enabled').checked = config.providers?.email?.enabled || false;
|
||||
|
||||
// Show/hide config sections
|
||||
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none';
|
||||
document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
|
||||
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none';
|
||||
document.getElementById('email-config').style.display = config.providers?.email?.enabled ? 'block' : 'none';
|
||||
|
||||
// ntfy server URL
|
||||
if (config.providers?.ntfy?.serverUrl) {
|
||||
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
||||
}
|
||||
|
||||
// email fields
|
||||
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
|
||||
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
|
||||
|
||||
// Health check
|
||||
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
||||
if (config.healthCheck?.intervalMinutes) {
|
||||
@@ -260,6 +312,16 @@
|
||||
enabled: document.getElementById('ntfy-enabled').checked,
|
||||
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
|
||||
topic: document.getElementById('ntfy-topic').value.trim()
|
||||
},
|
||||
email: {
|
||||
enabled: document.getElementById('email-enabled').checked,
|
||||
host: document.getElementById('email-host').value.trim(),
|
||||
port: parseInt(document.getElementById('email-port').value) || 587,
|
||||
secure: document.getElementById('email-secure').checked,
|
||||
user: document.getElementById('email-user').value.trim(),
|
||||
pass: document.getElementById('email-pass').value.trim(),
|
||||
from: document.getElementById('email-from').value.trim(),
|
||||
to: document.getElementById('email-to').value.trim()
|
||||
}
|
||||
},
|
||||
events: {
|
||||
@@ -315,6 +377,7 @@
|
||||
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
|
||||
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
|
||||
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
|
||||
document.getElementById('email-test')?.addEventListener('click', () => testProvider('email'));
|
||||
|
||||
// Health check now button
|
||||
document.getElementById('health-check-now')?.addEventListener('click', async () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
2
status/js/xterm-fit.min.js
vendored
Normal file
2
status/js/xterm-fit.min.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
2
status/js/xterm.min.js
vendored
Normal file
2
status/js/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user