feat(system): per-folder breakdown under each app on the Storage page

Extend the app-storage generator to record every bind mount's size and
in-container path, grouped into a per-app folder list. The "Storage by
app" rows are now expandable: click an app to see where its space goes
(e.g. /var/lib/mysql vs /data), with external-drive folders flagged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-28 20:33:21 +01:00
parent f5f3db900a
commit 7a0477ff8b
3 changed files with 102 additions and 33 deletions

View File

@ -1132,6 +1132,50 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
color: rgba(var(--text-rgb), 0.5);
font-size: 0.85rem;
}
/* Expandable per-app folder breakdown. */
.sys-storage-app-row.is-clickable { cursor: pointer; }
.sys-storage-caret,
.sys-storage-caret-spacer {
display: inline-block;
width: 1em;
margin-right: 6px;
color: rgba(var(--text-rgb), 0.45);
transition: transform .15s ease;
}
.sys-storage-caret.is-open { transform: rotate(90deg); }
.sys-storage-app-detail.is-collapsed { display: none; }
.sys-storage-app-detail > td {
padding: 2px 12px 10px 30px;
background: rgba(var(--text-rgb), 0.02);
}
.sys-storage-folders { list-style: none; margin: 0; padding: 0; }
.sys-storage-folders li {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
font-size: 0.82rem;
color: rgba(var(--text-rgb), 0.72);
}
.sys-storage-folder-path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sys-storage-folder-size {
margin-left: auto;
font-variant-numeric: tabular-nums;
color: rgba(var(--text-rgb), 0.92);
}
.sys-storage-ext-badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.68rem;
background: rgba(var(--status-warning-rgb), 0.18);
color: var(--status-warning);
}
.sys-storage-card-meta {
margin-top: 8px;
display: flex;

View File

@ -19,6 +19,7 @@ class SystemStoragePage {
this.rootId = rootId;
this.data = null;
this._timer = null;
this._expanded = new Set(); // app names whose folder breakdown is open
this._onClick = this._onClick.bind(this);
}
@ -53,7 +54,19 @@ class SystemStoragePage {
return;
}
const recl = e.target.closest('[data-storage-reclaim]');
if (recl) { this._reclaim(recl); }
if (recl) { this._reclaim(recl); return; }
// Expand/collapse an app's per-folder breakdown. Toggle the DOM directly
// (no re-render) and remember the state so the 30s refresh re-applies it.
const tog = e.target.closest('[data-app-toggle]');
if (tog) {
const app = tog.getAttribute('data-app-toggle');
if (this._expanded.has(app)) this._expanded.delete(app);
else this._expanded.add(app);
const open = this._expanded.has(app);
const detail = this.root()?.querySelector(`[data-app-detail="${CSS.escape(app)}"]`);
if (detail) detail.classList.toggle('is-collapsed', !open);
tog.querySelector('.sys-storage-caret')?.classList.toggle('is-open', open);
}
}
// Confirm, then run the safe reclaim task and re-read usage as it lands.
@ -270,6 +283,7 @@ class SystemStoragePage {
const appRows = (as && Array.isArray(as.apps)) ? as.apps : [];
const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0);
const hasExternal = appRows.some(a => a.external_bytes > 0);
const cols = hasExternal ? 4 : 3;
const appBody = appRows.length
? `<div class="sys-apps-wrap">
<table class="sys-apps">
@ -277,12 +291,25 @@ class SystemStoragePage {
<tbody>
${appRows.map(a => {
const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
return `<tr>
<td class="sys-app-name">${fmt.escape(a.app)}</td>
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
const open = this._expanded.has(a.app);
// Each app row expands into its folders (the bind mounts
// it stores data in), labelled by the in-container path.
const folders = mounts.map(m => `
<li>
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
</li>`).join('');
return `<tr class="sys-storage-app-row${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
<td class="sys-app-name">${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : '<span class="sys-storage-caret-spacer"></span>'}${fmt.escape(a.app)}</td>
<td>${fmt.bytes(a.bytes || 0)}</td>
<td class="sys-storage-app-barcol"><span class="sys-storage-app-bar"><span style="width:${pct.toFixed(1)}%"></span></span></td>
${hasExternal ? `<td>${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}</td>` : ''}
</tr>`;
</tr>${mounts.length ? `
<tr class="sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">
<td colspan="${cols}"><ul class="sys-storage-folders">${folders}</ul></td>
</tr>` : ''}`;
}).join('')}
</tbody>
</table>

View File

@ -236,18 +236,21 @@ webuiSystemAppStorage() {
return 0
fi
# name|project|source for every bind mount. Compose project groups an app's
# containers; fall back to the container name when unlabelled (as webuiSystemApps).
# name|project|destination|source for every bind mount. Compose project
# groups an app's containers; fall back to the container name when unlabelled
# (as webuiSystemApps). The destination (container path, e.g. /var/lib/mysql)
# labels each folder in the per-app breakdown.
local raw
raw=$(runFileOp docker inspect $ids --format \
'{{$n := .Name}}{{$p := index .Config.Labels "com.docker.compose.project"}}{{range .Mounts}}{{if eq .Type "bind"}}{{$n}}|{{$p}}|{{.Source}}
'{{$n := .Name}}{{$p := index .Config.Labels "com.docker.compose.project"}}{{range .Mounts}}{{if eq .Type "bind"}}{{$n}}|{{$p}}|{{.Destination}}|{{.Source}}
{{end}}{{end}}' 2>/dev/null)
declare -A app_local app_ext seen
local total_local=0 total_ext=0
# One JSON object per included mount; jq groups them into apps below.
declare -A seen
local mounts_stream=""
local cname proj src
while IFS='|' read -r cname proj src; do
local cname proj dest src
while IFS='|' read -r cname proj dest src; do
[[ -z "$src" ]] && continue
cname="${cname#/}"
proj="${proj:-$cname}"
@ -266,35 +269,30 @@ webuiSystemAppStorage() {
local bytes
bytes=$(runFileOp du -sxB1 -- "$src" 2>/dev/null | awk '{print $1+0; exit}')
bytes="${bytes:-0}"
if [[ -n "$root_dev" && "$dev" == "$root_dev" ]]; then
app_local["$proj"]=$(( ${app_local["$proj"]:-0} + bytes ))
total_local=$(( total_local + bytes ))
else
app_ext["$proj"]=$(( ${app_ext["$proj"]:-0} + bytes ))
total_ext=$(( total_ext + bytes ))
fi
local ext="false"
[[ -n "$root_dev" && "$dev" == "$root_dev" ]] || ext="true"
mounts_stream+="$(jq -nc --arg app "$proj" --arg path "$dest" --arg source "$src" \
--argjson bytes "$bytes" --argjson external "$ext" \
'{app:$app, path:$path, source:$source, bytes:$bytes, external:$external}')"$'\n'
done <<< "$raw"
# Group mounts → apps (each with its folder breakdown), largest first.
local apps_json="[]"
local appnames
appnames=$(printf '%s\n' "${!app_local[@]}" "${!app_ext[@]}" 2>/dev/null | sort -u)
if [[ -n "$appnames" ]]; then
local stream="" p
while IFS= read -r p; do
[[ -z "$p" ]] && continue
local l="${app_local[$p]:-0}" e="${app_ext[$p]:-0}"
stream+="$(jq -nc --arg app "$p" --argjson l "$l" --argjson e "$e" \
'{app:$app, local_bytes:$l, external_bytes:$e, bytes:($l+$e)}')"$'\n'
done <<< "$appnames"
apps_json=$(printf '%s' "$stream" | jq -s 'sort_by(-.bytes)' 2>/dev/null)
if [[ -n "${mounts_stream//[$' \t\n']/}" ]]; then
apps_json=$(printf '%s' "$mounts_stream" | jq -s '
group_by(.app) | map({
app: .[0].app,
bytes: (map(.bytes) | add),
local_bytes: ([.[] | select(.external | not) | .bytes] | add // 0),
external_bytes: ([.[] | select(.external) | .bytes] | add // 0),
mounts: (sort_by(-.bytes) | map({path, source, bytes, external}))
}) | sort_by(-.bytes)' 2>/dev/null)
[[ -z "$apps_json" ]] && apps_json="[]"
fi
local tmp; tmp=$(mktemp)
if jq -n --argjson apps "$apps_json" \
--argjson tl "$total_local" --argjson te "$total_ext" \
--arg now "$now_iso" \
'{apps:$apps, total_local:$tl, total_external:$te, total:($tl+$te), updated:$now}' \
if printf '%s' "$apps_json" | jq --arg now "$now_iso" \
'{apps: ., total: (map(.bytes) | add // 0), total_local: (map(.local_bytes) | add // 0), total_external: (map(.external_bytes) | add // 0), updated: $now}' \
> "$tmp" 2>/dev/null; then
runFileWrite "$final_file" < "$tmp"
fi