diff --git a/scripts/source/files/arrays/files_webui.sh b/scripts/source/files/arrays/files_webui.sh index e567a1f..74dc93f 100755 --- a/scripts/source/files/arrays/files_webui.sh +++ b/scripts/source/files/arrays/files_webui.sh @@ -30,6 +30,7 @@ webui_scripts=( "webui/data/generators/system/webui_system_disk.sh" "webui/data/generators/system/webui_system_info.sh" "webui/data/generators/system/webui_system_memory.sh" + "webui/data/generators/system/webui_system_metrics.sh" "webui/data/generators/system/webui_system_update.sh" "webui/data/lock/webui_check_update_lock.sh" "webui/data/lock/webui_create_update_lock.sh" diff --git a/scripts/webui/data/generators/system/webui_system_metrics.sh b/scripts/webui/data/generators/system/webui_system_metrics.sh new file mode 100644 index 0000000..48b3622 --- /dev/null +++ b/scripts/webui/data/generators/system/webui_system_metrics.sh @@ -0,0 +1,295 @@ +#!/bin/bash + +# WebUI System Metrics Generator +# --------------------------------------------------------------------------- +# Produces the data behind the Admin → System page: +# metrics.json — current whole-server snapshot (CPU/mem/disk/net/docker) +# metrics_history.json — capped ring buffer of compact samples (for trend charts) +# metrics_apps.json — current per-app Docker resource snapshot +# metrics_apps_history.json — short per-app ring buffer (for per-app sparklines) +# +# Runs every minute from the same cron as webuiSystemUpdate. CPU% and network +# rate are rate-of-change values, so we stash the previous raw counters in +# hidden state files next to the JSON and diff against them each run (the +# existing update checker uses the same .stamp idiom in this directory). +# Everything is read from /proc + the docker socket, so it works rootless. + +# Read the aggregate CPU jiffy counters from /proc/stat. +# Echoes: " " +_metricsReadCpu() { + awk '/^cpu /{ + idle = $5 + $6 # idle + iowait + total = 0 + for (i = 2; i <= NF; i++) total += $i + print total, idle + exit + }' /proc/stat 2>/dev/null +} + +webuiSystemMetrics() { + local system_dir="${containers_dir}libreportal/frontend/data/system" + createFolders "quiet" "$sudo_user_name" "$system_dir" + + local now_epoch; now_epoch=$(date +%s) + local now_iso; now_iso=$(date -Iseconds) + + # --- CPU -------------------------------------------------------------- + local cpu_cores; cpu_cores=$(nproc 2>/dev/null || echo 1) + local cpu_state="$system_dir/.metrics_cpu_prev" + local cur_cpu; cur_cpu=$(_metricsReadCpu) + local cur_total cur_idle + cur_total=$(echo "$cur_cpu" | awk '{print $1}') + cur_idle=$(echo "$cur_cpu" | awk '{print $2}') + + local cpu_percent="0.0" + if [[ -f "$cpu_state" ]]; then + local prev_total prev_idle + prev_total=$(awk '{print $1}' "$cpu_state" 2>/dev/null) + prev_idle=$(awk '{print $2}' "$cpu_state" 2>/dev/null) + if [[ -n "$prev_total" && -n "$cur_total" && "$cur_total" -gt "$prev_total" ]]; then + cpu_percent=$(awk -v t="$cur_total" -v i="$cur_idle" -v pt="$prev_total" -v pi="$prev_idle" \ + 'BEGIN { dt = t - pt; di = i - pi; if (dt <= 0) { print "0.0" } else { printf "%.1f", (1 - di/dt) * 100 } }') + fi + fi + [[ -n "$cur_total" ]] && echo "$cur_total $cur_idle" > "$cpu_state" 2>/dev/null + + # Load average (1/5/15) and 1-min load as a % of total cores. + local load1 load5 load15 + read -r load1 load5 load15 _ < /proc/loadavg 2>/dev/null + load1="${load1:-0}"; load5="${load5:-0}"; load15="${load15:-0}" + local load1_pct + load1_pct=$(awk -v l="$load1" -v c="$cpu_cores" 'BEGIN { if (c <= 0) c = 1; v = l / c * 100; if (v > 100) v = 100; printf "%.1f", v }') + + # --- Memory + swap ---------------------------------------------------- + local mem_total mem_avail swap_total swap_free + mem_total=$(awk '/^MemTotal:/{print $2*1024}' /proc/meminfo) + mem_avail=$(awk '/^MemAvailable:/{print $2*1024}' /proc/meminfo) + swap_total=$(awk '/^SwapTotal:/{print $2*1024}' /proc/meminfo) + swap_free=$(awk '/^SwapFree:/{print $2*1024}' /proc/meminfo) + mem_total="${mem_total:-0}"; mem_avail="${mem_avail:-0}" + swap_total="${swap_total:-0}"; swap_free="${swap_free:-0}" + local mem_used=$(( mem_total - mem_avail )) + local swap_used=$(( swap_total - swap_free )) + local mem_percent="0.0" swap_percent="0.0" + [[ "$mem_total" -gt 0 ]] && mem_percent=$(awk -v u="$mem_used" -v t="$mem_total" 'BEGIN{printf "%.1f", u/t*100}') + [[ "$swap_total" -gt 0 ]] && swap_percent=$(awk -v u="$swap_used" -v t="$swap_total" 'BEGIN{printf "%.1f", u/t*100}') + + # --- Disks (real filesystems only) ----------------------------------- + # Build a JSON array of mounts with size/used/percent + inode %. + local disks_json="[]" + disks_json=$(df -PB1 -x tmpfs -x devtmpfs -x squashfs -x overlay -x aufs 2>/dev/null \ + | tail -n +2 \ + | while read -r fs blocks used avail pcent mount; do + [[ -z "$mount" ]] && continue + local ipct + ipct=$(df -Pi "$mount" 2>/dev/null | tail -1 | awk '{gsub(/%/,"",$5); print $5+0}') + printf '{"mount":"%s","total":%s,"used":%s,"percent":%s,"inode_percent":%s}\n' \ + "$mount" "${blocks:-0}" "${used:-0}" "$(echo "${pcent:-0}" | tr -d '%')" "${ipct:-0}" + done | jq -s '.' 2>/dev/null) + [[ -z "$disks_json" ]] && disks_json="[]" + # Root mount percent powers the compact history sample. + local root_percent + root_percent=$(echo "$disks_json" | jq -r '(.[] | select(.mount=="/") | .percent) // (.[0].percent) // 0' 2>/dev/null) + root_percent="${root_percent:-0}" + + # --- Network throughput (aggregate, bytes/sec) ----------------------- + local net_state="$system_dir/.metrics_net_prev" + local cur_rx cur_tx + read -r cur_rx cur_tx < <(awk -F'[: ]+' ' + NR>2 && $2 !~ /^lo$/ { rx += $3; tx += $11 } + END { print rx+0, tx+0 }' /proc/net/dev 2>/dev/null) + cur_rx="${cur_rx:-0}"; cur_tx="${cur_tx:-0}" + local net_rx_rate=0 net_tx_rate=0 + if [[ -f "$net_state" ]]; then + local prev_t prev_rx prev_tx + read -r prev_t prev_rx prev_tx < "$net_state" + local dt=$(( now_epoch - ${prev_t:-now_epoch} )) + if [[ "$dt" -gt 0 ]]; then + net_rx_rate=$(awk -v c="$cur_rx" -v p="${prev_rx:-0}" -v d="$dt" 'BEGIN{v=(c-p)/d; if(v<0)v=0; printf "%.0f", v}') + net_tx_rate=$(awk -v c="$cur_tx" -v p="${prev_tx:-0}" -v d="$dt" 'BEGIN{v=(c-p)/d; if(v<0)v=0; printf "%.0f", v}') + fi + fi + echo "$now_epoch $cur_rx $cur_tx" > "$net_state" 2>/dev/null + + # --- Docker summary --------------------------------------------------- + local d_running=0 d_total=0 d_images=0 d_volumes=0 + if command -v docker &>/dev/null; then + local states + states=$(runFileOp docker ps -a --format '{{.State}}' 2>/dev/null) + if [[ -n "$states" ]]; then + d_total=$(echo "$states" | grep -c . ) + d_running=$(echo "$states" | grep -c '^running$') + fi + d_images=$(runFileOp docker images -q 2>/dev/null | grep -c . ) + d_volumes=$(runFileOp docker volume ls -q 2>/dev/null | grep -c . ) + fi + + # --- Write current snapshot ------------------------------------------ + local tmp; tmp=$(mktemp) + cat > "$tmp" << EOF +{ + "cpu": { + "percent": $cpu_percent, + "cores": $cpu_cores, + "load1": $load1, + "load5": $load5, + "load15": $load15, + "load1_percent": $load1_pct + }, + "memory": { + "total": $mem_total, + "used": $mem_used, + "available": $mem_avail, + "percent": $mem_percent, + "swap_total": $swap_total, + "swap_used": $swap_used, + "swap_percent": $swap_percent + }, + "disks": $disks_json, + "network": { + "rx_rate": $net_rx_rate, + "tx_rate": $net_tx_rate + }, + "docker": { + "containers_running": $d_running, + "containers_total": $d_total, + "images": $d_images, + "volumes": $d_volumes + }, + "updated": "$now_iso" +} +EOF + runFileWrite "$system_dir/metrics.json" < "$tmp"; rm -f "$tmp" + + # --- Append to the history ring buffer ------------------------------- + local cap="${CFG_METRICS_HISTORY_CAP:-1440}" # 24h at 1 sample/min + local hist="$system_dir/metrics_history.json" + local existing="{}" + [[ -s "$hist" ]] && existing=$(cat "$hist" 2>/dev/null) + local sample + sample=$(jq -n \ + --argjson t "$now_epoch" \ + --argjson cpu "$cpu_percent" \ + --argjson mem "$mem_percent" \ + --argjson swap "$swap_percent" \ + --argjson disk "$root_percent" \ + --argjson load1 "$load1" \ + --argjson rx "$net_rx_rate" \ + --argjson tx "$net_tx_rate" \ + '{t:$t, cpu:$cpu, mem:$mem, swap:$swap, disk:$disk, load1:$load1, net_rx:$rx, net_tx:$tx}' 2>/dev/null) + + local tmp2; tmp2=$(mktemp) + if echo "$existing" | jq \ + --argjson p "$sample" \ + --argjson cap "$cap" \ + --arg now "$now_iso" \ + '{cap:$cap, points: (((.points // []) + [$p]) | if length > $cap then .[-$cap:] else . end), updated:$now}' \ + > "$tmp2" 2>/dev/null; then + runFileWrite "$hist" < "$tmp2" + fi + rm -f "$tmp2" + + webuiSystemApps "$now_epoch" "$now_iso" "$system_dir" +} + +# Per-app Docker resource snapshot, grouped by compose project. Apps without a +# compose project label fall back to their container name so nothing is lost. +webuiSystemApps() { + local now_epoch="$1" now_iso="$2" system_dir="$3" + [[ -z "$system_dir" ]] && system_dir="${containers_dir}libreportal/frontend/data/system" + command -v docker &>/dev/null || return 0 + + # name|project|state|status for every container (running or not) + local meta + meta=$(runFileOp docker ps -a --format '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.State}}|{{.Status}}' 2>/dev/null) + [[ -z "$meta" ]] && { echo '{"apps":[],"updated":"'"$now_iso"'"}' | runFileWrite "$system_dir/metrics_apps.json"; return 0; } + + # name|cpu%|memUsage|mem%|netIO for running containers + local stats + stats=$(runFileOp docker stats --no-stream --no-trunc \ + --format '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}' 2>/dev/null) + + # Aggregate into JSON via awk, keyed by project. + local apps_json + apps_json=$(awk -v meta="$meta" -v stats="$stats" ' + function tobytes(s, num, unit, m) { + if (match(s, /[0-9.]+/) == 0) return 0 + num = substr(s, RSTART, RLENGTH) + 0 + unit = substr(s, RSTART + RLENGTH); gsub(/[ \t]/, "", unit) + m = 1 + if (unit ~ /^[Kk]i/) m = 1024 + else if (unit ~ /^[Mm]i/) m = 1024^2 + else if (unit ~ /^[Gg]i/) m = 1024^3 + else if (unit ~ /^[Tt]i/) m = 1024^4 + else if (unit ~ /^[Kk]/) m = 1000 + else if (unit ~ /^[Mm]/) m = 1000^2 + else if (unit ~ /^[Gg]/) m = 1000^3 + else if (unit ~ /^[Tt]/) m = 1000^4 + return num * m + } + BEGIN { + n = split(meta, mlines, "\n") + for (i = 1; i <= n; i++) { + if (mlines[i] == "") continue + split(mlines[i], f, "|") + cname = f[1]; proj = f[2]; state = f[3] + if (proj == "") proj = cname + c2p[cname] = proj + total[proj]++ + if (state == "running") running[proj]++ + seen[proj] = 1 + } + s = split(stats, slines, "\n") + for (i = 1; i <= s; i++) { + if (slines[i] == "") continue + split(slines[i], g, "|") + cname = g[1] + proj = (cname in c2p) ? c2p[cname] : cname + cpu = g[2]; gsub(/%/, "", cpu); cpu += 0 + memp = g[4]; gsub(/%/, "", memp); memp += 0 + cpus[proj] += cpu + mems[proj] += tobytes(g[3]) + memps[proj] += memp + split(g[5], nio, "/") + netrx[proj] += tobytes(nio[1]) + nettx[proj] += tobytes(nio[2]) + seen[proj] = 1 + } + first = 1 + printf "[" + for (p in seen) { + st = (running[p] > 0) ? "running" : "stopped" + if (!first) printf "," + first = 0 + printf "{\"app\":\"%s\",\"containers\":%d,\"running\":%d,\"cpu_percent\":%.1f,\"mem_bytes\":%.0f,\"mem_percent\":%.1f,\"net_rx\":%.0f,\"net_tx\":%.0f,\"status\":\"%s\"}", \ + p, total[p]+0, running[p]+0, cpus[p]+0, mems[p]+0, memps[p]+0, netrx[p]+0, nettx[p]+0, st + } + printf "]" + }') + [[ -z "$apps_json" ]] && apps_json="[]" + # Sort by CPU desc so the busiest apps surface first. + apps_json=$(echo "$apps_json" | jq 'sort_by(-.cpu_percent)' 2>/dev/null || echo "$apps_json") + + local tmp; tmp=$(mktemp) + jq -n --argjson apps "$apps_json" --arg now "$now_iso" '{apps:$apps, updated:$now}' > "$tmp" 2>/dev/null \ + && runFileWrite "$system_dir/metrics_apps.json" < "$tmp" + rm -f "$tmp" + + # Per-app history: append one {t,cpu,mem} per app, capped short. + local cap="${CFG_METRICS_APP_HISTORY_CAP:-120}" + local hist="$system_dir/metrics_apps_history.json" + local existing="{}" + [[ -s "$hist" ]] && existing=$(cat "$hist" 2>/dev/null) + local tmp2; tmp2=$(mktemp) + if echo "$existing" | jq \ + --argjson cur "$apps_json" \ + --argjson t "$now_epoch" \ + --argjson cap "$cap" \ + --arg now "$now_iso" \ + 'reduce $cur[] as $a ((.apps // {}); .[$a.app] = (((.[$a.app] // []) + [{t:$t, cpu:$a.cpu_percent, mem:$a.mem_bytes}]) | if length > $cap then .[-$cap:] else . end)) + | {cap:$cap, apps:., updated:$now}' \ + > "$tmp2" 2>/dev/null; then + runFileWrite "$hist" < "$tmp2" + fi + rm -f "$tmp2" +} diff --git a/scripts/webui/data/generators/system/webui_system_update.sh b/scripts/webui/data/generators/system/webui_system_update.sh index c8b730e..1e80dc9 100755 --- a/scripts/webui/data/generators/system/webui_system_update.sh +++ b/scripts/webui/data/generators/system/webui_system_update.sh @@ -7,6 +7,7 @@ webuiSystemUpdate() { webuiSystemInfo webuiSystemDisk webuiSystemMemory + webuiSystemMetrics webuiSystemUpdateCheck isSuccessful "System information updated!" }