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 + ? `
+ + ${hasExternal ? '' : ''} + + ${appRows.map(a => { + const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0; + return ` + + + + ${hasExternal ? `` : ''} + `; + }).join('')} + +
AppSizeExternal
${fmt.escape(a.app)}${fmt.bytes(a.bytes || 0)}${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}
+
` + : `
${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