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:
parent
f5f3db900a
commit
7a0477ff8b
@ -1132,6 +1132,50 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
|
|||||||
color: rgba(var(--text-rgb), 0.5);
|
color: rgba(var(--text-rgb), 0.5);
|
||||||
font-size: 0.85rem;
|
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 {
|
.sys-storage-card-meta {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ class SystemStoragePage {
|
|||||||
this.rootId = rootId;
|
this.rootId = rootId;
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this._timer = null;
|
this._timer = null;
|
||||||
|
this._expanded = new Set(); // app names whose folder breakdown is open
|
||||||
this._onClick = this._onClick.bind(this);
|
this._onClick = this._onClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +54,19 @@ class SystemStoragePage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const recl = e.target.closest('[data-storage-reclaim]');
|
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.
|
// 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 appRows = (as && Array.isArray(as.apps)) ? as.apps : [];
|
||||||
const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0);
|
const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0);
|
||||||
const hasExternal = appRows.some(a => a.external_bytes > 0);
|
const hasExternal = appRows.some(a => a.external_bytes > 0);
|
||||||
|
const cols = hasExternal ? 4 : 3;
|
||||||
const appBody = appRows.length
|
const appBody = appRows.length
|
||||||
? `<div class="sys-apps-wrap">
|
? `<div class="sys-apps-wrap">
|
||||||
<table class="sys-apps">
|
<table class="sys-apps">
|
||||||
@ -277,12 +291,25 @@ class SystemStoragePage {
|
|||||||
<tbody>
|
<tbody>
|
||||||
${appRows.map(a => {
|
${appRows.map(a => {
|
||||||
const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
|
const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
|
||||||
return `<tr>
|
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
|
||||||
<td class="sys-app-name">${fmt.escape(a.app)}</td>
|
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>${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>
|
<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>` : ''}
|
${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('')}
|
}).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@ -236,18 +236,21 @@ webuiSystemAppStorage() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# name|project|source for every bind mount. Compose project groups an app's
|
# name|project|destination|source for every bind mount. Compose project
|
||||||
# containers; fall back to the container name when unlabelled (as webuiSystemApps).
|
# 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
|
local raw
|
||||||
raw=$(runFileOp docker inspect $ids --format \
|
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)
|
{{end}}{{end}}' 2>/dev/null)
|
||||||
|
|
||||||
declare -A app_local app_ext seen
|
# One JSON object per included mount; jq groups them into apps below.
|
||||||
local total_local=0 total_ext=0
|
declare -A seen
|
||||||
|
local mounts_stream=""
|
||||||
|
|
||||||
local cname proj src
|
local cname proj dest src
|
||||||
while IFS='|' read -r cname proj src; do
|
while IFS='|' read -r cname proj dest src; do
|
||||||
[[ -z "$src" ]] && continue
|
[[ -z "$src" ]] && continue
|
||||||
cname="${cname#/}"
|
cname="${cname#/}"
|
||||||
proj="${proj:-$cname}"
|
proj="${proj:-$cname}"
|
||||||
@ -266,35 +269,30 @@ webuiSystemAppStorage() {
|
|||||||
local bytes
|
local bytes
|
||||||
bytes=$(runFileOp du -sxB1 -- "$src" 2>/dev/null | awk '{print $1+0; exit}')
|
bytes=$(runFileOp du -sxB1 -- "$src" 2>/dev/null | awk '{print $1+0; exit}')
|
||||||
bytes="${bytes:-0}"
|
bytes="${bytes:-0}"
|
||||||
if [[ -n "$root_dev" && "$dev" == "$root_dev" ]]; then
|
local ext="false"
|
||||||
app_local["$proj"]=$(( ${app_local["$proj"]:-0} + bytes ))
|
[[ -n "$root_dev" && "$dev" == "$root_dev" ]] || ext="true"
|
||||||
total_local=$(( total_local + bytes ))
|
mounts_stream+="$(jq -nc --arg app "$proj" --arg path "$dest" --arg source "$src" \
|
||||||
else
|
--argjson bytes "$bytes" --argjson external "$ext" \
|
||||||
app_ext["$proj"]=$(( ${app_ext["$proj"]:-0} + bytes ))
|
'{app:$app, path:$path, source:$source, bytes:$bytes, external:$external}')"$'\n'
|
||||||
total_ext=$(( total_ext + bytes ))
|
|
||||||
fi
|
|
||||||
done <<< "$raw"
|
done <<< "$raw"
|
||||||
|
|
||||||
|
# Group mounts → apps (each with its folder breakdown), largest first.
|
||||||
local apps_json="[]"
|
local apps_json="[]"
|
||||||
local appnames
|
if [[ -n "${mounts_stream//[$' \t\n']/}" ]]; then
|
||||||
appnames=$(printf '%s\n' "${!app_local[@]}" "${!app_ext[@]}" 2>/dev/null | sort -u)
|
apps_json=$(printf '%s' "$mounts_stream" | jq -s '
|
||||||
if [[ -n "$appnames" ]]; then
|
group_by(.app) | map({
|
||||||
local stream="" p
|
app: .[0].app,
|
||||||
while IFS= read -r p; do
|
bytes: (map(.bytes) | add),
|
||||||
[[ -z "$p" ]] && continue
|
local_bytes: ([.[] | select(.external | not) | .bytes] | add // 0),
|
||||||
local l="${app_local[$p]:-0}" e="${app_ext[$p]:-0}"
|
external_bytes: ([.[] | select(.external) | .bytes] | add // 0),
|
||||||
stream+="$(jq -nc --arg app "$p" --argjson l "$l" --argjson e "$e" \
|
mounts: (sort_by(-.bytes) | map({path, source, bytes, external}))
|
||||||
'{app:$app, local_bytes:$l, external_bytes:$e, bytes:($l+$e)}')"$'\n'
|
}) | sort_by(-.bytes)' 2>/dev/null)
|
||||||
done <<< "$appnames"
|
|
||||||
apps_json=$(printf '%s' "$stream" | jq -s 'sort_by(-.bytes)' 2>/dev/null)
|
|
||||||
[[ -z "$apps_json" ]] && apps_json="[]"
|
[[ -z "$apps_json" ]] && apps_json="[]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local tmp; tmp=$(mktemp)
|
local tmp; tmp=$(mktemp)
|
||||||
if jq -n --argjson apps "$apps_json" \
|
if printf '%s' "$apps_json" | jq --arg now "$now_iso" \
|
||||||
--argjson tl "$total_local" --argjson te "$total_ext" \
|
'{apps: ., total: (map(.bytes) | add // 0), total_local: (map(.local_bytes) | add // 0), total_external: (map(.external_bytes) | add // 0), updated: $now}' \
|
||||||
--arg now "$now_iso" \
|
|
||||||
'{apps:$apps, total_local:$tl, total_external:$te, total:($tl+$te), updated:$now}' \
|
|
||||||
> "$tmp" 2>/dev/null; then
|
> "$tmp" 2>/dev/null; then
|
||||||
runFileWrite "$final_file" < "$tmp"
|
runFileWrite "$final_file" < "$tmp"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user