Compare commits

..

No commits in common. "f5f3db900ae364597266256a8f62381b79a08887" and "10b814cd93faf2981f71c0275b8b8d6d5ecf36fe" have entirely different histories.

3 changed files with 62 additions and 26 deletions

View File

@ -22,11 +22,8 @@
// //
// GET /api/system/storage // GET /api/system/storage
// `docker system df` — total + reclaimable per category (images, // `docker system df` — total + reclaimable per category (images,
// containers, build cache). Cached for STORAGE_TTL_MS because this is one // containers, volumes, build cache). Cached for STORAGE_TTL_MS because
// of the more expensive calls on a busy daemon. Named volumes are omitted: // this is one of the more expensive calls on a busy daemon.
// LibrePortal apps keep data in bind mounts, so volume accounting is always
// ~empty here — per-app on-disk usage is generated separately (see
// webuiSystemAppStorage / /data/system/app_storage.json).
// //
// Mounted at /api/system in routes.js (so paths are /api/system/containers // Mounted at /api/system in routes.js (so paths are /api/system/containers
// etc.). Uses the shared docker util (utils/docker.js) which talks to the // etc.). Uses the shared docker util (utils/docker.js) which talks to the
@ -339,9 +336,9 @@ router.get('/storage', async (req, res) => {
// Roll the verbose response up into headline numbers per category. // Roll the verbose response up into headline numbers per category.
// "Reclaimable" across this page reflects exactly what the Reclaim // "Reclaimable" across this page reflects exactly what the Reclaim
// button frees: dangling images + the whole build cache. Tagged-but- // button frees: dangling images + the whole build cache. Tagged-but-
// unused images and stopped containers are deliberately NOT counted — // unused images, stopped containers and volumes are deliberately NOT
// the safe prune leaves them alone — so the headline number matches the // counted — the safe prune leaves them alone — so the headline number
// button's effect instead of overstating it. // matches the button's effect instead of overstating it.
const isDangling = (im) => { const isDangling = (im) => {
const tags = im.RepoTags || []; const tags = im.RepoTags || [];
return tags.length === 0 || tags.every(t => t.includes('<none>')); return tags.length === 0 || tags.every(t => t.includes('<none>'));
@ -364,6 +361,14 @@ router.get('/storage', async (req, res) => {
}, },
{ count: 0, size: 0, reclaimable: 0 } { count: 0, size: 0, reclaimable: 0 }
); );
const sumVolumes = (df.Volumes || []).reduce(
(a, v) => {
a.count++;
a.size += (v.UsageData && v.UsageData.Size) || 0;
return a;
},
{ count: 0, size: 0, reclaimable: 0 }
);
const sumBuild = (df.BuildCache || []).reduce( const sumBuild = (df.BuildCache || []).reduce(
(a, b) => { (a, b) => {
a.count++; a.count++;
@ -387,15 +392,29 @@ router.get('/storage', async (req, res) => {
containers: im.Containers || 0, containers: im.Containers || 0,
created: im.Created, created: im.Created,
})); }));
const total = sumImages.size + sumContainers.size + sumBuild.size; // Largest volumes — reclaimable or otherwise. Useful when /docker
const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumBuild.reclaimable; // is hitting 90%+.
const topVolumes = (df.Volumes || [])
.slice()
.sort((a, b) => ((b.UsageData?.Size) || 0) - ((a.UsageData?.Size) || 0))
.slice(0, 10)
.map(v => ({
name: v.Name,
driver: v.Driver,
size: (v.UsageData && v.UsageData.Size) || 0,
ref_count: (v.UsageData && v.UsageData.RefCount) || 0,
}));
const total = sumImages.size + sumContainers.size + sumVolumes.size + sumBuild.size;
const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumVolumes.reclaimable + sumBuild.reclaimable;
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.json({ res.json({
total, reclaimable, total, reclaimable,
images: sumImages, images: sumImages,
containers: sumContainers, containers: sumContainers,
volumes: sumVolumes,
build_cache: sumBuild, build_cache: sumBuild,
top_images: topImages, top_images: topImages,
top_volumes: topVolumes,
updated: new Date().toISOString(), updated: new Date().toISOString(),
}); });
} catch (err) { } catch (err) {

View File

@ -335,7 +335,7 @@ class AdminSystem {
const head = ` const head = `
<div class="sys-section-head"> <div class="sys-section-head">
<h2>Storage</h2> <h2>Storage</h2>
<span class="sys-chart-meta">${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images</span> <span class="sys-chart-meta">${dk.containers_running ?? 0}/${dk.containers_total ?? 0} running · ${dk.images ?? 0} images · ${dk.volumes ?? 0} volumes</span>
</div>`; </div>`;
if (!s || !s.total || !SP) { if (!s || !s.total || !SP) {
const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.'; const msg = (s && !s.total) ? 'No Docker storage in use yet.' : 'Storage usage unavailable.';
@ -368,7 +368,7 @@ class AdminSystem {
<div class="sys-storage-srows">${rows}</div> <div class="sys-storage-srows">${rows}</div>
<div class="sys-storage-summary-foot"> <div class="sys-storage-summary-foot">
<span class="sys-storage-recl-pill">${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}</span> <span class="sys-storage-recl-pill">${this.bytes(recl)} reclaimable${reclPct ? ` · ${reclPct}% of total` : ''}</span>
<button type="button" class="sys-storage-more" data-sys-storage>View storage breakdown </button> <button type="button" class="sys-storage-more" data-sys-storage>View largest images &amp; volumes </button>
</div> </div>
</div> </div>
</div>`; </div>`;

View File

@ -1,18 +1,16 @@
// Admin → System → Storage — where the disk is going. // Admin → System → Storage — Docker disk-usage breakdown.
// //
// Mounted at /admin/config/system/storage. Two data sources: // Mounted at /admin/config/system/storage. Visualises `docker system df`:
// //
// - Storage by app — on-disk size of each app's bind-mounted data, from the // - Headline total + reclaimable
// generated /data/system/app_storage.json (du, not docker — see the // - Donut chart split by category (images / containers / volumes / build cache)
// generator). The headline view for LibrePortal, whose data lives in bind // - Per-category cards (count + size + reclaimable)
// mounts, not named volumes. // - Top 10 images by size
// - Docker engine `docker system df` — headline total + reclaimable, a // - Top 10 volumes by size
// per-category donut (images / containers / build cache), per-category cards,
// and the top images by size.
// //
// One backend call (GET /api/system/storage), cached server-side for 5s. // One backend call (GET /api/system/storage), cached server-side for 5s.
// A "Reclaim space" button runs the safe prune (build cache + dangling // A "Reclaim space" button runs the safe prune (build cache + dangling
// images, never app data) via the system_reclaim task, then re-reads usage. // images, never volumes) via the system_reclaim task, then re-reads usage.
class SystemStoragePage { class SystemStoragePage {
constructor(rootId = 'config-section') { constructor(rootId = 'config-section') {
@ -83,7 +81,7 @@ class SystemStoragePage {
if (window.showConfirmation) { if (window.showConfirmation) {
window.showConfirmation( window.showConfirmation(
'Reclaim space', 'Reclaim space',
'Clear the build cache and dangling images? Images in use and your app data are left untouched.', 'Clear the build cache and dangling images? Volumes and images in use are left untouched.',
run, run,
'Reclaim space', 'Reclaim space',
'Cancel', 'Cancel',
@ -117,9 +115,9 @@ class SystemStoragePage {
<a href="/admin/config/system" data-back>Admin · System</a> <a href="/admin/config/system" data-back>Admin · System</a>
</div> </div>
<h1>Storage</h1> <h1>Storage</h1>
<p class="sys-storage-sub">On-disk space by app, plus Docker's images, containers, and build cache.</p> <p class="sys-storage-sub">Docker disk usage images, containers, volumes, and build cache.</p>
</div> </div>
<button type="button" class="sys-storage-reclaim" data-storage-reclaim title="Clear build cache and dangling images (images in use and app data are kept)"> <button type="button" class="sys-storage-reclaim" data-storage-reclaim title="Clear build cache and dangling images (volumes and images in use are kept)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Reclaim space Reclaim space
</button> </button>
@ -133,6 +131,7 @@ class SystemStoragePage {
static segmentsFrom(d) { static segmentsFrom(d) {
return [ return [
{ key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} }, { key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} },
{ key: 'volumes', label: 'Volumes', color: 'status-success', data: (d && d.volumes) || {} },
{ key: 'containers', label: 'Containers', color: 'status-info', data: (d && d.containers) || {} }, { key: 'containers', label: 'Containers', color: 'status-info', data: (d && d.containers) || {} },
{ key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} }, { key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} },
]; ];
@ -243,6 +242,7 @@ class SystemStoragePage {
</div>`; </div>`;
const topImages = Array.isArray(d.top_images) ? d.top_images : []; const topImages = Array.isArray(d.top_images) ? d.top_images : [];
const topVolumes = Array.isArray(d.top_volumes) ? d.top_volumes : [];
const imagesTable = topImages.length ? ` const imagesTable = topImages.length ? `
<div class="sys-section-head"><h2>Largest images</h2><span class="sys-chart-meta">top ${topImages.length} by size</span></div> <div class="sys-section-head"><h2>Largest images</h2><span class="sys-chart-meta">top ${topImages.length} by size</span></div>
@ -262,6 +262,23 @@ class SystemStoragePage {
</table> </table>
</div>` : ''; </div>` : '';
const volumesTable = topVolumes.length ? `
<div class="sys-section-head"><h2>Largest volumes</h2><span class="sys-chart-meta">top ${topVolumes.length} by size</span></div>
<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>Name</th><th>Driver</th><th>Size</th><th>Refs</th></tr></thead>
<tbody>
${topVolumes.map(v => `
<tr>
<td class="sys-app-name">${fmt.escape(v.name)}</td>
<td>${fmt.escape(v.driver || '—')}</td>
<td>${fmt.bytes(v.size)}</td>
<td>${v.ref_count}${v.ref_count === 0 ? ' <span class="sys-storage-orphan">orphan</span>' : ''}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : '';
// Storage by app — the number Docker can't give us: the on-disk size of // Storage by app — the number Docker can't give us: the on-disk size of
// each app's bind-mounted data, measured by the generator. This is the // each app's bind-mounted data, measured by the generator. This is the
// useful "what's eating my disk" view for LibrePortal, where app data // useful "what's eating my disk" view for LibrePortal, where app data
@ -298,7 +315,7 @@ class SystemStoragePage {
</div> </div>
${appBody}`; ${appBody}`;
body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}`; body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}${volumesTable}`;
} }
} }