Created
September 7, 2025 19:51
-
-
Save jasontucker/cde4b7d31f986af00a9bb8ff08ff8330 to your computer and use it in GitHub Desktop.
Generate server snapshot + WikiDocs pages (Unraid)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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