diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css
index e4438a5..11f5a92 100644
--- a/containers/libreportal/frontend/css/admin.css
+++ b/containers/libreportal/frontend/css/admin.css
@@ -1112,6 +1112,26 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
background: var(--cat);
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 {
margin-top: 8px;
display: flex;
diff --git a/containers/libreportal/frontend/js/components/admin/system-storage-page.js b/containers/libreportal/frontend/js/components/admin/system-storage-page.js
index eb51cda..bc3b58c 100644
--- a/containers/libreportal/frontend/js/components/admin/system-storage-page.js
+++ b/containers/libreportal/frontend/js/components/admin/system-storage-page.js
@@ -93,13 +93,15 @@ class SystemStoragePage {
}
async _load() {
- try {
- const r = await fetch('/api/system/storage');
- const j = await r.json().catch(() => null);
- this.data = j || null;
- } catch (_) {
- this.data = null;
- }
+ // Live docker df + the periodically-generated per-app on-disk sizes.
+ // The latter is a static generator artifact (du is too heavy for a live
+ // call), so a missing/empty file just means "not measured yet".
+ const [storage, app] = await Promise.all([
+ fetch('/api/system/storage').then(r => r.json()).catch(() => 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() {
@@ -277,7 +279,43 @@ class SystemStoragePage {
` : '';
- 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
+ ? `
+
+ | App | Size | | ${hasExternal ? 'External | ' : ''}
+
+ ${appRows.map(a => {
+ const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
+ return `
+ | ${fmt.escape(a.app)} |
+ ${fmt.bytes(a.bytes || 0)} |
+ |
+ ${hasExternal ? `${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'} | ` : ''}
+
`;
+ }).join('')}
+
+
+
`
+ : `${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}
`;
+ 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 = `
+
+
Storage by app
+ ${metaBits.length ? `${metaBits.join(' · ')}` : ''}
+
+ ${appBody}`;
+
+ body.innerHTML = `${headline}${appsSection}${catCards}${imagesTable}${volumesTable}`;
}
}
diff --git a/scripts/webui/data/generators/system/webui_system_metrics.sh b/scripts/webui/data/generators/system/webui_system_metrics.sh
index 9d51604..344958d 100644
--- a/scripts/webui/data/generators/system/webui_system_metrics.sh
+++ b/scripts/webui/data/generators/system/webui_system_metrics.sh
@@ -190,6 +190,116 @@ EOF
rm -f "$tmp2"
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