Merge claude/2
This commit is contained in:
commit
1c3531b932
@ -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;
|
||||
|
||||
@ -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 {
|
||||
</table>
|
||||
</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"
|
||||
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user