#!/usr/bin/env bash # ============================================================================= # ZFS Pool Health Report — NAS08 # Sends an HTML status email to rufus.king@kingdezigns.com # Cron example: 0 7 * * * /opt/scripts/zfs_report_nas08.sh # # Commands executed per pool: # zpool status -v — full status + verbose per-file error listing # zpool list — pool-level size / capacity / health # zfs list -r — per-dataset breakdown (used, avail, refer, mount) # ============================================================================= set -euo pipefail # ── Configuration ───────────────────────────────────────────────────────────── TARGET_HOST="NAS08" REPORT_TO="rufus.king@kingdezigns.com" REPORT_FROM="zfs-monitor@${TARGET_HOST,,}.local" SCRUB_MAX_AGE_DAYS=8 # Warn if last scrub is older than this CAP_WARN=80 # Capacity % that triggers WARNING CAP_CRIT=90 # Capacity % that triggers CRITICAL # ────────────────────────────────────────────────────────────────────────────── NOW=$(date '+%Y-%m-%d %H:%M:%S') HOSTNAME_REAL=$(hostname -s 2>/dev/null || echo "$TARGET_HOST") # ── HTML escape helper ──────────────────────────────────────────────────────── html_escape() { sed 's/&/\&/g; s//\>/g; s/"/\"/g'; } # ── Collect pool list ───────────────────────────────────────────────────────── POOL_LIST=$(zpool list -H -o name 2>/dev/null) || { echo "ERROR: zpool not found"; exit 1; } OVERALL_STATUS="HEALTHY" POOL_BLOCKS="" POOL_COUNT=0 PROBLEM_COUNT=0 # ── Per-pool loop ───────────────────────────────────────────────────────────── while IFS= read -r POOL; do [[ -z "$POOL" ]] && continue POOL_COUNT=$((POOL_COUNT + 1)) # 1. zpool status -v ── verbose: includes per-file permanent error listing STATUS_RAW=$(zpool status -v "$POOL" 2>&1) # 2. zpool list ── raw (-p) for math, human for display ZPOOL_RAW=$(zpool list -H -p -o name,size,alloc,free,frag,cap,health "$POOL" 2>&1) ZPOOL_HUM=$(zpool list -H -o name,size,alloc,free,frag,cap,health "$POOL" 2>&1) HEALTH=$(echo "$ZPOOL_RAW" | awk '{print $7}') CAP_RAW=$(echo "$ZPOOL_RAW" | awk '{print $6}') SIZE_H=$(echo "$ZPOOL_HUM" | awk '{print $2}') ALLOC_H=$(echo "$ZPOOL_HUM" | awk '{print $3}') FREE_H=$(echo "$ZPOOL_HUM" | awk '{print $4}') FRAG_H=$(echo "$ZPOOL_HUM" | awk '{print $5}') CAP_H=$(echo "$ZPOOL_HUM" | awk '{print $6}') # 3. zfs list -r ── per-dataset: name, used, avail, refer, mountpoint ZFS_LIST_RAW=$(zfs list -r -H -o name,used,avail,refer,mountpoint "$POOL" 2>&1) # Build dataset table rows DATASET_ROWS="" DATASET_COUNT=0 while IFS=$'\t' read -r DS_NAME DS_USED DS_AVAIL DS_REFER DS_MOUNT; do [[ -z "$DS_NAME" ]] && continue DATASET_COUNT=$((DATASET_COUNT + 1)) (( DATASET_COUNT % 2 == 0 )) && ROW_SHADE="#f9fafb" || ROW_SHADE="white" DS_NAME_ESC=$(echo "$DS_NAME" | html_escape) DS_MOUNT_ESC=$(echo "$DS_MOUNT" | html_escape) DATASET_ROWS+=" ${DS_NAME_ESC} ${DS_USED} ${DS_AVAIL} ${DS_REFER} ${DS_MOUNT_ESC} " done <<< "$ZFS_LIST_RAW" DATASET_TABLE="
▶ Datasets / Filesystems — zfs list -r  (${DATASET_COUNT} entries)
${DATASET_ROWS}
Dataset Used Available Referenced Mountpoint
" # ── Scrub parse ──────────────────────────────────────────────────────────── SCRUB_LINE=$(echo "$STATUS_RAW" | grep -E "scan:|scrub" | head -1 || true) if echo "$SCRUB_LINE" | grep -q "scrub repaired"; then LAST_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\w{3} \w{3} +\d+ \d+:\d+:\d+ \d{4}' | head -1 || echo "unknown") ERRORS_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1 || echo "0 errors") SCRUB_STATUS="Completed" elif echo "$SCRUB_LINE" | grep -q "in progress"; then LAST_SCRUB="In Progress"; SCRUB_STATUS="Running"; ERRORS_SCRUB="—" elif echo "$SCRUB_LINE" | grep -q "none requested"; then LAST_SCRUB="Never"; SCRUB_STATUS="Never Run"; ERRORS_SCRUB="—" else LAST_SCRUB=$(echo "$SCRUB_LINE" | sed 's/.*on //' | sed 's/ with.*//' || echo "unknown") SCRUB_STATUS="Completed" ERRORS_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1 || echo "0 errors") fi # ── Severity logic ───────────────────────────────────────────────────────── POOL_SEVERITY="ok" POOL_LABEL="HEALTHY" if [[ "$HEALTH" != "ONLINE" ]]; then POOL_SEVERITY="critical"; POOL_LABEL="$HEALTH" OVERALL_STATUS="CRITICAL"; PROBLEM_COUNT=$((PROBLEM_COUNT + 1)) fi CAP_NUM=${CAP_RAW//%/}; CAP_NUM=${CAP_NUM%%.*} if [[ "$CAP_NUM" =~ ^[0-9]+$ ]]; then if (( CAP_NUM >= CAP_CRIT )); then POOL_SEVERITY="critical"; POOL_LABEL="CRITICAL — CAPACITY ${CAP_H}" OVERALL_STATUS="CRITICAL" [[ "$HEALTH" == "ONLINE" ]] && PROBLEM_COUNT=$((PROBLEM_COUNT + 1)) elif (( CAP_NUM >= CAP_WARN )); then [[ "$POOL_SEVERITY" == "ok" ]] && POOL_SEVERITY="warning" [[ "$POOL_LABEL" == "HEALTHY" ]] && POOL_LABEL="WARNING — CAPACITY ${CAP_H}" [[ "$OVERALL_STATUS" == "HEALTHY" ]] && OVERALL_STATUS="WARNING" fi fi if [[ "$LAST_SCRUB" == "Never" ]]; then [[ "$POOL_SEVERITY" == "ok" ]] && POOL_SEVERITY="warning" [[ "$POOL_LABEL" == "HEALTHY" ]] && POOL_LABEL="WARNING — NO SCRUB" [[ "$OVERALL_STATUS" == "HEALTHY" ]] && OVERALL_STATUS="WARNING" fi case "$POOL_SEVERITY" in ok) BADGE_BG="#1a7f4b"; BADGE_TEXT="white"; ROW_BG="#f0fdf4" ;; warning) BADGE_BG="#b45309"; BADGE_TEXT="white"; ROW_BG="#fffbeb" ;; critical) BADGE_BG="#b91c1c"; BADGE_TEXT="white"; ROW_BG="#fff1f2" ;; esac # ── Error blocks ─────────────────────────────────────────────────────────── # a) Pool/vdev state errors ERROR_LINES=$(echo "$STATUS_RAW" \ | grep -E "(DEGRADED|FAULTED|OFFLINE|REMOVED|UNAVAIL|errors:)" \ | grep -v "errors: No known data errors" || true) # b) Permanent per-file errors exposed by -v flag VERBOSE_ERRORS=$(echo "$STATUS_RAW" \ | awk '/Permanent errors have been detected/,0' \ | grep -v "^$" || true) ERROR_HTML="" if [[ -n "$ERROR_LINES" ]]; then ERROR_LINES_ESC=$(echo "$ERROR_LINES" | head -30 | html_escape) ERROR_HTML+="
⚠ Pool / vdev Errors
${ERROR_LINES_ESC}
" fi if [[ -n "$VERBOSE_ERRORS" ]]; then VERBOSE_ESC=$(echo "$VERBOSE_ERRORS" | head -50 | html_escape) ERROR_HTML+="
📄 Affected Files — zpool status -v
${VERBOSE_ESC}
" fi # ── vdev config tree ─────────────────────────────────────────────────────── VDEV_TREE=$(echo "$STATUS_RAW" \ | sed -n '/config:/,/errors:/p' \ | grep -v "^$" | head -60 | html_escape || true) # ── Assemble pool card ───────────────────────────────────────────────────── POOL_BLOCKS+="
📉 ${POOL} pool ${POOL_LABEL}
Total Size
${SIZE_H}
Used
${ALLOC_H}
Free
${FREE_H}
Capacity
${CAP_H}
Fragmentation
${FRAG_H}
Health
${HEALTH}
🔍 Last Scrub ${SCRUB_STATUS}  |  ${LAST_SCRUB}  |  Errors: ${ERRORS_SCRUB}
${ERROR_HTML}
▶ vdev / Drive Configuration — zpool status -v
${VDEV_TREE}
${DATASET_TABLE}
" done <<< "$POOL_LIST" # ── Overall banner ───────────────────────────────────────────────────────────── case "$OVERALL_STATUS" in HEALTHY) BANNER_BG="#1a7f4b"; BANNER_ICON="✅"; BANNER_MSG="All pools are healthy — no action required." ;; WARNING) BANNER_BG="#b45309"; BANNER_ICON="⚠️"; BANNER_MSG="One or more pools require your attention." ;; CRITICAL) BANNER_BG="#b91c1c"; BANNER_ICON="🚨"; BANNER_MSG="CRITICAL issue detected — immediate action required!" ;; esac SUBJECT="[ZFS] ${TARGET_HOST} — ${OVERALL_STATUS} — ${NOW}" # ── Build HTML email ─────────────────────────────────────────────────────────── HTML=$(cat <
ZFS Pool Health Monitor
🔌 ${TARGET_HOST}
${NOW}  |  ${POOL_COUNT} pool(s) monitored
${BANNER_ICON} ${OVERALL_STATUS}
${BANNER_ICON}  ${BANNER_MSG}
${POOL_BLOCKS}
📋 Report Summary
Host ${TARGET_HOST} (${HOSTNAME_REAL})
Report Time ${NOW}
Pools Checked ${POOL_COUNT}
Pools with Issues ${PROBLEM_COUNT}
Capacity Warning Threshold ≥ ${CAP_WARN}%
Capacity Critical Threshold ≥ ${CAP_CRIT}%
Scrub Age Warning > ${SCRUB_MAX_AGE_DAYS} days
Commands Executed
zpool status -v <pool>
zpool list -H -o name,size,alloc,free,frag,cap,health <pool>
zfs list -r -H -o name,used,avail,refer,mountpoint <pool>
Automated ZFS Monitor  |  ${TARGET_HOST}  |  KingDezigns Infrastructure
EOF ) # ── Send email ───────────────────────────────────────────────────────────────── send_email() { if command -v sendmail &>/dev/null; then { echo "To: ${REPORT_TO}"; echo "From: ${REPORT_FROM}"; echo "Subject: ${SUBJECT}" echo "MIME-Version: 1.0"; echo "Content-Type: text/html; charset=UTF-8"; echo "" echo "$HTML"; } | sendmail -t echo "Report sent via sendmail to ${REPORT_TO}" elif command -v mailx &>/dev/null; then echo "$HTML" | mailx -a "Content-Type: text/html" -s "$SUBJECT" "$REPORT_TO" echo "Report sent via mailx to ${REPORT_TO}" elif command -v mail &>/dev/null; then echo "$HTML" | mail -a "Content-Type: text/html; charset=UTF-8" -s "$SUBJECT" "$REPORT_TO" echo "Report sent via mail to ${REPORT_TO}" else echo "ERROR: No mail transport found. Install sendmail, mailx, or msmtp." FALLBACK="/tmp/zfs_report_${TARGET_HOST}_$(date +%Y%m%d_%H%M%S).html" echo "$HTML" > "$FALLBACK" echo "HTML report saved to: $FALLBACK" exit 1 fi } send_email echo "[$(date '+%Y-%m-%d %H:%M:%S')] ZFS report for ${TARGET_HOST} — Status: ${OVERALL_STATUS} — Pools: ${POOL_COUNT} — Problems: ${PROBLEM_COUNT}"