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