diff --git a/containers/libreportal/frontend/css/admin.css b/containers/libreportal/frontend/css/admin.css index 11f5a92..bf2fffb 100644 --- a/containers/libreportal/frontend/css/admin.css +++ b/containers/libreportal/frontend/css/admin.css @@ -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; 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 6e545aa..e53aa3f 100644 --- a/containers/libreportal/frontend/js/components/admin/system-storage-page.js +++ b/containers/libreportal/frontend/js/components/admin/system-storage-page.js @@ -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 ? `
@@ -277,12 +291,25 @@ class SystemStoragePage { ${appRows.map(a => { const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0; - return ` - + 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 => ` +
  • + ${fmt.escape(m.path || m.source || '—')} + ${m.external ? 'external' : ''} + ${fmt.bytes(m.bytes || 0)} +
  • `).join(''); + return ` + ${hasExternal ? `` : ''} - `; + ${mounts.length ? ` + + + ` : ''}`; }).join('')}
    ${fmt.escape(a.app)}
    ${mounts.length ? `` : ''}${fmt.escape(a.app)} ${fmt.bytes(a.bytes || 0)} ${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}
      ${folders}
    diff --git a/scripts/webui/data/generators/system/webui_system_metrics.sh b/scripts/webui/data/generators/system/webui_system_metrics.sh index 344958d..8d003fb 100644 --- a/scripts/webui/data/generators/system/webui_system_metrics.sh +++ b/scripts/webui/data/generators/system/webui_system_metrics.sh @@ -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