feat (ui): voeg Volumes tabblad toe aan webui
Nieuw tabblad na Images met: - Tabel: Naam, Driver, Mountpoint (afgekapt + tooltip), Aangemaakt (relatieve tijd), Labels (pills), Containers (pills via Mounts koppeling) - Toolbar: Ververs, + Volume, Prune (met bevestigingsdialoog) - Verwijder knop per rij (uitgeschakeld als volume in gebruik) - Create Volume modal: naam (verplicht) + labels (key=value per regel) - Lege staat via renderStateBox volumes.js: _volEsc() voor XSS-safe rendering, encodeURIComponent voor onclick-handlers, parallel fetch volumes + containers-dashboard voor container-koppeling via Mounts[].Name. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
let volumesData = [];
|
||||
let volumeContainersMap = {};
|
||||
|
||||
async function loadVolumes() {
|
||||
const tbody = document.getElementById("volumes-tbody");
|
||||
try {
|
||||
const [volumes, containers] = await Promise.all([
|
||||
fetch("/api/volumes").then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }),
|
||||
fetch("/api/containers-dashboard").then(r => r.ok ? r.json() : []).catch(() => [])
|
||||
]);
|
||||
|
||||
volumesData = Array.isArray(volumes) ? volumes : [];
|
||||
|
||||
// Bouw volume → containers mapping op basis van Mounts
|
||||
volumeContainersMap = {};
|
||||
for (const c of (Array.isArray(containers) ? containers : [])) {
|
||||
const cname = (c.Names && c.Names[0]) || "";
|
||||
for (const m of (c.Mounts || [])) {
|
||||
if (m.Name) {
|
||||
(volumeContainersMap[m.Name] = volumeContainersMap[m.Name] || []).push(cname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.updateNavCount === "function") {
|
||||
window.updateNavCount("countNavVolumes", volumesData.length);
|
||||
}
|
||||
renderVolumes(volumesData);
|
||||
} catch (e) {
|
||||
volumesData = [];
|
||||
if (typeof window.updateNavCount === "function") window.updateNavCount("countNavVolumes", 0);
|
||||
if (tbody) {
|
||||
const box = typeof window.renderStateBox === "function"
|
||||
? window.renderStateBox("error", "Volumes laden mislukt", e.message || String(e))
|
||||
: "Volumes laden mislukt.";
|
||||
tbody.innerHTML = `<tr><td colspan="7">${box}</td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _volRelTime(isoStr) {
|
||||
if (!isoStr) return "-";
|
||||
const d = new Date(isoStr);
|
||||
if (isNaN(d)) return String(isoStr);
|
||||
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s geleden`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m geleden`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}u geleden`;
|
||||
return `${Math.floor(s / 86400)} dagen geleden`;
|
||||
}
|
||||
|
||||
function _volEsc(s) {
|
||||
return String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function renderVolumes(volumes) {
|
||||
const tbody = document.getElementById("volumes-tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (!volumes.length) {
|
||||
const box = typeof window.renderStateBox === "function"
|
||||
? window.renderStateBox("empty", "Geen volumes", "Er zijn momenteel geen volumes gevonden.")
|
||||
: "Geen volumes gevonden.";
|
||||
tbody.innerHTML = `<tr><td colspan="7">${box}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
volumes.forEach(vol => {
|
||||
const name = vol.Name || "-";
|
||||
const driver = vol.Driver || "-";
|
||||
const mp = vol.Mountpoint || "";
|
||||
const mpShort = mp.length > 45 ? mp.slice(0, 42) + "…" : mp;
|
||||
const created = _volRelTime(vol.CreatedAt);
|
||||
const labels = vol.Labels || {};
|
||||
const cNames = volumeContainersMap[name] || [];
|
||||
const inUse = cNames.length > 0;
|
||||
|
||||
const labelHtml = Object.keys(labels).length
|
||||
? Object.keys(labels).map(k =>
|
||||
`<span class="badge muted" title="${_volEsc(k + "=" + labels[k])}">${_volEsc(k)}</span>`
|
||||
).join(" ")
|
||||
: `<span class="muted">-</span>`;
|
||||
|
||||
const containersHtml = cNames.length
|
||||
? cNames.map(n => `<span class="badge ok">${_volEsc(n)}</span>`).join(" ")
|
||||
: `<span class="muted">-</span>`;
|
||||
|
||||
const nameEnc = encodeURIComponent(name);
|
||||
const disabledAttr = inUse ? `disabled title="In gebruik door een container"` : "";
|
||||
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><strong>${_volEsc(name)}</strong></td>
|
||||
<td class="muted">${_volEsc(driver)}</td>
|
||||
<td class="muted mono" title="${_volEsc(mp)}">${_volEsc(mpShort)}</td>
|
||||
<td class="muted">${created}</td>
|
||||
<td>${labelHtml}</td>
|
||||
<td>${containersHtml}</td>
|
||||
<td>
|
||||
<button class="btn small bad" onclick="removeVolume(decodeURIComponent('${nameEnc}'))" ${disabledAttr}>
|
||||
Verwijder
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function removeVolume(name) {
|
||||
if (!confirm(`Volume '${name}' verwijderen?\nDit kan niet ongedaan worden gemaakt.`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/volumes/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
alert(`Verwijderen mislukt (${res.status}): ${body}`);
|
||||
return;
|
||||
}
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
alert(`Fout: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneVolumes() {
|
||||
if (!confirm(
|
||||
"Prune volumes\n\n" +
|
||||
"Dit verwijdert alle volumes die niet aan een container gekoppeld zijn.\n" +
|
||||
"Dit kan niet ongedaan worden gemaakt.\n\n" +
|
||||
"Doorgaan?"
|
||||
)) return;
|
||||
try {
|
||||
const res = await fetch("/api/volumes/prune", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
alert(`Prune mislukt (${res.status}): ${body}`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const removed = Array.isArray(data) ? data.length : 0;
|
||||
alert(`Prune voltooid. ${removed} volume(s) verwijderd.`);
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
alert(`Fout: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Create Volume Modal ----
|
||||
|
||||
function openCreateVolumeModal() {
|
||||
document.getElementById("createVolumeModalBack").style.display = "flex";
|
||||
document.getElementById("createVolumeName").value = "";
|
||||
document.getElementById("createVolumeLabels").value = "";
|
||||
}
|
||||
|
||||
function hideCreateVolumeModal() {
|
||||
document.getElementById("createVolumeModalBack").style.display = "none";
|
||||
}
|
||||
|
||||
function closeCreateVolumeModal(e) {
|
||||
if (e.target.id === "createVolumeModalBack") hideCreateVolumeModal();
|
||||
}
|
||||
|
||||
async function createVolume() {
|
||||
const name = document.getElementById("createVolumeName").value.trim();
|
||||
if (!name) { alert("Naam is verplicht."); return; }
|
||||
|
||||
const labelsRaw = document.getElementById("createVolumeLabels").value.trim();
|
||||
const labels = {};
|
||||
if (labelsRaw) {
|
||||
for (const line of labelsRaw.split(/\r?\n/)) {
|
||||
const l = line.trim();
|
||||
if (!l) continue;
|
||||
const idx = l.indexOf("=");
|
||||
if (idx > 0) labels[l.slice(0, idx).trim()] = l.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const body = { name };
|
||||
if (Object.keys(labels).length) body.labels = labels;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/volumes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => "");
|
||||
alert(`Aanmaken mislukt (${res.status}): ${err}`);
|
||||
return;
|
||||
}
|
||||
hideCreateVolumeModal();
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
alert(`Fout: ${e.message}`);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@
|
||||
<div class="tab" id="tab-images" onclick="setTab('images')" title="Images">
|
||||
<span class="navIcon">📦</span><span class="navLabel">Images</span><span class="navCount" id="countNavImages">-</span>
|
||||
</div>
|
||||
<div class="tab" id="tab-volumes" onclick="setTab('volumes')" title="Volumes">
|
||||
<span class="navIcon">🗄️</span><span class="navLabel">Volumes</span><span class="navCount" id="countNavVolumes">-</span>
|
||||
</div>
|
||||
<div class="tab" id="tab-files" onclick="setTab('files')" title="Files">
|
||||
<span class="navIcon">📁</span><span class="navLabel">Files</span><span class="navCount" id="countNavFiles">-</span>
|
||||
</div>
|
||||
@@ -305,6 +308,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-volumes" class="grid" style="display:none">
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="cardHeader">
|
||||
<div class="cardTitle">Volumes</div>
|
||||
<div class="flex">
|
||||
<button class="btn" onclick="loadVolumes()">Ververs</button>
|
||||
<button class="btn ok" onclick="openCreateVolumeModal()">+ Volume</button>
|
||||
<button class="btn bad" onclick="pruneVolumes()">Prune</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Driver</th>
|
||||
<th>Mountpoint</th>
|
||||
<th>Aangemaakt</th>
|
||||
<th>Labels</th>
|
||||
<th>Containers</th>
|
||||
<th style="width:90px;">Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="volumes-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-files" class="grid" style="display:none">
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="cardHeader">
|
||||
@@ -395,6 +427,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Volume Modal -->
|
||||
<div class="modalBack" id="createVolumeModalBack" style="display:none;" onclick="closeCreateVolumeModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()" style="width:460px;">
|
||||
<div class="modalHeader">
|
||||
<div class="modalTitle">Volume aanmaken</div>
|
||||
<button class="btn small ghost" onclick="hideCreateVolumeModal()">Sluiten</button>
|
||||
</div>
|
||||
<div class="modalBody">
|
||||
<div style="margin-bottom:12px;">
|
||||
<label class="label">Naam <span style="color:var(--bad)">*</span></label>
|
||||
<input id="createVolumeName" class="input" type="text" placeholder="mijn-volume" style="width:100%;" />
|
||||
</div>
|
||||
<div style="margin-bottom:16px;">
|
||||
<label class="label">Labels <span class="muted">(optioneel, één key=value per regel)</span></label>
|
||||
<textarea id="createVolumeLabels" class="textarea mono" rows="3" placeholder="app=myapp env=production" style="width:100%;"></textarea>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button class="btn ok" onclick="createVolume()">Aanmaken</button>
|
||||
<button class="btn ghost" onclick="hideCreateVolumeModal()">Annuleren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Build Modal -->
|
||||
<div class="modalBack" id="buildModalBack" style="display:none;" onclick="closeBuildModal(event)">
|
||||
<div class="modal" onclick="event.stopPropagation()" style="width:700px;">
|
||||
@@ -590,6 +646,9 @@
|
||||
if (tab === "images") {
|
||||
loadImages();
|
||||
}
|
||||
if (tab === "volumes") {
|
||||
loadVolumes();
|
||||
}
|
||||
// Start/stop live stats alleen in Containers tab (polling via /containers-dashboard)
|
||||
if (tab === 'containers') startContainersDashboardStatsPoll();
|
||||
else stopContainersDashboardStatsPoll();
|
||||
@@ -772,5 +831,6 @@
|
||||
<script src="assets/js/d3.min.js"></script>
|
||||
<script src="assets/js/tabs/networks.js"></script>
|
||||
<script src="assets/js/tabs/images.js"></script>
|
||||
<script src="assets/js/tabs/volumes.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user