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:
parent
271b489029
commit
4b39cf770b
@ -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;
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user