834 lines
33 KiB
Bash
834 lines
33 KiB
Bash
#!/bin/bash
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ----------------------------- Sunday Morning Coffee Report -----------------------------------
|
||
# -----------------------------------------------------------------------------------------------
|
||
# Weekly system overview — everything that happened this week in one clean read.
|
||
# Designed to be read over coffee Sunday morning while the system is fully caught up
|
||
# from the 2:30am maintenance window.
|
||
# Schedule: 0 7 * * 0 (7am Sunday — after weekly_sync_maintenance.sh finishes at ~3am)
|
||
# Maintenance window completes → 4 hours of fresh data → report ready ☕
|
||
#
|
||
# Sections:
|
||
# 🖥️ System — uptime, memory, boot drive, cache drive, reboots
|
||
# 📀 Array — disk count, parity status, ZFS health, drive temps
|
||
# 🎬 Transcodes — ramdisk usage, weekly peak, flips, session split
|
||
# 🎵 Media Activity — arr cleanup stats, arr recovery stats, library health
|
||
# 🌐 Rsync — weekly transfer totals, per-share breakdown
|
||
# 🛡️ Watchdog — system watchdog, docker watchdog, failover (all continuous loops)
|
||
# strikes, skip list, restarts, system snapshot, container overview
|
||
# 🔐 Security — SSL cert expiry per domain
|
||
# 📊 Emby — weekly stream count, top users, top content
|
||
# ⚙️ Health — SMART summary, docker container count, Gitea sync status
|
||
# ⚠️ Issues — anything requiring attention collected above
|
||
#
|
||
# Data sources (reads only — no writes except the notification):
|
||
# DATA_DIR stats files — arr cleanup, recovery, transcode, bandwidth history
|
||
# /boot/config — failover state, watchdog reboot log
|
||
# /tmp — watchdog strike state files
|
||
# /proc, /sys — system memory, uptime
|
||
# /var/local/emhttp/ — unRAID array info
|
||
# Emby API — session history
|
||
# Arr APIs — current queue depth
|
||
# tailscale — network status
|
||
# openssl — live SSL cert check
|
||
# smartctl — drive health
|
||
#
|
||
# All configuration in Master.conf.
|
||
# Supports --dry-run to preview report without sending notification.
|
||
# -----------------------------------------------------------------------------------------------
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
source "$SCRIPT_DIR/../Master.conf"
|
||
source "$SCRIPT_DIR/../common.sh"
|
||
|
||
parse_args "$@"
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ Setup ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
acquire_lock
|
||
|
||
detect_hosts
|
||
|
||
REPORT_DATE=$(date '+%A, %B %-d, %Y')
|
||
WEEK_START=$(date -d "7 days ago" '+%Y-%m-%d')
|
||
TODAY=$(date '+%Y-%m-%d')
|
||
NOW=$(date +%s)
|
||
|
||
REPORT=() # all report lines
|
||
ISSUES=() # items needing attention
|
||
FINDINGS=() # notable but not critical
|
||
|
||
section() {
|
||
REPORT+=("")
|
||
REPORT+=("$1")
|
||
REPORT+=("$(printf '%.0s─' {1..50})")
|
||
}
|
||
|
||
line() {
|
||
REPORT+=(" $1")
|
||
}
|
||
|
||
issue() {
|
||
ISSUES+=("$1")
|
||
REPORT+=(" ⚠️ $1")
|
||
}
|
||
|
||
finding() {
|
||
FINDINGS+=("$1")
|
||
REPORT+=(" ℹ️ $1")
|
||
}
|
||
|
||
format_bytes() {
|
||
local bytes=$1
|
||
if (( bytes > 1073741824 )); then
|
||
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
|
||
elif (( bytes > 1048576 )); then
|
||
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
|
||
else
|
||
echo "${bytes}B"
|
||
fi
|
||
}
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 🖥️ System ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "🖥️ SYSTEM"
|
||
|
||
# Uptime
|
||
UPTIME_SECONDS=$(awk '{print int($1)}' /proc/uptime)
|
||
UPTIME_DAYS=$(( UPTIME_SECONDS / 86400 ))
|
||
UPTIME_HOURS=$(( (UPTIME_SECONDS % 86400) / 3600 ))
|
||
BOOT_TIME=$(date -d "@$(( NOW - UPTIME_SECONDS ))" '+%A %-d %b at %-I:%M%p')
|
||
line "Uptime: ${UPTIME_DAYS}d ${UPTIME_HOURS}hr (up since $BOOT_TIME)"
|
||
|
||
# Reboot history this week
|
||
REBOOT_COUNT=0
|
||
if [[ -f "$SYS_WATCHDOG_REBOOT_LOG" ]]; then
|
||
WEEK_EPOCH=$(date -d "$WEEK_START" +%s)
|
||
REBOOT_COUNT=$(awk -v cutoff="$WEEK_EPOCH" '$1 >= cutoff' \
|
||
"$SYS_WATCHDOG_REBOOT_LOG" 2>/dev/null | wc -l)
|
||
fi
|
||
if [[ "$REBOOT_COUNT" -gt 0 ]]; then
|
||
issue "Reboots this week: $REBOOT_COUNT (system watchdog triggered)"
|
||
else
|
||
line "Reboots this week: 0 ✅"
|
||
fi
|
||
|
||
# Memory
|
||
MEM_TOTAL_KB=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
|
||
MEM_AVAIL_KB=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
|
||
MEM_USED_KB=$(( MEM_TOTAL_KB - MEM_AVAIL_KB ))
|
||
MEM_TOTAL_GB=$(awk "BEGIN {printf \"%.0f\", $MEM_TOTAL_KB / 1048576}")
|
||
MEM_USED_GB=$(awk "BEGIN {printf \"%.1f\", $MEM_USED_KB / 1048576}")
|
||
MEM_FREE_GB=$(awk "BEGIN {printf \"%.1f\", $MEM_AVAIL_KB / 1048576}")
|
||
|
||
ARC_SIZE=0
|
||
if [[ -f /proc/spl/kstat/zfs/arcstats ]]; then
|
||
ARC_BYTES=$(awk '/^size / {print $3}' /proc/spl/kstat/zfs/arcstats 2>/dev/null || echo 0)
|
||
ARC_SIZE=$(awk "BEGIN {printf \"%.1f\", $ARC_BYTES / 1073741824}")
|
||
fi
|
||
line "Memory: ${MEM_USED_GB}GB used / ${MEM_TOTAL_GB}GB total (ARC: ${ARC_SIZE}GB)"
|
||
|
||
# Boot drive
|
||
BOOT_PCT=$(df /boot --output=pcent 2>/dev/null | tail -1 | tr -d ' %')
|
||
BOOT_USED=$(df /boot -h --output=used 2>/dev/null | tail -1 | tr -d ' ')
|
||
BOOT_SIZE=$(df /boot -h --output=size 2>/dev/null | tail -1 | tr -d ' ')
|
||
if [[ "${BOOT_PCT:-0}" -ge 80 ]]; then
|
||
issue "Boot drive: ${BOOT_PCT}% used (${BOOT_USED}/${BOOT_SIZE}) — getting full"
|
||
else
|
||
line "Boot drive: ${BOOT_PCT}% used (${BOOT_USED}/${BOOT_SIZE})"
|
||
fi
|
||
|
||
# Cache drive
|
||
CACHE_PCT=$(df /mnt/cache --output=pcent 2>/dev/null | tail -1 | tr -d ' %')
|
||
CACHE_AVAIL=$(df /mnt/cache -h --output=avail 2>/dev/null | tail -1 | tr -d ' ')
|
||
CACHE_SIZE=$(df /mnt/cache -h --output=size 2>/dev/null | tail -1 | tr -d ' ')
|
||
if [[ "${CACHE_PCT:-0}" -ge 85 ]]; then
|
||
issue "Cache drive: ${CACHE_PCT}% used (${CACHE_AVAIL} free of ${CACHE_SIZE})"
|
||
else
|
||
line "Cache drive: ${CACHE_PCT}% used (${CACHE_AVAIL} free of ${CACHE_SIZE})"
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 📀 Array ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "📀 ARRAY"
|
||
|
||
# unRAID array info
|
||
if [[ -f /var/local/emhttp/disks.ini ]]; then
|
||
DISK_COUNT=$(grep -c "^\[disk" /var/local/emhttp/disks.ini 2>/dev/null || echo "?")
|
||
line "Array disks: $DISK_COUNT"
|
||
fi
|
||
|
||
# Parity
|
||
if [[ -f /var/local/emhttp/parity-date.txt ]]; then
|
||
PARITY_INFO=$(cat /var/local/emhttp/parity-date.txt 2>/dev/null)
|
||
if echo "$PARITY_INFO" | grep -q "progress"; then
|
||
finding "Parity check in progress"
|
||
else
|
||
PARITY_DATE=$(echo "$PARITY_INFO" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)
|
||
PARITY_ERRORS=$(echo "$PARITY_INFO" | grep -oE 'errors=[0-9]+' | cut -d= -f2 || echo 0)
|
||
if [[ "${PARITY_ERRORS:-0}" -gt 0 ]]; then
|
||
issue "Parity: $PARITY_ERRORS errors on last check ($PARITY_DATE)"
|
||
else
|
||
line "Parity: OK (last check: ${PARITY_DATE:-unknown}, 0 errors)"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# ZFS pool health
|
||
if command -v zpool >/dev/null 2>&1; then
|
||
POOLS=$(zpool list -H -o name,health 2>/dev/null)
|
||
while IFS=$'\t' read -r pool health; do
|
||
[[ -z "$pool" ]] && continue
|
||
# Skip single-disk unRAID array pools
|
||
if [[ "$health" == "ONLINE" ]]; then
|
||
line "ZFS $pool: ONLINE ✅"
|
||
else
|
||
issue "ZFS $pool: $health — check immediately"
|
||
fi
|
||
done <<< "$POOLS"
|
||
fi
|
||
|
||
# Drive temperatures from SMART
|
||
if command -v smartctl >/dev/null 2>&1; then
|
||
TEMP_WARN=false
|
||
TEMP_SUMMARY=""
|
||
for disk in /dev/sd? /dev/nvme?; do
|
||
[[ ! -e "$disk" ]] && continue
|
||
DISK_NAME=$(basename "$disk")
|
||
# Skip ignored drives
|
||
SKIP=false
|
||
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
|
||
[[ "$DISK_NAME" == "$ignore" ]] && SKIP=true && break
|
||
done
|
||
[[ "$SKIP" == true ]] && continue
|
||
TEMP=$(smartctl -A "$disk" 2>/dev/null | \
|
||
awk '/Temperature_Celsius|Temperature:/ {print $NF; exit}' | tr -d '°C')
|
||
[[ -z "$TEMP" ]] && continue
|
||
TEMP_INT=$(printf "%.0f" "$TEMP" 2>/dev/null || echo 0)
|
||
if [[ "$TEMP_INT" -ge "${SMART_TEMP_CRIT:-55}" ]]; then
|
||
issue "Drive $DISK_NAME: ${TEMP_INT}°C — CRITICAL"
|
||
TEMP_WARN=true
|
||
elif [[ "$TEMP_INT" -ge "${SMART_TEMP_WARN:-45}" ]]; then
|
||
finding "Drive $DISK_NAME: ${TEMP_INT}°C — warm"
|
||
TEMP_WARN=true
|
||
fi
|
||
[[ -n "$TEMP_SUMMARY" ]] && TEMP_SUMMARY+=", "
|
||
TEMP_SUMMARY+="${DISK_NAME}:${TEMP_INT}°C"
|
||
done
|
||
if [[ "$TEMP_WARN" == false ]]; then
|
||
line "Drive temps: all normal"
|
||
fi
|
||
[[ -n "$TEMP_SUMMARY" ]] && line "Temps: $TEMP_SUMMARY"
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 🎬 Transcodes ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "🎬 TRANSCODES"
|
||
|
||
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
|
||
RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used | tail -1 | tr -d ' ')
|
||
RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", $RAMDISK_USED_KB / 1048576}")
|
||
RAMDISK_AVAIL_KB=$(df "$RAMDISK_PATH" --output=avail | tail -1 | tr -d ' ')
|
||
RAMDISK_AVAIL_GB=$(awk "BEGIN {printf \"%.2f\", $RAMDISK_AVAIL_KB / 1048576}")
|
||
SYMLINK_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null | xargs basename 2>/dev/null || echo "unknown")
|
||
line "Ramdisk now: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB free → $SYMLINK_TARGET"
|
||
else
|
||
issue "Ramdisk not mounted"
|
||
fi
|
||
|
||
if [[ -f "$TRANSCODE_DAILY_LOG" ]]; then
|
||
WEEK_PEAK=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {if ($2 > max) max=$2} END {printf "%.2f", max+0}' \
|
||
"$TRANSCODE_DAILY_LOG")
|
||
WEEK_FLIPS=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$3} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
|
||
WEEK_RAM=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$4} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
|
||
WEEK_SSD=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$5} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
|
||
WEEK_FILES=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$6} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
|
||
|
||
line "Week peak: ${WEEK_PEAK}GB | flips: ${WEEK_FLIPS} | files cleaned: ${WEEK_FILES}"
|
||
line "Sessions: ${WEEK_RAM} ramdisk / ${WEEK_SSD} SSD"
|
||
|
||
PEAK_INT=$(printf "%.0f" "$WEEK_PEAK" 2>/dev/null || echo 0)
|
||
WARN_INT=$(printf "%.0f" "$RAMDISK_WARN_GB" 2>/dev/null || echo 0)
|
||
if [[ "$PEAK_INT" -ge "$WARN_INT" ]]; then
|
||
finding "Transcode peak ${WEEK_PEAK}GB reached warn threshold — consider increasing RAMDISK_SIZE"
|
||
fi
|
||
if [[ "${WEEK_FLIPS:-0}" -ge "${TRANSCODE_FLIP_WARN:-3}" ]]; then
|
||
finding "Transcode flips this week: $WEEK_FLIPS — monitor ramdisk headroom"
|
||
fi
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 🎵 Media Activity ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "🎵 MEDIA ACTIVITY"
|
||
|
||
# Arr cleanup stats
|
||
if [[ -f "$ARR_CLEANUP_STATS" ]]; then
|
||
for arr in lidarr sonarr radarr; do
|
||
WEEK_ORPHANS=$(awk -F'|' -v cutoff="$WEEK_START" -v a="$arr" \
|
||
'$1 >= cutoff && $2 == a {sum+=$3} END {print sum+0}' "$ARR_CLEANUP_STATS")
|
||
WEEK_BYTES=$(awk -F'|' -v cutoff="$WEEK_START" -v a="$arr" \
|
||
'$1 >= cutoff && $2 == a {sum+=$4} END {print sum+0}' "$ARR_CLEANUP_STATS")
|
||
WEEK_TRACKED=$(awk -F'|' -v cutoff="$WEEK_START" -v a="$arr" \
|
||
'BEGIN{max=0} $1 >= cutoff && $2 == a && $8 > max {max=$8} END {print max+0}' \
|
||
"$ARR_CLEANUP_STATS")
|
||
if [[ "${WEEK_ORPHANS:-0}" -gt 0 ]]; then
|
||
FREED=$(format_bytes "${WEEK_BYTES:-0}")
|
||
line "${arr^} cleanup: $WEEK_ORPHANS orphans removed ($FREED freed) | tracked: $WEEK_TRACKED files"
|
||
else
|
||
line "${arr^} cleanup: clean ✅ (tracked: $WEEK_TRACKED files)"
|
||
fi
|
||
done
|
||
else
|
||
line "Arr cleanup stats: no data yet (runs after first cleanup)"
|
||
fi
|
||
|
||
# Arr recovery stats
|
||
if [[ -f "$ARR_RECOVERY_STATS" ]]; then
|
||
WEEK_ACTIONED=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$3} END {print sum+0}' "$ARR_RECOVERY_STATS")
|
||
WEEK_RUNS=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {count++} END {print count+0}' "$ARR_RECOVERY_STATS")
|
||
if [[ "${WEEK_ACTIONED:-0}" -gt 0 ]]; then
|
||
line "Arr recovery: $WEEK_ACTIONED items auto-recovered across $WEEK_RUNS runs"
|
||
else
|
||
line "Arr recovery: no failed imports this week ✅"
|
||
fi
|
||
fi
|
||
|
||
# Arr queue depth — current snapshot
|
||
for arr_name in "Sonarr|${HOST1_SONARR_URL}|${HOST1_SONARR_API_KEY}|v3" \
|
||
"Radarr|${HOST1_RADARR_URL}|${HOST1_RADARR_API_KEY}|v3" \
|
||
"Lidarr|${HOST1_LIDARR_URL}|${HOST1_LIDARR_API_KEY}|v1"; do
|
||
IFS='|' read -r name url key ver <<< "$arr_name"
|
||
if [[ "$url" == *"your-"* ]] || [[ -z "$key" ]]; then continue; fi
|
||
QUEUE=$(curl -sf --max-time 5 -H "X-Api-Key: $key" \
|
||
"${url}/api/${ver}/queue?pageSize=1" 2>/dev/null | \
|
||
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('totalRecords',0))" \
|
||
2>/dev/null || echo "?")
|
||
if [[ "$QUEUE" == "0" ]] || [[ -z "$QUEUE" ]]; then
|
||
line "$name queue: empty ✅"
|
||
else
|
||
line "$name queue: $QUEUE items"
|
||
fi
|
||
done
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 🌐 Rsync ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "🌐 RSYNC"
|
||
|
||
if [[ -f "$BANDWIDTH_LOG" ]] && [[ -s "$BANDWIDTH_LOG" ]]; then
|
||
WEEK_TOTAL_BYTES=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$3} END {print sum+0}' "$BANDWIDTH_LOG")
|
||
WEEK_SYNCS=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff' "$BANDWIDTH_LOG" | wc -l)
|
||
WEEK_FAILED=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff && $5 != "success"' "$BANDWIDTH_LOG" | wc -l)
|
||
WEEK_GB=$(awk "BEGIN {printf \"%.1f\", $WEEK_TOTAL_BYTES / 1073741824}")
|
||
|
||
line "Total transferred: ${WEEK_GB}GB across $WEEK_SYNCS syncs"
|
||
if [[ "${WEEK_FAILED:-0}" -gt 0 ]]; then
|
||
issue "Failed syncs this week: $WEEK_FAILED"
|
||
else
|
||
line "Sync failures: none ✅"
|
||
fi
|
||
|
||
# Top 3 shares by transfer this week
|
||
TOP_SHARES=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {bytes[$4]+=$3} END {for(s in bytes) print bytes[s], s}' \
|
||
"$BANDWIDTH_LOG" | sort -rn | head -3)
|
||
if [[ -n "$TOP_SHARES" ]]; then
|
||
while IFS=' ' read -r bytes share; do
|
||
[[ -z "$share" ]] && continue
|
||
SIZE=$(format_bytes "$bytes")
|
||
line " → $share: $SIZE"
|
||
done <<< "$TOP_SHARES"
|
||
fi
|
||
else
|
||
line "No bandwidth data yet"
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 🛡️ Watchdog ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "🛡️ WATCHDOG"
|
||
|
||
get_lock_pid_cr() {
|
||
local script_name="$1"
|
||
local lockfile="$LOCK_DIR/${script_name}.lock"
|
||
if [[ -f "$lockfile" ]]; then
|
||
local content
|
||
content=$(cat "$lockfile" 2>/dev/null)
|
||
echo "${content%%:*}"
|
||
fi
|
||
}
|
||
|
||
get_lock_name_cr() {
|
||
local script_name="$1"
|
||
local lockfile="$LOCK_DIR/${script_name}.lock"
|
||
if [[ -f "$lockfile" ]]; then
|
||
local content
|
||
content=$(cat "$lockfile" 2>/dev/null)
|
||
echo "${content##*:}"
|
||
fi
|
||
}
|
||
|
||
is_loop_running() {
|
||
local script_name="$1"
|
||
local pid name
|
||
pid=$(get_lock_pid_cr "$script_name")
|
||
name=$(get_lock_name_cr "$script_name")
|
||
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && [[ "$name" == "$script_name" ]]
|
||
}
|
||
|
||
get_lock_age_cr() {
|
||
local script_name="$1"
|
||
local lockfile="$LOCK_DIR/${script_name}.lock"
|
||
if [[ -f "$lockfile" ]]; then
|
||
local mtime
|
||
mtime=$(stat -c %Y "$lockfile" 2>/dev/null || echo 0)
|
||
echo $(( $(date +%s) - mtime ))
|
||
else
|
||
echo 0
|
||
fi
|
||
}
|
||
|
||
format_uptime_cr() {
|
||
local seconds=$1
|
||
local days=$(( seconds / 86400 ))
|
||
local hours=$(( (seconds % 86400) / 3600 ))
|
||
local mins=$(( (seconds % 3600) / 60 ))
|
||
if (( days > 0 )); then echo "${days}d ${hours}h ${mins}m"
|
||
elif (( hours > 0 )); then echo "${hours}h ${mins}m"
|
||
else echo "${mins}m"; fi
|
||
}
|
||
|
||
# ── System Watchdog ──
|
||
line "⚙️ System Watchdog"
|
||
|
||
SYS_PID_CR=$(get_lock_pid_cr "system_watchdog")
|
||
if is_loop_running "system_watchdog"; then
|
||
SYS_AGE_CR=$(get_lock_age_cr "system_watchdog")
|
||
SYS_UP_CR=$(format_uptime_cr "$SYS_AGE_CR")
|
||
SYS_CYCLE_CR=$(( SYS_AGE_CR / SYSTEM_WATCHDOG_INTERVAL ))
|
||
line " ✅ Running │ PID: $SYS_PID_CR │ Uptime: $SYS_UP_CR │ ~Cycle: $SYS_CYCLE_CR"
|
||
else
|
||
issue "system_watchdog NOT RUNNING"
|
||
fi
|
||
|
||
# System strikes
|
||
if [[ -f "$SYS_WATCHDOG_STATE_FILE" ]]; then
|
||
SYS_ACTIVE=$(grep -v ":0$" "$SYS_WATCHDOG_STATE_FILE" 2>/dev/null | grep -v "^$")
|
||
if [[ -n "$SYS_ACTIVE" ]]; then
|
||
while IFS=: read -r key count; do
|
||
[[ -z "$key" ]] && continue
|
||
issue " ⚠️ Strike: $key — $count/$SYS_WATCHDOG_STRIKE_LIMIT"
|
||
done <<< "$SYS_ACTIVE"
|
||
else
|
||
line " ✅ Strikes: none"
|
||
fi
|
||
fi
|
||
|
||
# Reboots this week
|
||
if [[ -f "$SYS_WATCHDOG_REBOOT_LOG" ]]; then
|
||
WEEK_EPOCH=$(date -d "$WEEK_START" +%s)
|
||
REBOOT_COUNT=$(awk -v cutoff="$WEEK_EPOCH" '$1 >= cutoff' \
|
||
"$SYS_WATCHDOG_REBOOT_LOG" 2>/dev/null | wc -l)
|
||
if [[ "${REBOOT_COUNT:-0}" -gt 0 ]]; then
|
||
issue " 🔄 Reboots this week: $REBOOT_COUNT (watchdog triggered)"
|
||
else
|
||
line " ✅ Reboots this week: 0"
|
||
fi
|
||
fi
|
||
|
||
# Skip list
|
||
if [[ -f "$SYS_WATCHDOG_FAILED_FILE" ]] && [[ -s "$SYS_WATCHDOG_FAILED_FILE" ]]; then
|
||
SKIP_COUNT=$(wc -l < "$SYS_WATCHDOG_FAILED_FILE")
|
||
SKIP_LIST=$(tr '\n' ' ' < "$SYS_WATCHDOG_FAILED_FILE")
|
||
issue " ⛔ Skip list ($SKIP_COUNT): $SKIP_LIST"
|
||
else
|
||
line " ✅ Skip list: empty"
|
||
fi
|
||
|
||
# System snapshot
|
||
ROOTFS_PCT_CR=$(df / --output=pcent 2>/dev/null | tail -1 | tr -d ' %')
|
||
MEM_AVAIL_CR=$(awk '/MemAvailable/ {printf "%.1f", $2/1048576}' /proc/meminfo)
|
||
MEM_TOTAL_CR=$(awk '/MemTotal/ {printf "%.0f", $2/1048576}' /proc/meminfo)
|
||
LOAD_CR=$(awk '{print $1}' /proc/loadavg)
|
||
ZOMBIE_CR=$(ps aux | awk '{print $8}' | grep -c "^Z$" 2>/dev/null || echo 0)
|
||
ZOMBIE_CR="${ZOMBIE_CR//[^0-9]/}"; ZOMBIE_CR="${ZOMBIE_CR:-0}"
|
||
ARC_GB_CR="n/a"
|
||
if [[ -f /proc/spl/kstat/zfs/arcstats ]]; then
|
||
ARC_B=$(awk '/^size / {print $3}' /proc/spl/kstat/zfs/arcstats)
|
||
ARC_GB_CR=$(awk "BEGIN {printf \"%.1f\", $ARC_B / 1073741824}")
|
||
fi
|
||
line " 📊 rootfs: ${ROOTFS_PCT_CR}% │ RAM: ${MEM_AVAIL_CR}GB free/${MEM_TOTAL_CR}GB │ ARC: ${ARC_GB_CR}GB │ load: ${LOAD_CR} │ zombies: ${ZOMBIE_CR}"
|
||
|
||
REPORT+=("")
|
||
|
||
# ── Docker Watchdog ──
|
||
line "🐳 Docker Watchdog"
|
||
|
||
DOCKER_PID_CR=$(get_lock_pid_cr "docker_watchdog")
|
||
if is_loop_running "docker_watchdog"; then
|
||
DOCKER_AGE_CR=$(get_lock_age_cr "docker_watchdog")
|
||
DOCKER_UP_CR=$(format_uptime_cr "$DOCKER_AGE_CR")
|
||
DOCKER_CYCLE_CR=$(( DOCKER_AGE_CR / DOCKER_WATCHDOG_INTERVAL ))
|
||
line " ✅ Running │ PID: $DOCKER_PID_CR │ Uptime: $DOCKER_UP_CR │ ~Cycle: $DOCKER_CYCLE_CR"
|
||
else
|
||
issue "docker_watchdog NOT RUNNING"
|
||
fi
|
||
|
||
# Container strikes
|
||
if [[ -f "$WATCHDOG_STATE_FILE" ]]; then
|
||
DOCK_ACTIVE=$(grep -v ":0$" "$WATCHDOG_STATE_FILE" 2>/dev/null | grep -v "^$")
|
||
if [[ -n "$DOCK_ACTIVE" ]]; then
|
||
while IFS=: read -r key count; do
|
||
[[ -z "$key" ]] && continue
|
||
issue " ⚠️ Strike: $key — $count"
|
||
done <<< "$DOCK_ACTIVE"
|
||
else
|
||
line " ✅ Container strikes: none"
|
||
fi
|
||
fi
|
||
|
||
# Container restarts this week
|
||
if [[ -f "$WATCHDOG_CONTAINER_RESTART_LOG" ]]; then
|
||
WEEK_RESTARTS=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$2 >= cutoff' "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null | wc -l)
|
||
if [[ "${WEEK_RESTARTS:-0}" -gt 0 ]]; then
|
||
RESTARTED=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$2 >= cutoff {print $1}' "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null | \
|
||
sort | uniq -c | sort -rn | head -5 | \
|
||
awk '{print $2"("$1")"}' | tr '\n' ' ')
|
||
finding " 🔄 Restarts this week: $WEEK_RESTARTS — $RESTARTED"
|
||
else
|
||
line " ✅ Container restarts this week: none"
|
||
fi
|
||
fi
|
||
|
||
# Docker overview
|
||
if command -v docker >/dev/null 2>&1; then
|
||
RUNNING_CR=$(docker ps -q 2>/dev/null | wc -l)
|
||
TOTAL_CR=$(docker ps -aq 2>/dev/null | wc -l)
|
||
UNHEALTHY_CR=$(docker ps --filter health=unhealthy -q 2>/dev/null | wc -l)
|
||
STOPPED_CR=$(docker ps -af "status=exited" --format "{{.Names}}" 2>/dev/null | head -5 | tr '\n' ' ')
|
||
STOPPED_COUNT_CR=$(docker ps -af "status=exited" -q 2>/dev/null | wc -l)
|
||
line " 📦 $RUNNING_CR/$TOTAL_CR running │ unhealthy: $UNHEALTHY_CR"
|
||
if [[ "${STOPPED_COUNT_CR:-0}" -gt 0 ]]; then
|
||
issue " ⚠️ Stopped: $STOPPED_CR"
|
||
else
|
||
line " ✅ All containers running"
|
||
fi
|
||
fi
|
||
|
||
REPORT+=("")
|
||
|
||
# ── Failover ──
|
||
line "🔀 Failover"
|
||
|
||
FAILOVER_PID_CR=$(get_lock_pid_cr "failover")
|
||
if is_loop_running "failover"; then
|
||
FAILOVER_AGE_CR=$(get_lock_age_cr "failover")
|
||
FAILOVER_UP_CR=$(format_uptime_cr "$FAILOVER_AGE_CR")
|
||
line " ✅ Running │ PID: $FAILOVER_PID_CR │ Uptime: $FAILOVER_UP_CR"
|
||
else
|
||
issue "failover NOT RUNNING"
|
||
fi
|
||
|
||
FAILOVER_STATE_CR="UNKNOWN"
|
||
FAILOVER_STATE_SECS_CR=0
|
||
if [[ -f "$FAILOVER_STATE_FILE" ]]; then
|
||
FAILOVER_STATE_CR=$(grep "^state=" "$FAILOVER_STATE_FILE" 2>/dev/null | cut -d= -f2)
|
||
FAILOVER_LAST_EPOCH_CR=$(grep "^last_change_epoch=" "$FAILOVER_STATE_FILE" 2>/dev/null | cut -d= -f2)
|
||
[[ -n "$FAILOVER_LAST_EPOCH_CR" ]] && \
|
||
FAILOVER_STATE_SECS_CR=$(( $(date +%s) - FAILOVER_LAST_EPOCH_CR ))
|
||
fi
|
||
|
||
STATE_DUR_CR=$(format_uptime_cr "${FAILOVER_STATE_SECS_CR:-0}")
|
||
|
||
case "$FAILOVER_STATE_CR" in
|
||
NORMAL)
|
||
line " ✅ State: NORMAL │ Duration: $STATE_DUR_CR"
|
||
;;
|
||
FAILOVER)
|
||
issue " ⚠️ State: FAILOVER — remote down for $STATE_DUR_CR"
|
||
FAILOVER_MINS_CR=$(( FAILOVER_STATE_SECS_CR / 60 ))
|
||
if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then
|
||
T2=$HOST2_TIER2_DELAY; T3=$HOST2_TIER3_DELAY; T4=$HOST2_TIER4_DELAY
|
||
else
|
||
T2=$HOST1_TIER2_DELAY; T3=$HOST1_TIER3_DELAY; T4=$HOST1_TIER4_DELAY
|
||
fi
|
||
(( FAILOVER_MINS_CR >= T2 )) && line " 🔄 Tier 2: ✅ active" || \
|
||
line " 🔄 Tier 2: ⏳ in $(( T2 - FAILOVER_MINS_CR ))min"
|
||
(( FAILOVER_MINS_CR >= T3 )) && line " 🔄 Tier 3: ✅ active" || \
|
||
line " 🔄 Tier 3: ⏳ in $(( T3 - FAILOVER_MINS_CR ))min"
|
||
(( FAILOVER_MINS_CR >= T4 )) && line " 🔄 Tier 4: ✅ active" || \
|
||
line " 🔄 Tier 4: ⏳ in $(( T4 - FAILOVER_MINS_CR ))min"
|
||
;;
|
||
NO_INTERNET)
|
||
issue " ❌ State: NO_INTERNET — DDNS stopped │ Duration: $STATE_DUR_CR"
|
||
;;
|
||
DARK)
|
||
issue " ❌ State: DARK — remote down AND no internet │ Duration: $STATE_DUR_CR"
|
||
;;
|
||
*)
|
||
finding " ❓ State: ${FAILOVER_STATE_CR:-unknown}"
|
||
;;
|
||
esac
|
||
|
||
# Tailscale
|
||
if command -v tailscale >/dev/null 2>&1; then
|
||
REMOTE_IP_CR=$(tailscale ip -4 "$HOST2" 2>/dev/null)
|
||
if [[ -n "$REMOTE_IP_CR" ]]; then
|
||
if ping -c 1 -W 2 "$REMOTE_IP_CR" >/dev/null 2>&1; then
|
||
line " 🌐 $HOST2: $REMOTE_IP_CR — reachable ✅"
|
||
else
|
||
issue " 🌐 $HOST2: $REMOTE_IP_CR — not responding"
|
||
fi
|
||
else
|
||
issue " 🌐 $HOST2 not visible on Tailscale"
|
||
fi
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 🔐 Security ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "🔐 SECURITY"
|
||
|
||
for domain in "${CERT_MONITOR_DOMAINS[@]:-}"; do
|
||
[[ -z "$domain" ]] && continue
|
||
EXPIRY=$(echo | timeout "${CERT_TIMEOUT:-10}" openssl s_client \
|
||
-connect "${domain}:443" -servername "$domain" 2>/dev/null | \
|
||
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||
if [[ -z "$EXPIRY" ]]; then
|
||
issue "$domain: could not check certificate"
|
||
continue
|
||
fi
|
||
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || echo 0)
|
||
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW) / 86400 ))
|
||
if [[ "$DAYS_LEFT" -le "${CERT_CRIT_DAYS:-7}" ]]; then
|
||
issue "$domain: ${DAYS_LEFT} days remaining — CRITICAL, renew now"
|
||
elif [[ "$DAYS_LEFT" -le "${CERT_WARN_DAYS:-30}" ]]; then
|
||
finding "$domain: ${DAYS_LEFT} days remaining — renew soon"
|
||
else
|
||
line "$domain: ${DAYS_LEFT} days remaining ✅"
|
||
fi
|
||
done
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ 📊 Emby ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "📊 EMBY"
|
||
|
||
# Select correct Emby URL and key for this host
|
||
if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then
|
||
EMBY_URL="$HOST1_EMBY_URL"
|
||
EMBY_KEY="$HOST1_EMBY_API_KEY"
|
||
else
|
||
EMBY_URL="$HOST2_EMBY_URL"
|
||
EMBY_KEY="$HOST2_EMBY_API_KEY"
|
||
fi
|
||
|
||
if [[ "$EMBY_KEY" != *"your-"* ]] && [[ -n "$EMBY_KEY" ]]; then
|
||
WEEK_MS=$(( 7 * 24 * 3600 * 10000000 ))
|
||
ACTIVITY=$(curl -sf --max-time 10 \
|
||
-H "X-Emby-Token: $EMBY_KEY" \
|
||
"${EMBY_URL}/Sessions?ControllableByUserId=&api_key=$EMBY_KEY" \
|
||
2>/dev/null)
|
||
|
||
# Use activity log for weekly stats
|
||
ACTIVITY_LOG=$(curl -sf --max-time 10 \
|
||
-H "X-Emby-Token: $EMBY_KEY" \
|
||
"${EMBY_URL}/user_usage_stats/user_activity?days=7&api_key=$EMBY_KEY" \
|
||
2>/dev/null)
|
||
|
||
if [[ -n "$ACTIVITY_LOG" ]]; then
|
||
TOTAL_PLAYS=$(echo "$ACTIVITY_LOG" | \
|
||
python3 -c "import sys,json; d=json.load(sys.stdin); \
|
||
print(sum(u.get('total_plays',0) for u in d))" 2>/dev/null || echo "?")
|
||
line "Streams this week: $TOTAL_PLAYS total plays"
|
||
|
||
# Top 3 users
|
||
TOP_USERS=$(echo "$ACTIVITY_LOG" | \
|
||
python3 -c "
|
||
import sys,json
|
||
d=json.load(sys.stdin)
|
||
users=sorted(d,key=lambda x:x.get('total_plays',0),reverse=True)[:3]
|
||
for u in users:
|
||
print(f\" → {u.get('user_name','?')}: {u.get('total_plays',0)} plays\")
|
||
" 2>/dev/null)
|
||
[[ -n "$TOP_USERS" ]] && echo "$TOP_USERS" | while IFS= read -r l; do line "$l"; done
|
||
else
|
||
# Fallback — just show active sessions
|
||
ACTIVE=$(curl -sf --max-time 5 \
|
||
-H "X-Emby-Token: $EMBY_KEY" \
|
||
"${EMBY_URL}/Sessions?api_key=$EMBY_KEY" 2>/dev/null | \
|
||
python3 -c "import sys,json; \
|
||
d=json.load(sys.stdin); \
|
||
active=[s for s in d if s.get('NowPlayingItem')]; \
|
||
print(f'{len(active)} active streams now')" 2>/dev/null || echo "API unavailable")
|
||
line "Emby: $ACTIVE"
|
||
fi
|
||
else
|
||
line "Emby: API key not configured"
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ ⚙️ System Health ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
section "⚙️ SYSTEM HEALTH"
|
||
|
||
# inotify + php-fpm weekly stats
|
||
if [[ -f "${TUNING_MONITOR_LOG:-}" ]] && [[ -s "$TUNING_MONITOR_LOG" ]]; then
|
||
# inotify weekly stats
|
||
INOTIFY_PEAK=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {if ($3+0 > max) max=$3+0} END {print max+0}' "$TUNING_MONITOR_LOG")
|
||
INOTIFY_AVG=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$3; count++} END {if(count>0) printf "%.0f", sum/count; else print 0}' \
|
||
"$TUNING_MONITOR_LOG")
|
||
INOTIFY_LOW=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'BEGIN{min=99999} $1 >= cutoff {if($3+0 < min) min=$3+0} END {print min+0}' \
|
||
"$TUNING_MONITOR_LOG")
|
||
INOTIFY_LIMIT_NOW=$(sysctl -n fs.inotify.max_user_instances 2>/dev/null || echo 1024)
|
||
INOTIFY_NOW=$(find /proc/*/fd -lname 'anon_inode:inotify' 2>/dev/null | wc -l)
|
||
INOTIFY_NOW="${INOTIFY_NOW//[^0-9]/}"; INOTIFY_NOW="${INOTIFY_NOW:-0}"
|
||
INOTIFY_NOW_PCT=$(( INOTIFY_NOW * 100 / INOTIFY_LIMIT_NOW ))
|
||
INOTIFY_WARN_COUNT=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff && $6 == 1 {count++} END {print count+0}' "$TUNING_MONITOR_LOG")
|
||
|
||
if [[ "${INOTIFY_WARN_COUNT:-0}" -gt 0 ]]; then
|
||
issue "inotify: ${INOTIFY_NOW}/${INOTIFY_LIMIT_NOW} now (${INOTIFY_NOW_PCT}%) | week peak: $INOTIFY_PEAK avg: $INOTIFY_AVG low: $INOTIFY_LOW | ⚠️ warnings: $INOTIFY_WARN_COUNT"
|
||
else
|
||
line "inotify: ${INOTIFY_NOW}/${INOTIFY_LIMIT_NOW} now (${INOTIFY_NOW_PCT}%) | week peak: $INOTIFY_PEAK avg: $INOTIFY_AVG low: $INOTIFY_LOW ✅"
|
||
fi
|
||
|
||
# php-fpm weekly stats
|
||
PHPFPM_PEAK=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {if ($7+0 > max) max=$7+0} END {print max+0}' "$TUNING_MONITOR_LOG")
|
||
PHPFPM_AVG=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff {sum+=$7; count++} END {if(count>0) printf "%.0f", sum/count; else print 0}' \
|
||
"$TUNING_MONITOR_LOG")
|
||
PHPFPM_MAX_NOW="${PHP_MAX_CHILDREN:-250}"
|
||
PHPFPM_NOW=$(ps aux 2>/dev/null | grep -c "php-fpm: pool" || echo 0)
|
||
PHPFPM_NOW="${PHPFPM_NOW//[^0-9]/}"; PHPFPM_NOW="${PHPFPM_NOW:-0}"
|
||
PHPFPM_WARN_COUNT=$(awk -F'|' -v cutoff="$WEEK_START" \
|
||
'$1 >= cutoff && $10 == 1 {count++} END {print count+0}' "$TUNING_MONITOR_LOG")
|
||
|
||
if [[ "${PHPFPM_WARN_COUNT:-0}" -gt 0 ]]; then
|
||
issue "php-fpm: ${PHPFPM_NOW}/${PHPFPM_MAX_NOW} workers now | week peak: $PHPFPM_PEAK avg: $PHPFPM_AVG | ⚠️ warnings: $PHPFPM_WARN_COUNT"
|
||
else
|
||
line "php-fpm: ${PHPFPM_NOW}/${PHPFPM_MAX_NOW} workers now | week peak: $PHPFPM_PEAK avg: $PHPFPM_AVG ✅"
|
||
fi
|
||
else
|
||
# No log yet — just show live values
|
||
INOTIFY_LIMIT_NOW=$(sysctl -n fs.inotify.max_user_instances 2>/dev/null || echo 1024)
|
||
INOTIFY_NOW=$(find /proc/*/fd -lname 'anon_inode:inotify' 2>/dev/null | wc -l)
|
||
INOTIFY_NOW="${INOTIFY_NOW//[^0-9]/}"; INOTIFY_NOW="${INOTIFY_NOW:-0}"
|
||
INOTIFY_NOW_PCT=$(( INOTIFY_NOW * 100 / INOTIFY_LIMIT_NOW ))
|
||
PHPFPM_NOW=$(ps aux 2>/dev/null | grep -c "php-fpm: pool" || echo 0)
|
||
PHPFPM_NOW="${PHPFPM_NOW//[^0-9]/}"; PHPFPM_NOW="${PHPFPM_NOW:-0}"
|
||
line "inotify: ${INOTIFY_NOW}/${INOTIFY_LIMIT_NOW} (${INOTIFY_NOW_PCT}%) — no weekly data yet"
|
||
line "php-fpm: ${PHPFPM_NOW}/${PHP_MAX_CHILDREN:-250} workers — no weekly data yet"
|
||
fi
|
||
|
||
# SMART summary
|
||
if command -v smartctl >/dev/null 2>&1; then
|
||
SMART_ISSUES=0
|
||
for disk in /dev/sd? /dev/nvme?; do
|
||
[[ ! -e "$disk" ]] && continue
|
||
DISK_NAME=$(basename "$disk")
|
||
SKIP=false
|
||
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
|
||
[[ "$DISK_NAME" == "$ignore" ]] && SKIP=true && break
|
||
done
|
||
[[ "$SKIP" == true ]] && continue
|
||
HEALTH=$(smartctl -H "$disk" 2>/dev/null | grep "SMART overall-health" | \
|
||
awk '{print $NF}')
|
||
if [[ "$HEALTH" != "PASSED" ]] && [[ -n "$HEALTH" ]]; then
|
||
issue "SMART $DISK_NAME: $HEALTH — check immediately"
|
||
((SMART_ISSUES++))
|
||
fi
|
||
done
|
||
if [[ "$SMART_ISSUES" -eq 0 ]]; then
|
||
line "SMART: all drives PASSED ✅"
|
||
fi
|
||
fi
|
||
|
||
# Gitea sync status
|
||
if command -v git >/dev/null 2>&1 && [[ -d "$TARGET_DIR/.git" ]]; then
|
||
CURRENT_COMMIT=$(git -C "$TARGET_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||
LAST_PULL=$(git -C "$TARGET_DIR" log -1 --format="%ar" 2>/dev/null || echo "unknown")
|
||
line "Gitea: commit $CURRENT_COMMIT (pulled $LAST_PULL)"
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ ⚠️ Issues Requiring Attention ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
if [[ ${#ISSUES[@]} -gt 0 ]]; then
|
||
REPORT+=("")
|
||
REPORT+=("⚠️ ISSUES REQUIRING ATTENTION")
|
||
REPORT+=("$(printf '%.0s─' {1..50})")
|
||
for issue_line in "${ISSUES[@]}"; do
|
||
REPORT+=(" ❌ $issue_line")
|
||
done
|
||
fi
|
||
|
||
if [[ ${#FINDINGS[@]} -gt 0 ]]; then
|
||
REPORT+=("")
|
||
REPORT+=("ℹ️ NOTABLE")
|
||
REPORT+=("$(printf '%.0s─' {1..50})")
|
||
for finding_line in "${FINDINGS[@]}"; do
|
||
REPORT+=(" → $finding_line")
|
||
done
|
||
fi
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ Footer ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
REPORT+=("")
|
||
if [[ ${#ISSUES[@]} -eq 0 ]]; then
|
||
STATUS="✅ All systems healthy — enjoy your Sunday"
|
||
else
|
||
STATUS="⚠️ ${#ISSUES[@]} issue(s) need attention"
|
||
fi
|
||
REPORT+=("$STATUS")
|
||
REPORT+=("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||
|
||
# -----------------------------------------------------------------------------------------------
|
||
# ━━━ Output and Send ━━━
|
||
# -----------------------------------------------------------------------------------------------
|
||
HEADER="☕ SUNDAY MORNING COFFEE REPORT — $REPORT_DATE"
|
||
DIVIDER="$(printf '%.0s━' {1..50})"
|
||
|
||
echo ""
|
||
echo "$DIVIDER"
|
||
echo "$HEADER"
|
||
echo "$DIVIDER"
|
||
|
||
for report_line in "${REPORT[@]}"; do
|
||
echo "$report_line"
|
||
done
|
||
|
||
echo ""
|
||
|
||
# Send notification
|
||
BODY=$(printf '%s\n' "$HEADER" "${REPORT[@]}")
|
||
|
||
if [[ "$DRY_RUN" == true ]]; then
|
||
warn "DRY RUN — notification not sent"
|
||
else
|
||
notify "$BODY" "☕ Weekly Report" "normal"
|
||
success "Report sent"
|
||
fi |