feat(system): per-app on-disk storage on the Storage page

Docker only tracks where an app's data lives (its bind mounts), not how
big a bind-mounted host dir is — so named-volume accounting reads ~0 for
LibrePortal, whose app data lives in bind mounts. Add a generator that
reads each app's mount map from `docker inspect` and `du`s the directories
(via runFileOp, so it runs as the data-owning user and isn't blocked by
rootless UID mapping). `du -x` keeps each measurement on its own
filesystem, so data on a separate disk is reported as a distinct
"external" total. The generator self-throttles to ~10 min since du is
heavier than the per-minute metrics. Surfaced as a "Storage by app"
section on the Storage page.

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:06:34 +01:00
parent 271b489029
commit 4b39cf770b
3 changed files with 176 additions and 8 deletions

View File

@ -1112,6 +1112,26 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
background: var(--cat); background: var(--cat);
transition: width .4s ease; transition: width .4s ease;
} }
/* Inline bar for the "Storage by app" table. */
.sys-storage-app-barcol { width: 38%; }
.sys-storage-app-bar {
display: block;
height: 6px;
border-radius: 3px;
background: rgba(var(--text-rgb), 0.08);
overflow: hidden;
}
.sys-storage-app-bar > span {
display: block;
height: 100%;
background: var(--accent);
transition: width .4s ease;
}
.sys-storage-app-empty {
padding: 16px 4px;
color: rgba(var(--text-rgb), 0.5);
font-size: 0.85rem;
}
.sys-storage-card-meta { .sys-storage-card-meta {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;

View File

@ -93,13 +93,15 @@ class SystemStoragePage {
} }
async _load() { async _load() {
try { // Live docker df + the periodically-generated per-app on-disk sizes.
const r = await fetch('/api/system/storage'); // The latter is a static generator artifact (du is too heavy for a live
const j = await r.json().catch(() => null); // call), so a missing/empty file just means "not measured yet".
this.data = j || null; const [storage, app] = await Promise.all([
} catch (_) { fetch('/api/system/storage').then(r => r.json()).catch(() => null),
this.data = null; fetch(`/data/system/app_storage.json?t=${Date.now()}`).then(r => r.ok ? r.json() : null).catch(() => null),
} ]);
this.data = storage;
this.appStorage = app;
} }
_renderShell() { _renderShell() {
@ -277,7 +279,43 @@ class SystemStoragePage {
</table> </table>
</div>` : ''; </div>` : '';
body.innerHTML = `${headline}${catCards}${imagesTable}${volumesTable}`; // 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
// useful "what's eating my disk" view for LibrePortal, where app data
// lives in bind mounts rather than named volumes.
const as = this.appStorage;
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 appBody = appRows.length
? `<div class="sys-apps-wrap">
<table class="sys-apps">
<thead><tr><th>App</th><th>Size</th><th class="sys-storage-app-barcol"></th>${hasExternal ? '<th>External</th>' : ''}</tr></thead>
<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>
<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>`;
}).join('')}
</tbody>
</table>
</div>`
: `<div class="sys-storage-app-empty">${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}</div>`;
const metaBits = [];
if (as && as.total) metaBits.push(`${fmt.bytes(as.total)} on disk`);
if (as && as.total_external) metaBits.push(`${fmt.bytes(as.total_external)} on external drives`);
const appsSection = `
<div class="sys-section-head">
<h2>Storage by app</h2>
${metaBits.length ? `<span class="sys-chart-meta">${metaBits.join(' · ')}</span>` : ''}
</div>
${appBody}`;
body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}${volumesTable}`;
} }
} }

View File

@ -190,6 +190,116 @@ EOF
rm -f "$tmp2" rm -f "$tmp2"
webuiSystemApps "$now_epoch" "$now_iso" "$system_dir" webuiSystemApps "$now_epoch" "$now_iso" "$system_dir"
webuiSystemAppStorage
}
# Per-app on-disk storage, in bytes, measured by du over each app's bind-mount
# sources. Docker only knows *where* an app's data lives (the mount map) — not
# how big a bind-mounted host dir is, since to the daemon it's just a host path.
# So we read the mounts from `docker inspect` and du the directories ourselves.
#
# Everything that touches the filesystem (stat, du) goes through runFileOp so it
# runs AS the data-owning user — rootless containers store data under the install
# user, whom the manager can't always read directly — otherwise du would silently
# undercount. `du -x` keeps each measurement on its own filesystem, so a source on
# a separate disk (an external media drive) is summed into its own "external"
# bucket instead of being walked as if it were local.
#
# du is far heavier than the per-minute metrics, so this self-throttles to one
# run per CFG_APP_STORAGE_INTERVAL (default 10 min) via the .stamp idiom used by
# the update/verify checks above. Pass "force" to bypass it.
webuiSystemAppStorage() {
local system_dir="${containers_dir}libreportal/frontend/data/system"
createFolders "quiet" "$sudo_user_name" "$system_dir"
local final_file="$system_dir/app_storage.json"
local stamp_file="$system_dir/.app_storage_stamp"
local interval="${CFG_APP_STORAGE_INTERVAL:-600}"
if [[ "$1" != "force" && -f "$stamp_file" ]]; then
local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$stamp_file" 2>/dev/null || echo 0)
(( _now - _last < interval )) && return 0
fi
command -v docker &>/dev/null || return 0
local now_iso; now_iso=$(date -Iseconds)
# Containers root device — anything on a different device is "external".
local root_dev; root_dev=$(runFileOp stat -c '%d' -- "${containers_dir%/}" 2>/dev/null)
# Every container (running or not) so stopped apps still report.
local ids; ids=$(runFileOp docker ps -aq 2>/dev/null)
if [[ -z "$ids" ]]; then
printf '{"apps":[],"total":0,"total_local":0,"total_external":0,"updated":"%s"}\n' "$now_iso" \
| runFileWrite "$final_file"
runAsManager touch "$stamp_file" 2>/dev/null || touch "$stamp_file" 2>/dev/null
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).
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}}
{{end}}{{end}}' 2>/dev/null)
declare -A app_local app_ext seen
local total_local=0 total_ext=0
local cname proj src
while IFS='|' read -r cname proj src; do
[[ -z "$src" ]] && continue
cname="${cname#/}"
proj="${proj:-$cname}"
case "$src" in
/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|/etc|/etc/*) continue ;;
esac
[[ -n "${seen[$src]}" ]] && continue # a path mounted into N containers counts once
seen["$src"]=1
# One stat (as the data owner) gives type + device: drop non-directories
# (mounted files, the docker socket) and classify local vs external at once.
local info ftype dev
info=$(runFileOp stat -c '%F|%d' -- "$src" 2>/dev/null)
[[ -z "$info" ]] && continue
ftype="${info%%|*}"; dev="${info##*|}"
[[ "$ftype" == "directory" ]] || continue
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
done <<< "$raw"
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)
[[ -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}' \
> "$tmp" 2>/dev/null; then
runFileWrite "$final_file" < "$tmp"
fi
rm -f "$tmp"
runAsManager touch "$stamp_file" 2>/dev/null || touch "$stamp_file" 2>/dev/null
} }
# Per-app Docker resource snapshot, grouped by compose project. Apps without a # Per-app Docker resource snapshot, grouped by compose project. Apps without a