Skip to content

Instantly share code, notes, and snippets.

@jasontucker
Created September 7, 2025 19:51
Show Gist options
  • Save jasontucker/cde4b7d31f986af00a9bb8ff08ff8330 to your computer and use it in GitHub Desktop.
Save jasontucker/cde4b7d31f986af00a9bb8ff08ff8330 to your computer and use it in GitHub Desktop.
Generate server snapshot + WikiDocs pages (Unraid)
#!/bin/bash
# =======================================
# Generate server snapshot + WikiDocs pages (Unraid)
# - Standard pages + per-disk pages (folder per disk with content.md)
# - Disks inventory (Active + Previously Active) with clean dates (M/D/YYYY)
# - Disk age (SMART POH), tenure, manufacture date (best-effort)
# - Per-disk pages include breadcrumb
# - Capacity parsing fixed (no "\1")
# - Suggestions page: scores disks + CPU section
# - Hardware page: CPU sensors + GPU section using vendor tools (GPU Statistics plugin parity)
# - Output root: /mnt/user/appdata/wikidocs/documents/servers/unraid
# =======================================
set -u
umask 002 # files 664, dirs 775
# --- Paths ---
WIKI_ROOT="/mnt/user/appdata/wikidocs/documents/servers/unraid"
OUT_DIR="${WIKI_ROOT}"
DOCS_DIR="/root/server-docs"
STATE_DIR="${DOCS_DIR}/state"
STATE_FILE="${STATE_DIR}/disks.db"
TS="$(date +"%Y-%m-%d_%H-%M-%S")"
DATE_HUMAN="$(date)"
SNAPSHOT_DIR="${DOCS_DIR}/snapshot_${TS}"
# --- Ownership for WikiDocs container (override via env) ---
PUID="${PUID:-99}" # nobody
PGID="${PGID:-100}" # users
mkdir -p "$SNAPSHOT_DIR" "$OUT_DIR" "$STATE_DIR"
has(){ command -v "$1" >/dev/null 2>&1; }
# ---------- Helpers ----------
ts_to_epoch() { # "YYYY-MM-DD_HH-MM-SS" -> epoch
local ts="$1"
local norm
norm="$(echo "$ts" | sed -E 's#^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$#\1 \2:\3:\4#')"
date -d "$norm" +%s 2>/dev/null || echo ""
}
fmt_age() { # from hours -> "Xy Yd (Nh)"
local hours="$1"
[[ -z "$hours" || "$hours" = "N/A" ]] && { echo "Unknown"; return; }
hours="${hours//[^0-9]/}"
[[ -z "$hours" ]] && { echo "Unknown"; return; }
local days=$((hours/24))
local years=$((days/365))
local rem=$((days%365))
echo "${years}y ${rem}d (${hours}h)"
}
fmt_tenure() { # first_seen -> "Xy Yd"
local first="$1"; local last="${2:-$TS}"
local fs ls
fs="$(ts_to_epoch "$first")"; ls="$(ts_to_epoch "$last")"
if [[ -n "$fs" && -n "$ls" && "$ls" -ge "$fs" ]]; then
local days=$(( (ls - fs)/86400 )); local years=$((days/365)); local rem=$((days%365))
echo "${years}y ${rem}d"
else
echo "since ${first}"
fi
}
# "YYYY-MM-DD_HH-MM-SS" -> "M/D/YYYY"
fmt_date_mdy() {
local ts="$1"
if [[ "$ts" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2}) ]]; then
local yyyy="${BASH_REMATCH[1]}"
local mm="${BASH_REMATCH[2]}"
local dd="${BASH_REMATCH[3]}"
printf "%d/%d/%04d" "$((10#$mm))" "$((10#$dd))" "$((10#$yyyy))"
else
echo "$ts"
fi
}
slugify() { # "Model Name", "SER123" -> "model-name-ser123"
local s="${1}-${2}"
s="$(echo "$s" | tr '[:upper:]' '[:lower:]')"
s="${s// /-}"; s="${s//_/-}"
s="${s//[^a-z0-9.-]/-}"
s="${s//--/-}"; s="${s##-}"; s="${s%%-}"
echo "${s:0:80}"
}
mkpage() { # mkpage <dir> <lines...>
local dir="$1"; shift
mkdir -p "$dir"
printf "%s\n" "$@" > "$dir/content.md"
}
num_only() { echo "$1" | grep -oE '[0-9]+' | head -1; }
# ---------- Collect snapshot ----------
{
echo "Hostname: $(hostname 2>/dev/null || echo N/A)"
[ -f /etc/unraid-version ] && { echo -n "Unraid: "; cat /etc/unraid-version; }
if [ -f /etc/os-release ]; then . /etc/os-release; echo "OS Release: ${PRETTY_NAME:-$NAME $VERSION}"; fi
echo "Kernel: $(uname -r 2>/dev/null || echo N/A)"
echo "Uptime: $(uptime -p 2>/dev/null || echo N/A)"
} > "$SNAPSHOT_DIR/system-info.txt"
(has uname && uname -a > "$SNAPSHOT_DIR/kernel.txt") || true
(has uptime && uptime > "$SNAPSHOT_DIR/uptime.txt") || true
(has lscpu && lscpu > "$SNAPSHOT_DIR/cpu.txt") || echo "(lscpu missing)" > "$SNAPSHOT_DIR/cpu.txt"
(has free && free -h > "$SNAPSHOT_DIR/memory.txt") || echo "(free missing)" > "$SNAPSHOT_DIR/memory.txt"
(has lsblk && lsblk -o NAME,SIZE,TYPE,MOUNTPOINT > "$SNAPSHOT_DIR/disks.txt") || echo "(lsblk missing)" > "$SNAPSHOT_DIR/disks.txt"
(has df && df -hT > "$SNAPSHOT_DIR/filesystems.txt") || echo "(df missing)" > "$SNAPSHOT_DIR/filesystems.txt"
(has lspci && lspci > "$SNAPSHOT_DIR/pci.txt") || echo "(lspci missing)" > "$SNAPSHOT_DIR/pci.txt"
(has lsusb && lsusb > "$SNAPSHOT_DIR/usb.txt") || echo "(lsusb missing)" > "$SNAPSHOT_DIR/usb.txt"
(has ip && ip addr show > "$SNAPSHOT_DIR/ip-addresses.txt") || echo "(ip missing)" > "$SNAPSHOT_DIR/ip-addresses.txt"
(has ip && ip route show > "$SNAPSHOT_DIR/routes.txt") || echo "(ip missing)" > "$SNAPSHOT_DIR/routes.txt"
cp /etc/resolv.conf "$SNAPSHOT_DIR/dns.txt" 2>/dev/null || echo "(no resolv.conf)" > "$SNAPSHOT_DIR/dns.txt"
(getent passwd 2>/dev/null | cut -d: -f1 > "$SNAPSHOT_DIR/users.txt") || echo "(getent missing)" > "$SNAPSHOT_DIR/users.txt"
(getent group 2>/dev/null | cut -d: -f1 > "$SNAPSHOT_DIR/groups.txt") || echo "(getent missing)" > "$SNAPSHOT_DIR/groups.txt"
# Sensors (CPU/board)
if has sensors; then
sensors > "$SNAPSHOT_DIR/sensors.txt" 2>/dev/null || echo "(sensors error)" > "$SNAPSHOT_DIR/sensors.txt"
else
echo "(lm-sensors not installed)" > "$SNAPSHOT_DIR/sensors.txt"
fi
# IPMI sensors
if has ipmitool; then
ipmitool sensor 2>/dev/null > "$SNAPSHOT_DIR/ipmi-sensors.txt" || echo "(ipmitool sensor error)" > "$SNAPSHOT_DIR/ipmi-sensors.txt"
else
echo "(ipmitool not installed)" > "$SNAPSHOT_DIR/ipmi-sensors.txt"
fi
# gpustat snapshot (optional)
if has gpustat; then
gpustat --color never 2>/dev/null > "$SNAPSHOT_DIR/gpustat.txt" || gpustat > "$SNAPSHOT_DIR/gpustat.txt" 2>/dev/null || echo "(gpustat error)" > "$SNAPSHOT_DIR/gpustat.txt"
else
echo "(gpustat not installed)" > "$SNAPSHOT_DIR/gpustat.txt"
fi
# PCI GPU listing
if has lspci; then
lspci -nnk | grep -A3 -E 'VGA|3D|Display' > "$SNAPSHOT_DIR/gpus-lspci.txt" 2>/dev/null || echo "(lspci parse error)" > "$SNAPSHOT_DIR/gpus-lspci.txt"
else
echo "(lspci not installed)" > "$SNAPSHOT_DIR/gpus-lspci.txt"
fi
# Services
if has systemctl; then
systemctl list-unit-files --type=service --state=enabled > "$SNAPSHOT_DIR/services-enabled.txt"
systemctl list-units --type=service --state=running > "$SNAPSHOT_DIR/services-running.txt"
else
echo "(systemctl not available on Unraid)" > "$SNAPSHOT_DIR/services-enabled.txt"
echo "(systemctl not available on Unraid)" > "$SNAPSHOT_DIR/services-running.txt"
fi
# Packages
if has dpkg; then dpkg -l > "$SNAPSHOT_DIR/packages.txt"
elif has rpm; then rpm -qa > "$SNAPSHOT_DIR/packages.txt"
else echo "(package list not available on Unraid)" > "$SNAPSHOT_DIR/packages.txt"
fi
# Docker
if has docker; then
docker --version > "$SNAPSHOT_DIR/docker-version.txt" 2>/dev/null || true
docker ps -a > "$SNAPSHOT_DIR/docker-containers.txt" 2>/dev/null || true
docker images > "$SNAPSHOT_DIR/docker-images.txt" 2>/dev/null || true
else
echo "(docker not installed)" > "$SNAPSHOT_DIR/docker-version.txt"
fi
# ---------- SMART details (for disks) ----------
DISK_DETAILS_TXT="$SNAPSHOT_DIR/disks-details.txt"
DISK_SUMMARY_MD="$SNAPSHOT_DIR/disks-summary.md"
mapfile -t BLK_DISKS < <(lsblk -dn -o NAME,TYPE 2>/dev/null | awk '$2=="disk"{print "/dev/"$1}')
mapfile -t NVME_NS < <(ls /dev/nvme*n1 2>/dev/null || true)
ALL_DISKS=("${BLK_DISKS[@]}" "${NVME_NS[@]}")
declare -A SEEN
declare -A L_MODEL L_FW L_CAP L_HEALTH L_TEMP L_DEV L_SLUG L_POH L_AGESTR L_MFGDATE
declare -A L_TEMP_NUM L_RSC L_CPS L_OU L_CRC L_PCTUSED L_CRITWARN
for d in "${ALL_DISKS[@]}"; do [[ -e "$d" && -z "${SEEN[$d]+x}" ]] && SEEN["$d"]=1; done
DEDUP_DISKS=( "${!SEEN[@]}" )
{
echo "# Disk Summary"
echo "*Generated: ${DATE_HUMAN}*"
echo
echo "| Device | Model | Serial | Firmware | Capacity | Health | Temp | On-time |"
echo "|-------:|-------|--------|----------|----------:|--------|------|--------|"
} > "$DISK_SUMMARY_MD"
: > "$DISK_DETAILS_TXT"
if has smartctl; then
for dev in "${DEDUP_DISKS[@]}"; do
echo "===== $dev =====" | tee -a "$DISK_DETAILS_TXT"
smartctl -i "$dev" 2>&1 | tee -a "$DISK_DETAILS_TXT"; echo | tee -a "$DISK_DETAILS_TXT"
smartctl -H "$dev" 2>&1 | tee -a "$DISK_DETAILS_TXT"; echo | tee -a "$DISK_DETAILS_TXT"
smartctl -A "$dev" 2>&1 | tee -a "$DISK_DETAILS_TXT"; echo | tee -a "$DISK_DETAILS_TXT"
MODEL=$(smartctl -i "$dev" 2>/dev/null | awk -F: '/Device Model|Model Number|Model:/ {sub(/^ +/,"",$2); print $2; exit}')
SERIAL=$(smartctl -i "$dev" 2>/dev/null | awk -F: '/Serial Number|Serial:/ {sub(/^ +/,"",$2); print $2; exit}')
[[ -z "$SERIAL" ]] && SERIAL="$(basename "$dev")"
FW=$(smartctl -i "$dev" 2>/dev/null | awk -F: '/Firmware Version|Firmware:/ {sub(/^ +/,"",$2); print $2; exit}')
# Capacity: prefer text inside [...] from smartctl; else lsblk size
CAP=$(smartctl -i "$dev" 2>/dev/null | awk -F'[][]' '/User Capacity|Total NVM Capacity/ {print $2; exit}')
[[ -z "$CAP" ]] && CAP=$(lsblk -dn -o SIZE "$dev" 2>/dev/null)
HEALTH=$(smartctl -H "$dev" 2>/dev/null | awk -F: '/SMART overall-health|SMART Health Status|overall-health/ {sub(/^ +/,"",$2); print $2; exit}')
[[ -z "$HEALTH" ]] && HEALTH="N/A"
TEMP=$(smartctl -A "$dev" 2>/dev/null | awk '
/Temperature_Celsius/ {print $10"°C"; found=1}
/Temperature:/ && $2 ~ /[0-9]+/ {print $2; found=1}
/Composite Temperature/ {print $3" "$4; found=1}
END{ if(!found) print "" }')
[[ -z "$TEMP" ]] && TEMP="N/A"
TEMP_NUM=$(num_only "$TEMP")
# Power-On Hours (SATA attr 9 or NVMe "Power On Hours:")
POH=$(smartctl -A "$dev" 2>/dev/null | awk '
/Power_On_Hours/ {gsub(/[^0-9]/,"",$NF); if($NF!=""){print $NF; exit}}
/Power[ ]*On[ ]*Hours:/ {for(i=1;i<=NF;i++) if($i ~ /^[0-9]+$/){print $i; exit}}')
[[ -z "$POH" ]] && POH="N/A"
AGESTR="$(fmt_age "$POH")"
# Manufacture date (best-effort; may be missing)
MFG=$(smartctl -i "$dev" 2>/dev/null | awk -F: '
/Manufactured|Date of manufacture|Manufacture Date|Production Date/ {sub(/^ +/,"",$2); print $2; exit}')
[[ -z "$MFG" ]] && MFG="Unknown"
# Extra SMART attrs for suggestions
RSC=$(smartctl -A "$dev" 2>/dev/null | awk '/Reallocated_Sector_Ct/ {print $NF; exit}')
CPS=$(smartctl -A "$dev" 2>/dev/null | awk '/Current_Pending_Sector/ {print $NF; exit}')
OU=$(smartctl -A "$dev" 2>/dev/null | awk '/Offline_Uncorrectable/ {print $NF; exit}')
CRC=$(smartctl -A "$dev" 2>/dev/null | awk '/UDMA_CRC_Error_Count/ {print $NF; exit}')
PCTUSED=$(smartctl -A "$dev" 2>/dev/null | awk -F: '/Percentage Used/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
CRITWARN=$(smartctl -A "$dev" 2>/dev/null | awk -F: '/Critical Warning/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
SLUG=$(slugify "${MODEL:-disk}" "$SERIAL")
L_MODEL["$SERIAL"]="$MODEL"
L_FW["$SERIAL"]="$FW"
L_CAP["$SERIAL"]="$CAP"
L_HEALTH["$SERIAL"]="$HEALTH"
L_TEMP["$SERIAL"]="$TEMP"
L_DEV["$SERIAL"]="$dev"
L_SLUG["$SERIAL"]="$SLUG"
L_POH["$SERIAL"]="$POH"
L_AGESTR["$SERIAL"]="$AGESTR"
L_MFGDATE["$SERIAL"]="$MFG"
L_TEMP_NUM["$SERIAL"]="$TEMP_NUM"
L_RSC["$SERIAL"]="${RSC:-0}"
L_CPS["$SERIAL"]="${CPS:-0}"
L_OU["$SERIAL"]="${OU:-0}"
L_CRC["$SERIAL"]="${CRC:-0}"
L_PCTUSED["$SERIAL"]="${PCTUSED:-0}"
L_CRITWARN["$SERIAL"]="${CRITWARN:-0}"
printf "| \`%s\` | %s | %s | %s | %s | %s | %s | %s |\n" \
"$dev" "${MODEL:-N/A}" "${SERIAL:-N/A}" "${FW:-N/A}" "${CAP:-N/A}" "${HEALTH:-N/A}" "${TEMP:-N/A}" "${AGESTR}" \
>> "$DISK_SUMMARY_MD"
done
else
echo "(smartctl not found; install smartmontools)" | tee -a "$DISK_DETAILS_TXT"
fi
# ---------- Load previous state & update ----------
# STATE: serial|model|slug|first_seen|last_seen|status|last_poh|mfg_date
declare -A S_MODEL S_SLUG S_FIRST S_LAST S_STATUS S_LASTPOH S_MFGDATE
if [[ -f "$STATE_FILE" ]]; then
while IFS='|' read -r s m sl fs ls st poh mfg; do
[[ -z "$s" ]] && continue
S_MODEL["$s"]="$m"; S_SLUG["$s"]="$sl"; S_FIRST["$s"]="$fs"; S_LAST["$s"]="$ls"
S_STATUS["$s"]="$st"; S_LASTPOH["$s"]="$poh"; S_MFGDATE["$s"]="$mfg"
done < "$STATE_FILE"
fi
# Update with live inventory
for s in "${!L_MODEL[@]}"; do
[[ -z "${S_FIRST[$s]+x}" ]] && S_FIRST["$s"]="$TS"
S_MODEL["$s"]="${L_MODEL[$s]}"
S_SLUG["$s"]="${L_SLUG[$s]}"
S_LAST["$s"]="$TS"
S_STATUS["$s"]="Active"
S_LASTPOH["$s"]="${L_POH[$s]}"
if [[ "${L_MFGDATE[$s]}" != "Unknown" ]]; then
S_MFGDATE["$s"]="${L_MFGDATE[$s]}"
elif [[ -z "${S_MFGDATE[$s]+x}" ]]; then
S_MFGDATE["$s"]="Unknown"
fi
done
# Mark previously known but now missing as Disabled
for s in "${!S_MODEL[@]}"; do
if [[ -z "${L_MODEL[$s]+x}" ]]; then
S_STATUS["$s"]="Disabled"
fi
done
# Persist state
{
for s in "${!S_MODEL[@]}"; do
echo "${s}|${S_MODEL[$s]}|${S_SLUG[$s]}|${S_FIRST[$s]}|${S_LAST[$s]}|${S_STATUS[$s]}|${S_LASTPOH[$s]}|${S_MFGDATE[$s]}"
done | sort
} > "$STATE_FILE"
# ---------- Standard pages ----------
mkpage "$OUT_DIR/system" \
"# System" "*Generated: ${DATE_HUMAN}*" "" \
'```text' "$(cat "$SNAPSHOT_DIR/system-info.txt")" '```'
mkpage "$OUT_DIR/hardware" \
"# Hardware" "*Generated: ${DATE_HUMAN}*" "" \
"### CPU" '```text' "$(cat "$SNAPSHOT_DIR/cpu.txt")" '```' "" \
"### CPU & Board Sensors" '```text' "$(cat "$SNAPSHOT_DIR/sensors.txt")" '```' "" \
"### IPMI Sensors" '```text' "$(cat "$SNAPSHOT_DIR/ipmi-sensors.txt")" '```' "" \
"### GPU (vendor tools below)" "" \
"#### GPU Processes (gpustat)" '```text' "$(cat "$SNAPSHOT_DIR/gpustat.txt")" '```' "" \
"#### GPU Devices (PCI)" '```text' "$(cat "$SNAPSHOT_DIR/gpus-lspci.txt")" '```'
mkpage "$OUT_DIR/storage" \
"# Storage" "*Generated: ${DATE_HUMAN}*" "" \
"### Block Devices" '```text' "$(cat "$SNAPSHOT_DIR/disks.txt")" '```' "" \
"### Filesystems" '```text' "$(cat "$SNAPSHOT_DIR/filesystems.txt")" '```' "" \
"See **[Disks (Inventory)](../disks/)** for per-disk pages and SMART."
mkpage "$OUT_DIR/networking" \
"# Networking" "*Generated: ${DATE_HUMAN}*" "" \
"### Interfaces & IPs" '```text' "$(cat "$SNAPSHOT_DIR/ip-addresses.txt")" '```' "" \
"### Routes" '```text' "$(cat "$SNAPSHOT_DIR/routes.txt")" '```' "" \
"### DNS" '```text' "$(cat "$SNAPSHOT_DIR/dns.txt")" '```'
mkpage "$OUT_DIR/users" \
"# Users" "*Generated: ${DATE_HUMAN}*" "" \
'```text' "$(cat "$SNAPSHOT_DIR/users.txt")" '```'
mkpage "$OUT_DIR/services" \
"# Services" "*Generated: ${DATE_HUMAN}*" "" \
"### Enabled at Boot" '```text' "$(cat "$SNAPSHOT_DIR/services-enabled.txt")" '```' "" \
"### Running" '```text' "$(cat "$SNAPSHOT_DIR/services-running.txt")" '```'
mkpage "$OUT_DIR/docker" \
"# Docker" "*Generated: ${DATE_HUMAN}*" "" \
"### Docker Version" '```text' "$(cat "$SNAPSHOT_DIR/docker-version.txt")" '```' "" \
"### Containers" '```text' "$(cat "$SNAPSHOT_DIR/docker-containers.txt")" '```' "" \
"### Images" '```text' "$(cat "$SNAPSHOT_DIR/docker-images.txt")" '```'
# ---------- Disks inventory + per-disk pages ----------
DISKS_ROOT="${OUT_DIR}/disks"
mkdir -p "$DISKS_ROOT"
# Inventory page
INV_TMP="$(mktemp)"
{
echo "# Disks (Inventory)"
echo "*Generated: ${DATE_HUMAN}*"
echo
echo "### Active"
echo "| Disk | Device | Capacity | Health | Temp | On-time | Tenure | First Seen | Last Seen |"
echo "|------|--------|----------|--------|------|---------|--------|------------|-----------|"
for s in "${!S_MODEL[@]}"; do
[[ "${S_STATUS[$s]}" != "Active" ]] && continue
model="${S_MODEL[$s]}"; slug="${S_SLUG[$s]}"; dev="${L_DEV[$s]:-N/A}"
cap="${L_CAP[$s]:-N/A}"; health="${L_HEALTH[$s]:-N/A}"; temp="${L_TEMP[$s]:-N/A}"
ontime="$(fmt_age "${L_POH[$s]:-N/A}")"
tenure="$(fmt_tenure "${S_FIRST[$s]}" "${S_LAST[$s]}")"
fs_fmt="$(fmt_date_mdy "${S_FIRST[$s]}")"
ls_fmt="$(fmt_date_mdy "${S_LAST[$s]}")"
link="[${model} — ${s}](disks/${slug}/)"
printf "| %s | \`%s\` | %s | %s | %s | %s | %s | %s | %s |\n" \
"$link" "$dev" "$cap" "$health" "$temp" "$ontime" "$tenure" "$fs_fmt" "$ls_fmt"
done
echo
echo "### Previously Active / Retired"
echo "| Disk | Last Seen | Last On-time | First Seen |"
echo "|------|-----------|--------------|------------|"
for s in "${!S_MODEL[@]}"; do
[[ "${S_STATUS[$s]}" = "Active" ]] && continue
model="${S_MODEL[$s]}"; slug="${S_SLUG[$s]}"; lastpoh="${S_LASTPOH[$s]:-N/A}"
ontime="$(fmt_age "$lastpoh")"
fs_fmt="$(fmt_date_mdy "${S_FIRST[$s]}")"
ls_fmt="$(fmt_date_mdy "${S_LAST[$s]}")"
link_disabled="[${model} — ${s}](disks/${slug}/)"
printf "| %s | %s | %s | %s |\n" \
"$link_disabled" "$ls_fmt" "$ontime" "$fs_fmt"
done
} > "$INV_TMP"
mkpage "$DISKS_ROOT" "$(cat "$INV_TMP")"
rm -f "$INV_TMP"
# Per-disk detail pages (Active + Disabled), include breadcrumb
for s in "${!S_MODEL[@]}"; do
slug="${S_SLUG[$s]}"; ddir="${DISKS_ROOT}/${slug}"
status="${S_STATUS[$s]}"; model="${S_MODEL[$s]}"
dev="${L_DEV[$s]:-N/A}" ; cap="${L_CAP[$s]:-N/A}" ; fw="${L_FW[$s]:-N/A}"
health="${L_HEALTH[$s]:-N/A}" ; temp="${L_TEMP[$s]:-N/A}"
ontime_str="$(fmt_age "${S_LASTPOH[$s]:-N/A}")"
tenure_str="$(fmt_tenure "${S_FIRST[$s]}" "${S_LAST[$s]}")"
mfg="${S_MFGDATE[$s]:-Unknown}"
# Fresh SMART blocks if Active
SMART_INFO="(not available)"; SMART_HEALTH="(not available)"; SMART_ATTRS="(not available)"
if [[ "$status" == "Active" && -n "${L_DEV[$s]:-}" && $(has smartctl && echo yes) == "yes" ]]; then
SMART_INFO="$(smartctl -i "${L_DEV[$s]}" 2>/dev/null || echo '(error)')"
SMART_HEALTH="$(smartctl -H "${L_DEV[$s]}" 2>/dev/null || echo '(error)')"
SMART_ATTRS="$(smartctl -A "${L_DEV[$s]}" 2>/dev/null || echo '(error)')"
fi
mkpage "$ddir" \
"# Disk: ${model} — ${s}" \
"*Generated: ${DATE_HUMAN}*" \
"[← Back to Disks](../)" \
"" \
"**Status:** ${status}" \
"" \
"- **Device:** \`${dev}\`" \
"- **Capacity:** ${cap}" \
"- **Firmware:** ${fw}" \
"- **Manufacture Date:** ${mfg}" \
"- **Age (power-on):** ${ontime_str}" \
"- **Tenure (observed):** ${tenure_str}" \
"- **Health:** ${health}" \
"- **Temperature:** ${temp}" \
"- **First Seen:** ${S_FIRST[$s]}" \
"- **Last Seen:** ${S_LAST[$s]}" \
"" \
"### SMART Info" \
'```text' "$SMART_INFO" '```' \
"" \
"### SMART Health" \
'```text' "$SMART_HEALTH" '```' \
"" \
"### SMART Attributes" \
'```text' "$SMART_ATTRS" '```'
done
# ---------- CPU metrics for suggestions ----------
CPU_MODEL="Unknown"; TOTAL_CORES=""; TOTAL_THREADS=""; LOAD1=""; LOAD5=""; LOAD15=""; NORM="0.00"; CPU_LEVEL="Low"
HAS_INTEL_IGPU="no"; HAS_NVIDIA="no"; AVX2="unknown"
if has lscpu; then
CPU_MODEL="$(lscpu 2>/dev/null | awk -F: '/Model name/ {sub(/^ +/,"",$2); print $2; exit}')"
TPC="$(lscpu 2>/dev/null | awk -F: '/Thread\\(s\\) per core/ {gsub(/[^0-9]/,"",$2); print $2; exit}')"
CPS="$(lscpu 2>/dev/null | awk -F: '/Core\\(s\\) per socket/ {gsub(/[^0-9]/,"",$2); print $2; exit}')"
SOCK="$(lscpu 2>/dev/null | awk -F: '/Socket\\(s\\)/ {gsub(/[^0-9]/,"",$2); print $2; exit}')"
[[ -z "$TPC" ]] && TPC=1
[[ -z "$CPS" ]] && CPS=$(nproc 2>/dev/null || echo 1)
[[ -z "$SOCK" ]] && SOCK=1
TOTAL_CORES=$((CPS*SOCK))
TOTAL_THREADS=$((TOTAL_CORES*TPC))
[[ -z "$TOTAL_THREADS" || "$TOTAL_THREADS" -le 0 ]] && TOTAL_THREADS=$(nproc 2>/dev/null || echo 1)
AVX2="$(lscpu 2>/dev/null | awk -F: '/Flags/ { if ($2 ~ / avx2( |$)/) print "yes"; else print "no"; exit }')"
fi
if [[ -r /proc/loadavg ]]; then
LOAD1="$(awk '{print $1}' /proc/loadavg)"
LOAD5="$(awk '{print $2}' /proc/loadavg)"
LOAD15="$(awk '{print $3}' /proc/loadavg)"
NORM="$(awk -v l="$LOAD15" -v t="${TOTAL_THREADS:-1}" 'BEGIN{if(t>0) printf "%.2f", l/t; else print "0.00"}')"
CPU_LEVEL="$(awk -v n="$NORM" 'BEGIN{if(n>=0.90)print "High";else if(n>=0.60)print "Medium";else print "Low"}')"
fi
if has lspci; then
lspci 2>/dev/null | grep -qiE 'VGA.*Intel|Display.*Intel' && HAS_INTEL_IGPU="yes" || true
lspci 2>/dev/null | grep -qiE 'VGA.*NVIDIA' && HAS_NVIDIA="yes" || true
fi
# ---------- Suggestions page ----------
SUG="$OUT_DIR/suggestions"
mkdir -p "$SUG"
# Build scored list
LINES_FILE="$(mktemp)"
for s in "${!S_MODEL[@]}"; do
model="${S_MODEL[$s]}"; status="${S_STATUS[$s]}"; slug="${S_SLUG[$s]}"
dev="${L_DEV[$s]:-N/A}"; cap="${L_CAP[$s]:-N/A}"
health="${L_HEALTH[$s]:-N/A}"; temp_num="${L_TEMP_NUM[$s]:-0}"
poh="${L_POH[$s]:-N/A}"; ontime="$(fmt_age "$poh")"
rsc="${L_RSC[$s]:-0}"; cps="${L_CPS[$s]:-0}"; ou="${L_OU[$s]:-0}"; crc="${L_CRC[$s]:-0}"
pct="${L_PCTUSED[$s]:-0}"; cw="${L_CRITWARN[$s]:-0}"
risk=0; reasons=()
[[ "$status" != "Active" ]] && { risk=$((risk+80)); reasons+=("Disk not present (Disabled/Retired)"); }
if echo "$health" | grep -qi 'fail'; then risk=$((risk+100)); reasons+=("SMART reports failure"); fi
if [[ "$temp_num" -ge 60 ]]; then risk=$((risk+60)); reasons+=("Very hot (${temp_num}°C)")
elif [[ "$temp_num" -ge 50 ]]; then risk=$((risk+30)); reasons+=("Hot (${temp_num}°C)")
elif [[ "$temp_num" -ge 45 ]]; then risk=$((risk+15)); reasons+=("Warm (${temp_num}°C)")
fi
pohnum=$(num_only "$poh"); [[ -z "$pohnum" ]] && pohnum=0
if [[ "$pohnum" -ge 60000 ]]; then risk=$((risk+50)); reasons+=("Very high power-on hours (${pohnum}h)")
elif [[ "$pohnum" -ge 50000 ]]; then risk=$((risk+40)); reasons+=("High power-on hours (${pohnum}h)")
elif [[ "$pohnum" -ge 40000 ]]; then risk=$((risk+25)); reasons+=("Moderate power-on hours (${pohnum}h)")
elif [[ "$pohnum" -ge 30000 ]]; then risk=$((risk+15)); reasons+=("Rising power-on hours (${pohnum}h)")
fi
[[ "${rsc:-0}" -ge 1 ]] && { risk=$((risk+40)); reasons+=("Reallocated sectors: ${rsc}"); }
[[ "${cps:-0}" -ge 1 ]] && { risk=$((risk+50)); reasons+=("Current pending sectors: ${cps}"); }
[[ "${ou:-0}" -ge 1 ]] && { risk=$((risk+50)); reasons+=("Offline uncorrectable: ${ou}"); }
[[ "${crc:-0}" -ge 1 ]] && { risk=$((risk+5)); reasons+=("UDMA CRC errors: ${crc} (cable?)"); }
if [[ "${pct:-0}" -ge 95 ]]; then risk=$((risk+60)); reasons+=("NVMe wear ${pct}% used")
elif [[ "${pct:-0}" -ge 80 ]]; then risk=$((risk+40)); reasons+=("NVMe wear ${pct}% used")
elif [[ "${pct:-0}" -ge 60 ]]; then risk=$((risk+25)); reasons+=("NVMe wear ${pct}% used"); fi
[[ "${cw:-0}" -gt 0 ]] && { risk=$((risk+60)); reasons+=("NVMe critical warning ${cw}"); }
if echo "$model" | grep -q 'ST3000DM001'; then risk=$((risk+25)); reasons+=("Model ST3000DM001 has higher failure rates"); fi
reason_str="$(printf '%s; ' "${reasons[@]}")"; reason_str="${reason_str%; }"
printf "%03d|%s|%s|%s|%s|%s|%s|%s\n" \
"$risk" "$model" "$s" "$status" "$health" "$temp_num" "$ontime" "$reason_str" >> "$LINES_FILE"
done
# Sort by risk desc
SORTED="$(mktemp)"
sort -r -n -t '|' -k1,1 "$LINES_FILE" > "$SORTED"
REPLACE_SOON="$(awk -F'|' '$1>=60 {print}' "$SORTED")"
PLAN_LATER="$(awk -F'|' '$1>=30 && $1<60 {print}' "$SORTED")"
MONITOR_ONLY="$(awk -F'|' '$1<30 {print}' "$SORTED")"
# Capacity/headroom suggestion from /mnt/user (best-effort)
HEADROOM_NOTE=""
if grep -q "/mnt/user" "$SNAPSHOT_DIR/filesystems.txt" 2>/dev/null; then
USEDPCT=$(awk '/\/mnt\/user/ {for(i=1;i<=NF;i++) if($i ~ /%$/){gsub(/%/,"",$i); print $i; break}}' "$SNAPSHOT_DIR/filesystems.txt" | head -1)
if [[ -n "$USEDPCT" && "$USEDPCT" -ge 85 ]]; then
HEADROOM_NOTE="Array usage appears ~${USEDPCT}% — consider adding capacity or larger drives."
fi
fi
# Write suggestions page
{
echo "# Suggestions"
echo "*Generated: ${DATE_HUMAN}*"
echo
echo "## CPU"
echo "- **Model:** ${CPU_MODEL}"
echo "- **Cores/Threads:** ${TOTAL_CORES:-?}/${TOTAL_THREADS:-?}"
echo "- **Load (1/5/15):** ${LOAD1:-?}, ${LOAD5:-?}, ${LOAD15:-?} — **Normalized 15m:** ${NORM}× of threads (**${CPU_LEVEL}**)"
if [[ "$HAS_INTEL_IGPU" == "yes" ]]; then
echo "- **Hardware encoder:** Intel Quick Sync (iGPU) detected"
fi
if [[ "$HAS_NVIDIA" == "yes" ]]; then
echo "- **Hardware encoder:** NVIDIA GPU detected"
fi
if [[ "$HAS_INTEL_IGPU" != "yes" && "$HAS_NVIDIA" != "yes" ]]; then
echo "- **Hardware encoder:** none detected"
fi
echo "- **AVX2 support:** ${AVX2}"
echo
echo "### CPU suggestions"
case "$CPU_LEVEL" in
High) echo "- Sustained high CPU utilization. Consider a CPU upgrade **or** offload Plex transcodes to a GPU (Intel iGPU/NVIDIA).";;
Medium) echo "- Moderate utilization. Keep an eye on spikes; GPU transcoding can free CPU headroom.";;
Low) echo "- CPU headroom looks fine.";;
esac
if [[ "$HAS_INTEL_IGPU" != "yes" && "$HAS_NVIDIA" != "yes" ]]; then
echo "- Plex: add an Intel iGPU or NVIDIA card for hardware-accelerated transcodes."
fi
if [[ "$AVX2" != "yes" ]]; then
echo "- No AVX2 support; some modern codecs/filters run slower."
fi
echo
echo "This page flags disks to **replace**, **plan**, or **monitor** using SMART health, temps, power-on hours, and other indicators."
echo
if [[ -n "$HEADROOM_NOTE" ]]; then
echo "⚠️ ${HEADROOM_NOTE}"
echo
fi
echo "## Replace soon (High risk)"
echo "| Disk | Status | Health | Temp | On-time | Reasons |"
echo "|------|--------|--------|------|---------|---------|"
if [[ -n "$REPLACE_SOON" ]]; then
while IFS='|' read -r score model serial status health tnum ontime reasons; do
link="[${model} — ${serial}](disks/$(slugify "$model" "$serial")/)"
[[ -z "$tnum" ]] && tnum="N/A"
echo "| ${link} | ${status} | ${health} | ${tnum}°C | ${ontime} | ${reasons} |"
done <<< "$REPLACE_SOON"
else
echo "| _No urgent replacements identified_ | | | | | |"
fi
echo
echo "## Plan replacement (Medium risk)"
echo "| Disk | Status | Health | Temp | On-time | Reasons |"
echo "|------|--------|--------|------|---------|---------|"
if [[ -n "$PLAN_LATER" ]]; then
while IFS='|' read -r score model serial status health tnum ontime reasons; do
link="[${model} — ${serial}](disks/$(slugify "$model" "$serial")/)"
[[ -z "$tnum" ]] && tnum="N/A"
echo "| ${link} | ${status} | ${health} | ${tnum}°C | ${ontime} | ${reasons} |"
done <<< "$PLAN_LATER"
else
echo "| _Nothing here right now_ | | | | | |"
fi
echo
echo "## Monitor (Low risk)"
echo "| Disk | Status | Health | Temp | On-time | Notes |"
echo "|------|--------|--------|------|---------|-------|"
if [[ -n "$MONITOR_ONLY" ]]; then
while IFS='|' read -r score model serial status health tnum ontime reasons; do
link="[${model} — ${serial}](disks/$(slugify "$model" "$serial")/)"
[[ -z "$tnum" ]] && tnum="N/A"
note="${reasons:-Healthy overall}"
echo "| ${link} | ${status} | ${health} | ${tnum}°C | ${ontime} | ${note} |"
done <<< "$MONITOR_ONLY"
else
echo "| _All quiet_ | | | | | |"
fi
echo
echo "## Other suggestions"
echo "- Keep drives **<45°C** under load; above that, improve airflow or fan curves."
echo "- If you see **UDMA CRC errors**, swap SATA cables or reseat connectors."
echo "- Schedule SMART tests: **short daily**, **long monthly** (staggered)."
echo "- Maintain a **cold spare** of your most common capacity."
echo "- When replacing, consider moving to higher-capacity CMR drives to grow array with fewer spindles."
} > "$SUG/content.md"
# ---------- GPU section (vendor tools like GPU Statistics plugin) ----------
collect_gpu_section() {
local HW_DIR="${OUT_DIR}/hardware"
local OUT="${HW_DIR}/content.md"
local now="$(date '+%b %e, %Y %H:%M %Z')"
{
echo ""
echo "## GPU"
echo "*Collected: ${now}*"
echo ""
} >> "${OUT}"
# ---------- NVIDIA ----------
if has nvidia-smi; then
echo "### NVIDIA" >> "${OUT}"
mapfile -t nvsmi < <(nvidia-smi \
--query-gpu=index,pci.bus_id,name,uuid,driver_version,temperature.gpu,utilization.gpu,utilization.memory,memory.total,memory.used,fan.speed,power.draw,pstate \
--format=csv,noheader,nounits 2>/dev/null)
if ((${#nvsmi[@]})); then
{
echo ""
echo "| GPU | Bus | Model | Driver | Temp (°C) | GPU% | MEM% | VRAM (MiB) | Fan% | Power (W) | Pstate |"
echo "|---:|:-----|:------|:------:|----------:|-----:|-----:|-----------:|----:|---------:|:------:|"
} >> "${OUT}"
for line in "${nvsmi[@]}"; do
IFS=',' read -r idx bus name uuid drv temp utilg utilm mtot mused fan pow pstate <<<"$(echo "$line" | sed 's/, */,/g')"
idx="${idx// /}"; temp="${temp// /}"; utilg="${utilg// /}"; utilm="${utilm// /}"
mtot="${mtot// /}"; mused="${mused// /}"; fan="${fan// /}"; pow="${pow// /}"
printf "| %s | %s | %s | %s | %s | %s | %s | %s/%s | %s | %s | %s |\n" \
"${idx}" "${bus}" "${name}" "${drv}" "${temp:-N/A}" "${utilg:-0}" "${utilm:-0}" \
"${mused:-0}" "${mtot:-0}" "${fan:-0}" "${pow:-0}" "${pstate:-N/A}" >> "${OUT}"
done
echo "" >> "${OUT}"
# Active processes
mapfile -t nvprocs < <(nvidia-smi \
--query-compute-apps=pid,process_name,gpu_uuid,used_memory \
--format=csv,noheader,nounits 2>/dev/null || true)
if ((${#nvprocs[@]})); then
{
echo "#### NVIDIA Processes"
echo ""
echo "| PID | Process | GPU UUID | VRAM (MiB) |"
echo "|----:|:--------|:--------:|-----------:|"
} >> "${OUT}"
for p in "${nvprocs[@]}"; do
IFS=',' read -r pid pname guuid vram <<<"$(echo "$p" | sed 's/, */,/g')"
pid="${pid// /}"; vram="${vram// /}"
printf "| %s | %s | %s | %s |\n" "${pid}" "${pname}" "${guuid}" "${vram:-0}" >> "${OUT}"
done
echo "" >> "${OUT}"
fi
else
echo "_nvidia-smi returned no data._" >> "${OUT}"
echo "" >> "${OUT}"
fi
fi
# ---------- Intel ----------
if has intel_gpu_top; then
echo "### Intel" >> "${OUT}"
if has timeout; then
intel_gpu_top -J -n 1 2>/dev/null > /tmp/intel_gpu_top.json || \
timeout -k .7 1.3 intel_gpu_top -J -s 250 2>/dev/null | tail -n 1 > /tmp/intel_gpu_top.json
else
intel_gpu_top -J -n 1 2>/dev/null > /tmp/intel_gpu_top.json || \
intel_gpu_top -J -s 250 2>/dev/null | tail -n 1 > /tmp/intel_gpu_top.json
fi
if [[ -s /tmp/intel_gpu_top.json ]]; then
local pwr rc6 r3d blit vid vde
pwr="$(grep -o '"GPU":[ ]*[0-9.]\+' /tmp/intel_gpu_top.json | head -1 | grep -o '[0-9.]\+')"
rc6="$(grep -o '"rc6":[^{}]*"value":[ ]*[0-9.]' /tmp/intel_gpu_top.json | grep -o '[0-9.]\+' | head -1)"
r3d="$(grep -o '"Render/3D/0":[^}]*"busy":[ ]*[0-9.]' /tmp/intel_gpu_top.json | grep -o '[0-9.]\+' | tail -1)"
blit="$(grep -o '"Blitter/0":[^}]*"busy":[ ]*[0-9.]' /tmp/intel_gpu_top.json | grep -o '[0-9.]\+' | tail -1)"
vid="$(grep -o '"Video/0":[^}]*"busy":[ ]*[0-9.]' /tmp/intel_gpu_top.json | grep -o '[0-9.]\+' | tail -1)"
vde="$(grep -o '"VideoEnhance/0":[^}]*"busy":[ ]*[0-9.]' /tmp/intel_gpu_top.json | grep -o '[0-9.]\+' | tail -1)"
{
echo ""
echo "| Power (W) | RC6 (%) | Render% | Blitter% | Video% | VideoEnhance% |"
echo "|----------:|--------:|--------:|---------:|-------:|--------------:|"
echo "| ${pwr:-0} | ${rc6:-0} | ${r3d:-0} | ${blit:-0} | ${vid:-0} | ${vde:-0} |"
echo ""
} >> "${OUT}"
else
echo "_intel_gpu_top returned no data._" >> "${OUT}"
echo "" >> "${OUT}"
fi
fi
# ---------- AMD ----------
if has radeontop; then
echo "### AMD" >> "${OUT}"
radeontop -d - -l 1 2>/dev/null > /tmp/radeontop.txt || true
if [[ -s /tmp/radeontop.txt ]]; then
local gpu mem temp
gpu="$(grep -o 'gpu[[:space:]]\+[0-9]\+%' /tmp/radeontop.txt | awk '{print $2}' | tr -d '%')"
mem="$(grep -o 'mem[[:space:]]\+[0-9]\+%' /tmp/radeontop.txt | awk '{print $2}' | tr -d '%')"
temp="$(grep -o 'temp[[:space:]]\+[0-9]\+C' /tmp/radeontop.txt | awk '{print $2}' | tr -d 'C')"
{
echo ""
echo "| GPU% | MEM% | Temp (°C) |"
echo "|----:|-----:|----------:|"
echo "| ${gpu:-0} | ${mem:-0} | ${temp:-N/A} |"
echo ""
} >> "${OUT}"
else
echo "_radeontop returned no data._" >> "${OUT}"
echo "" >> "${OUT}"
fi
fi
# None found?
if ! has nvidia-smi && ! has intel_gpu_top && ! has radeontop; then
echo "_No supported GPU tools found. Install vendor tool(s) (NVIDIA Driver / Intel GPU TOP / RadeonTop) and re-run._" >> "${OUT}"
echo "" >> "${OUT}"
fi
}
# Append GPU section to Hardware page
collect_gpu_section
# ---------- Unraid main page ----------
mkpage "$OUT_DIR" \
"# Unraid" "*Generated: ${DATE_HUMAN}*" "" \
"## Overview" \
"- **System:** [system](./system/)" \
"- **Hardware:** [hardware](./hardware/)" \
"- **Storage:** [storage](./storage/)" \
" - **Disks (Inventory):** [disks](./disks/)" \
"- **Networking:** [networking](./networking/)" \
"- **Users:** [users](./users/)" \
"- **Services:** [services](./services/)" \
"- **Docker:** [docker](./docker/)" \
"- **Suggestions:** [suggestions](./suggestions/)" \
"" \
"> Raw snapshot files: \`${SNAPSHOT_DIR}\`" \
"" \
"## Quick Status" \
'```text' "$(sed -n '1,10p' "$SNAPSHOT_DIR/system-info.txt" 2>/dev/null || echo "(no system-info yet)")" '```'
# ---------- Perms & archive ----------
chown -R "$PUID:$PGID" "$OUT_DIR" 2>/dev/null || true
find "$OUT_DIR" -type d -exec chmod 775 {} + 2>/dev/null || true
find "$OUT_DIR" -type f -exec chmod 664 {} + 2>/dev/null || true
tar -czf "${SNAPSHOT_DIR}.tar.gz" -C "$DOCS_DIR" "snapshot_${TS}" 2>/dev/null || true
echo "✅ Wiki updated at: $OUT_DIR"
echo "✅ Disk state saved at: $STATE_FILE"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment