Cleanup and Stocks Webservices

This commit is contained in:
Rufus King 2026-07-27 20:01:02 -04:00
parent f50008c41d
commit 838a3a4eb7
10 changed files with 307 additions and 1547 deletions

View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN mkdir -p /data
EXPOSE 5005
CMD ["gunicorn", "--bind", "0.0.0.0:5005", "--workers", "2", "--timeout", "30",>

170
Stocks/WebService/app.py Normal file
View file

@ -0,0 +1,170 @@
import os
import time
import sqlite3
import threading
from datetime import datetime, timedelta
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
# ---- Config ----
FINNHUB_API_KEY = os.environ.get("FINNHUB_API_KEY", "")
SHARED_SECRET = os.environ.get("PROXY_SECRET", "")
DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
FINNHUB_MAX_PER_MIN = 40 # stay safely under Finnhub's 60/min cap
_db_lock = threading.Lock()
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS historical_cache (
ticker TEXT NOT NULL,
requested_date TEXT NOT NULL,
resolved_date TEXT NOT NULL,
price REAL NOT NULL,
PRIMARY KEY (ticker, requested_date)
)
""")
return conn
_current_cache = {}
_current_cache_lock = threading.Lock()
CURRENT_TTL_SECONDS = 60
_finnhub_calls = []
_finnhub_lock = threading.Lock()
def finnhub_throttle():
with _finnhub_lock:
now = time.time()
while _finnhub_calls and _finnhub_calls[0] < now - 60:
_finnhub_calls.pop(0)
if len(_finnhub_calls) >= FINNHUB_MAX_PER_MIN:
sleep_for = 60 - (now - _finnhub_calls[0]) + 0.1
time.sleep(max(sleep_for, 0))
_finnhub_calls.append(time.time())
def check_auth():
key = request.args.get("key", "")
return bool(SHARED_SECRET) and key == SHARED_SECRET
@app.route("/current")
def current_price():
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
ticker = request.args.get("ticker", "").upper().strip()
if not ticker:
return jsonify({"error": "missing ticker"}), 400
now = time.time()
with _current_cache_lock:
cached = _current_cache.get(ticker)
if cached and (now - cached[1]) < CURRENT_TTL_SECONDS:
return jsonify({"ticker": ticker, "price": cached[0], "quote_time": cached[2], "cached": True})
finnhub_throttle()
r = requests.get(
"https://finnhub.io/api/v1/quote",
params={"symbol": ticker, "token": FINNHUB_API_KEY},
timeout=10,
)
data = r.json()
if not data or data.get("c") in (None, 0):
return jsonify({"ticker": ticker, "error": "no data"}), 502
price = data["c"]
quote_time = None
if data.get("t"):
quote_time = datetime.utcfromtimestamp(data["t"]).isoformat() + "Z"
with _current_cache_lock:
_current_cache[ticker] = (price, now, quote_time)
return jsonify({"ticker": ticker, "price": price, "quote_time": quote_time, "cached": False})
@app.route("/historical")
def historical_price():
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
ticker = request.args.get("ticker", "").upper().strip()
date_str = request.args.get("date", "").strip()
if not ticker or not date_str:
return jsonify({"error": "missing ticker or date"}), 400
try:
target_date = datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
return jsonify({"error": "date must be YYYY-MM-DD"}), 400
with _db_lock:
conn = get_db()
row = conn.execute(
"SELECT resolved_date, price FROM historical_cache WHERE ticker=? AND requested_date=?",
(ticker, date_str),
).fetchone()
conn.close()
if row:
return jsonify({"ticker": ticker, "requested_date": date_str, "resolved_date": row[0], "price": row[1], "cached": True})
period2 = int((target_date + timedelta(days=1)).timestamp())
period1 = int((target_date - timedelta(days=10)).timestamp())
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}"
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
r = requests.get(url, params={"period1": period1, "period2": period2, "interval": "1d"}, headers=headers, timeout=10)
if r.status_code != 200:
return jsonify({"ticker": ticker, "error": f"yahoo http {r.status_code}"}), 502
data = r.json()
try:
result = data["chart"]["result"][0]
timestamps = result["timestamp"]
closes = result["indicators"]["quote"][0]["close"]
except (KeyError, IndexError, TypeError):
return jsonify({"ticker": ticker, "error": "no historical data"}), 502
best = None
for ts, close in zip(timestamps, closes):
if close is None:
continue
day = datetime.utcfromtimestamp(ts)
if day.date() <= target_date.date():
if best is None or day > best[0]:
best = (day, close)
if best is None:
return jsonify({"ticker": ticker, "error": "no trading day found in window"}), 502
resolved_date = best[0].strftime("%Y-%m-%d")
price = round(best[1], 4)
with _db_lock:
conn = get_db()
conn.execute(
"INSERT OR REPLACE INTO historical_cache (ticker, requested_date, resolved_date, price) VALUES (?, ?, ?, ?)",
(ticker, date_str, resolved_date, price),
)
conn.commit()
conn.close()
return jsonify({"ticker": ticker, "requested_date": date_str, "resolved_date": resolved_date, "price": price, "cached": False})
@app.route("/health")
def health():
return jsonify({"status": "ok"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5005)

View file

@ -0,0 +1,14 @@
services:
stockproxy:
build: .
container_name: stockproxy
restart: unless-stopped
ports:
- "5005:5005"
environment:
- FINNHUB_API_KEY=d9e2k2hr01qh241a54ngd9e2k2hr01qh241a54o0
- PROXY_SECRET=90c2528e9b5221c110f7c2c9cd6c65dc
- DB_PATH=/data/cache.db
volumes:
- /opt/stockproxy/data:/data

View file

@ -0,0 +1,3 @@
flask
requests
gunicorn

View file

@ -1,371 +0,0 @@
#!/bin/bash
# =============================================================
# nextcloud_update_check.sh
# Checks for Nextcloud CORE and app updates daily.
# Sends an HTML email report with update commands ready to
# copy-paste into terminal.
#
# Schedule: Daily (recommended 7:00 AM)
# Install: /usr/scripts/omv/nextcloud_update_check.sh
# Log: /var/log/nextcloud-updates/
#
# 2026-07-24 fix: previously only checked `occ app:update --all
# --showonly`, which reports APP updates only. It never checked
# for a Nextcloud CORE/server version update, so a core release
# (e.g. Nextcloud 34.0.2) was silently missed and the report
# said "up to date" even when it wasn't. Added a separate core
# check via `occ update:check`.
# =============================================================
# --- Config --------------------------------------------------
CONTAINER="nextcloud"
OCC="sudo docker exec -u www-data ${CONTAINER} php occ"
# OMV Compose project location — NOT ~/docker/nextcloud. Confirmed via:
# docker inspect nextcloud --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
# Directory is root-only (drwx------ root root), so compose commands
# must run under sudo. OMV names its compose files "<project>.yml" +
# "compose.override.yml" — NOT the default "docker-compose.yml" — so
# both -f flags are required or `docker compose` won't find them.
NEXTCLOUD_COMPOSE_DIR="/kingdezignsnas/Docker/Compose/nextcloud"
NEXTCLOUD_COMPOSE_FILES="-f nextcloud.yml -f compose.override.yml"
SMTP_SERVER="smtppro.zoho.com"
SMTP_PORT="465"
SMTP_USER="rufus.king@kingdezigns.com"
SMTP_PASS_FILE="/etc/nextcloud-smtp-pass"
FROM_NAME="NAS08"
FROM_EMAIL="rufus.king@kingdezigns.com"
TO_EMAIL="rufus.king@kingdezigns.com"
HOSTNAME_LABEL="NAS08"
LOG_DIR="/var/log/nextcloud-updates"
LOG_RETENTION_DAYS=90
# -------------------------------------------------------------
mkdir -p "$LOG_DIR"
DATESTAMP=$(date +"%Y%m%d")
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
LOGFILE="$LOG_DIR/update-check-${DATESTAMP}.log"
log() { echo "[$(date +"%H:%M:%S")] $*" | tee -a "$LOGFILE"; }
# --- Load SMTP password --------------------------------------
if [[ ! -f "$SMTP_PASS_FILE" ]]; then
log "ERROR: SMTP password file not found at $SMTP_PASS_FILE"
exit 1
fi
SMTP_PASS=$(cat "$SMTP_PASS_FILE")
# --- Check container is running ------------------------------
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
log "ERROR: Container '${CONTAINER}' is not running."
# Send critical alert
STATUS_COLOR="#b91c1c"
STATUS_LABEL="CRITICAL"
STATUS_MSG="⛔ &nbsp;Nextcloud container is not running"
BODY_HTML="<p style='color:#b91c1c;font-weight:600;'>The Nextcloud Docker container was not found running on ${HOSTNAME_LABEL}. No update check could be performed.</p>"
NEEDS_ACTION=true
APP_ROWS=""
CMD_BLOCK=""
else
# --- Get Nextcloud status ----------------------------------
log "Checking Nextcloud status..."
NC_STATUS=$($OCC status 2>/dev/null | grep -v '^{')
MAINTENANCE=$(echo "$NC_STATUS" | grep "maintenance:" | awk '{print $3}')
NEEDS_UPGRADE=$(echo "$NC_STATUS" | grep "needsDbUpgrade:" | awk '{print $3}')
NC_VERSION=$(echo "$NC_STATUS" | grep "versionstring:" | awk '{print $3}')
log "Nextcloud version: $NC_VERSION | maintenance: $MAINTENANCE | needsDbUpgrade: $NEEDS_UPGRADE"
# --- Check for CORE (server) update -------------------------
# occ app:update only reports app updates, never core/server
# releases. occ update:check is the command that actually
# checks the updater channel for a new Nextcloud version.
log "Checking core (server) update..."
CORE_CHECK_RAW=$($OCC update:check 2>/dev/null | grep -v '^{')
CORE_UPDATE_AVAILABLE=false
CORE_NEW_VERSION=""
if echo "$CORE_CHECK_RAW" | grep -qi "is available"; then
CORE_UPDATE_AVAILABLE=true
CORE_NEW_VERSION=$(echo "$CORE_CHECK_RAW" | grep -oE 'Nextcloud [0-9]+\.[0-9]+\.[0-9]+' | head -1 | awk '{print $2}')
log "Core update available: Nextcloud ${CORE_NEW_VERSION}"
else
log "Core is up to date."
fi
# --- Check for app updates --------------------------------
log "Checking app updates..."
# Filter out admin_audit JSON log lines that appear when admin_audit app is enabled
APP_UPDATE_RAW=$($OCC app:update --all --showonly 2>/dev/null | grep -v '^{' )
# Parse apps with available updates
# Format from occ app:update --showonly: "appname new version available: X.Y.Z"
UPDATABLE_APPS=()
while IFS= read -r line; do
if echo "$line" | grep -q "new version available"; then
APP_NAME=$(echo "$line" | awk '{print $1}')
NEW_VER=$(echo "$line" | awk '{print $NF}')
UPDATABLE_APPS+=("${APP_NAME}|||${NEW_VER}")
log "Update available: $APP_NAME → $NEW_VER"
fi
done <<< "$APP_UPDATE_RAW"
APP_COUNT=${#UPDATABLE_APPS[@]}
# --- Build status -----------------------------------------
NEEDS_ACTION=false
WARNINGS=()
if [[ "$MAINTENANCE" == "true" ]]; then
WARNINGS+=("Nextcloud is currently in maintenance mode")
NEEDS_ACTION=true
fi
if [[ "$NEEDS_UPGRADE" == "true" ]]; then
WARNINGS+=("Database upgrade required (needsDbUpgrade: true)")
NEEDS_ACTION=true
fi
if [[ $APP_COUNT -gt 0 ]]; then
NEEDS_ACTION=true
fi
if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then
NEEDS_ACTION=true
fi
# --- Determine overall status color -----------------------
if [[ "$MAINTENANCE" == "true" ]] || [[ "$NEEDS_UPGRADE" == "true" ]]; then
STATUS_COLOR="#b91c1c"
STATUS_LABEL="CRITICAL"
STATUS_MSG="⛔ &nbsp;Nextcloud requires immediate attention"
elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]] && [[ $APP_COUNT -gt 0 ]]; then
STATUS_COLOR="#b45309"
STATUS_LABEL="UPDATES AVAILABLE"
STATUS_MSG="🔄 &nbsp;Core update to ${CORE_NEW_VERSION} + ${APP_COUNT} app update(s) available"
elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then
STATUS_COLOR="#b45309"
STATUS_LABEL="UPDATES AVAILABLE"
STATUS_MSG="🔄 &nbsp;Nextcloud core update available (${NC_VERSION} → ${CORE_NEW_VERSION})"
elif [[ $APP_COUNT -gt 0 ]]; then
STATUS_COLOR="#b45309"
STATUS_LABEL="UPDATES AVAILABLE"
STATUS_MSG="🔄 &nbsp;${APP_COUNT} app update(s) available"
else
STATUS_COLOR="#1a7f4b"
STATUS_LABEL="UP TO DATE"
STATUS_MSG="✅ &nbsp;Nextcloud is fully up to date"
fi
# --- Maintenance mode color (was previously unset/unused) --
if [[ "$MAINTENANCE" == "true" ]]; then
MAINTENANCE_COLOR="#b91c1c"
else
MAINTENANCE_COLOR="#1a7f4b"
fi
# --- Build app update rows --------------------------------
APP_ROWS=""
if [[ $APP_COUNT -gt 0 ]]; then
for entry in "${UPDATABLE_APPS[@]}"; do
APP_NAME=$(echo "$entry" | cut -d'|' -f1)
NEW_VER=$(echo "$entry" | cut -d'|' -f4)
APP_ROWS+="<tr>
<td style='padding:9px 14px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;font-family:monospace;'>${APP_NAME}</td>
<td style='padding:9px 14px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#1a7f4b;font-weight:600;'>${NEW_VER}</td>
</tr>"
done
fi
# --- Build warning rows -----------------------------------
WARN_HTML=""
for w in "${WARNINGS[@]}"; do
WARN_HTML+="<div style='margin-top:10px;padding:10px 14px;background:#fff1f2;border-left:4px solid #b91c1c;border-radius:4px;'>
<span style='font-size:13px;color:#7f1d1d;font-weight:600;'>⚠ ${w}</span>
</div>"
done
# --- Build copy-paste command block -----------------------
# NOTE: must use $'...' (ANSI-C quoting) below, not "...". Plain
# double quotes do NOT interpret \n as a newline in bash — they
# insert the two literal characters \ and n, which is why the
# previous version of this script emailed out commands with
# literal "\n" text instead of real line breaks.
CMD_LINES=""
if [[ "$MAINTENANCE" == "true" ]]; then
CMD_LINES+=$'# Disable maintenance mode\nsudo docker exec -u www-data nextcloud php occ maintenance:mode --off\n\n'
fi
if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then
# The nextcloud image's own entrypoint auto-detects the version
# bump and runs the upgrade itself on container start — it does
# NOT need `occ upgrade` run manually. Running `occ upgrade`
# immediately after `up -d` can race the container's own startup
# and falsely report "No upgrade required" if checked too early.
# The 30s sleep gives the entrypoint's internal upgrade time to
# finish before the confirming `occ status` call.
CMD_LINES+="# Update Nextcloud CORE to ${CORE_NEW_VERSION} (do this before app updates)"$'\n'
CMD_LINES+="sudo bash -c \"cd ${NEXTCLOUD_COMPOSE_DIR} && docker compose ${NEXTCLOUD_COMPOSE_FILES} pull && docker compose ${NEXTCLOUD_COMPOSE_FILES} up -d\""$'\n\n'
CMD_LINES+="# Give the container's built-in upgrade entrypoint time to finish,"$'\n'
CMD_LINES+="# then confirm the new version is active (watch for versionstring: ${CORE_NEW_VERSION})"$'\n'
CMD_LINES+="sleep 30"$'\n'
CMD_LINES+="sudo docker exec -u www-data nextcloud php occ status"$'\n\n'
fi
if [[ "$NEEDS_UPGRADE" == "true" ]]; then
CMD_LINES+=$'# Run database upgrade\nsudo docker exec -u www-data nextcloud php occ upgrade --no-interaction\n\n'
fi
if [[ $APP_COUNT -gt 0 ]]; then
CMD_LINES+=$'# Update all apps\nsudo docker exec -u www-data nextcloud php occ app:update --all\n\n'
fi
if [[ -z "$CMD_LINES" ]]; then
CMD_LINES="# No action required — everything is up to date"$'\n'
fi
CMD_LINES+=$'# Verify status after updates\nsudo docker exec -u www-data nextcloud php occ status'
CMD_BLOCK="<div style='margin-top:16px;'>
<div style='font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;'>📋 Commands to run on NAS08</div>
<pre style='margin:0;background:#1e293b;color:#e2e8f0;padding:14px;border-radius:6px;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;'>${CMD_LINES}</pre>
</div>"
# --- Maintenance/upgrade block ----------------------------
MAINT_BLOCK=""
if [[ -n "$WARN_HTML" ]]; then
MAINT_BLOCK="<div style='margin-top:20px;'>
<div style='font-size:11px;font-weight:700;color:#b91c1c;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;'>⚠ Issues Detected</div>
${WARN_HTML}
</div>"
fi
# --- Core update block --------------------------------------
CORE_BLOCK=""
if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then
CORE_BLOCK="<div style='margin-top:20px;background:white;border-radius:10px;border:1px solid #e5e7eb;padding:18px 22px;'>
<div style='font-size:13px;font-weight:700;color:#374151;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px;'>🆙 Core Update Available</div>
<table width='100%' cellspacing='0' cellpadding='0'>
<tr>
<td style='font-size:13px;color:#6b7280;padding-bottom:6px;'>Current Version</td>
<td style='font-size:13px;color:#111827;font-weight:600;padding-bottom:6px;font-family:monospace;'>${NC_VERSION}</td>
</tr>
<tr>
<td style='font-size:13px;color:#6b7280;'>New Version</td>
<td style='font-size:13px;color:#b45309;font-weight:700;font-family:monospace;'>${CORE_NEW_VERSION}</td>
</tr>
</table>
</div>"
fi
# --- App update table -------------------------------------
APP_TABLE=""
if [[ $APP_COUNT -gt 0 ]]; then
APP_TABLE="<div style='margin-top:20px;background:white;border-radius:10px;border:1px solid #e5e7eb;padding:18px 22px;'>
<div style='font-size:13px;font-weight:700;color:#374151;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px;'>🔄 App Updates Available (${APP_COUNT})</div>
<table width='100%' cellspacing='0' cellpadding='0' style='border-collapse:collapse;font-size:13px;'>
<thead>
<tr>
<th style='text-align:left;padding:8px 14px;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-weight:500;'>App</th>
<th style='text-align:left;padding:8px 14px;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-weight:500;'>New Version</th>
</tr>
</thead>
<tbody>${APP_ROWS}</tbody>
</table>
</div>"
else
APP_TABLE="<div style='margin-top:20px;padding:12px 14px;background:#f0fdf4;border-radius:6px;border:1px solid #bbf7d0;'>
<span style='font-size:13px;color:#166534;font-weight:600;'>✓ All apps are up to date</span>
</div>"
fi
BODY_HTML="${MAINT_BLOCK}
<!-- NC Version info -->
<div style='margin-top:20px;padding:12px 14px;background:#f8fafc;border-radius:6px;border:1px solid #e5e7eb;'>
<span style='font-size:12px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.5px;'>Nextcloud Version</span>
<span style='margin-left:10px;font-size:13px;color:#374151;font-family:monospace;'>${NC_VERSION}</span>
<span style='margin-left:20px;font-size:12px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.5px;'>Maintenance Mode</span>
<span style='margin-left:10px;font-size:13px;color:${MAINTENANCE_COLOR};font-family:monospace;'>${MAINTENANCE:-false}</span>
</div>
${CORE_BLOCK}
${APP_TABLE}
${CMD_BLOCK}"
fi
# --- Determine email subject ---------------------------------
if [[ "$NEEDS_ACTION" == "true" ]]; then
SUBJECT="[NAS08] Nextcloud — ${STATUS_LABEL} — Action Required"
else
SUBJECT="[NAS08] Nextcloud — All Up To Date"
fi
log "Composing email: $SUBJECT"
# --- Build full HTML email -----------------------------------
HTML_EMAIL=$(cat <<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellspacing="0" cellpadding="0" style="background:#f1f5f9;padding:24px 16px;">
<tr><td align="center">
<table width="100%" cellspacing="0" cellpadding="0" style="max-width:720px;">
<!-- HEADER -->
<tr><td style="background:#0f172a;border-radius:10px 10px 0 0;padding:28px 32px;">
<table width="100%" cellspacing="0" cellpadding="0"><tr>
<td>
<div style="font-size:11px;color:#94a3b8;letter-spacing:1px;text-transform:uppercase;">Nextcloud · Daily Update Report</div>
<div style="font-size:26px;font-weight:800;color:white;margin-top:4px;">☁ ${HOSTNAME_LABEL}</div>
<div style="font-size:13px;color:#94a3b8;margin-top:4px;">${TIMESTAMP} | cloud.kingdezigns.com</div>
</td>
<td align="right">
<div style="background:${STATUS_COLOR};color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;">${STATUS_LABEL}</div>
</td>
</tr></table>
</td></tr>
<!-- STATUS BANNER -->
<tr><td style="background:${STATUS_COLOR};padding:12px 32px;">
<span style="color:white;font-size:14px;font-weight:600;">${STATUS_MSG}</span>
</td></tr>
<!-- BODY -->
<tr><td style="background:white;border-radius:0 0 10px 10px;padding:28px 32px;">
${BODY_HTML}
</td></tr>
<!-- FOOTER -->
<tr><td style="background:#0f172a;border-radius:0 0 10px 10px;padding:16px 32px;text-align:center;margin-top:8px;">
<span style="font-size:11px;color:#64748b;">Nextcloud Update Check &nbsp;|&nbsp; ${HOSTNAME_LABEL} &nbsp;|&nbsp; KingDezigns Infrastructure</span>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
HTML
)
# --- Send email via curl/SMTP --------------------------------
log "Sending email to ${TO_EMAIL}..."
SEND_RESULT=$(curl --silent --show-error \
--url "smtps://${SMTP_SERVER}:${SMTP_PORT}" \
--ssl-reqd \
--mail-from "${FROM_EMAIL}" \
--mail-rcpt "${TO_EMAIL}" \
--user "${SMTP_USER}:${SMTP_PASS}" \
--upload-file - <<EOF 2>&1
From: ${FROM_NAME} <${FROM_EMAIL}>
To: ${TO_EMAIL}
Subject: ${SUBJECT}
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
${HTML_EMAIL}
EOF
)
if [[ $? -eq 0 ]]; then
log "Email sent successfully."
else
log "ERROR sending email: ${SEND_RESULT}"
fi
# --- Rotate old logs -----------------------------------------
find "$LOG_DIR" -name "update-check-*.log" -mtime +${LOG_RETENTION_DAYS} -delete
log "Done."

View file

@ -20,6 +20,14 @@
# --- Config --------------------------------------------------
CONTAINER="nextcloud"
OCC="sudo docker exec -u www-data ${CONTAINER} php occ"
# OMV Compose project location — NOT ~/docker/nextcloud. Confirmed via:
# docker inspect nextcloud --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'
# Directory is root-only (drwx------ root root), so compose commands
# must run under sudo. OMV names its compose files "<project>.yml" +
# "compose.override.yml" — NOT the default "docker-compose.yml" — so
# both -f flags are required or `docker compose` won't find them.
NEXTCLOUD_COMPOSE_DIR="/kingdezignsnas/Docker/Compose/nextcloud"
NEXTCLOUD_COMPOSE_FILES="-f nextcloud.yml -f compose.override.yml"
SMTP_SERVER="smtppro.zoho.com"
SMTP_PORT="465"
SMTP_USER="rufus.king@kingdezigns.com"
@ -56,7 +64,6 @@ if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
BODY_HTML="<p style='color:#b91c1c;font-weight:600;'>The Nextcloud Docker container was not found running on ${HOSTNAME_LABEL}. No update check could be performed.</p>"
NEEDS_ACTION=true
APP_ROWS=""
CORE_ROW=""
CMD_BLOCK=""
else
# --- Get Nextcloud status ----------------------------------
@ -184,7 +191,19 @@ else
CMD_LINES+=$'# Disable maintenance mode\nsudo docker exec -u www-data nextcloud php occ maintenance:mode --off\n\n'
fi
if [[ "$CORE_UPDATE_AVAILABLE" == "true" ]]; then
CMD_LINES+="# Update Nextcloud CORE to ${CORE_NEW_VERSION} (do this before app updates)"$'\n'"cd ~/docker/nextcloud"$'\n'"docker compose pull"$'\n'"docker compose up -d"$'\n'"sudo docker exec -u www-data nextcloud php occ upgrade --no-interaction"$'\n\n'
# The nextcloud image's own entrypoint auto-detects the version
# bump and runs the upgrade itself on container start — it does
# NOT need `occ upgrade` run manually. Running `occ upgrade`
# immediately after `up -d` can race the container's own startup
# and falsely report "No upgrade required" if checked too early.
# The 30s sleep gives the entrypoint's internal upgrade time to
# finish before the confirming `occ status` call.
CMD_LINES+="# Update Nextcloud CORE to ${CORE_NEW_VERSION} (do this before app updates)"$'\n'
CMD_LINES+="sudo bash -c \"cd ${NEXTCLOUD_COMPOSE_DIR} && docker compose ${NEXTCLOUD_COMPOSE_FILES} pull && docker compose ${NEXTCLOUD_COMPOSE_FILES} up -d\""$'\n\n'
CMD_LINES+="# Give the container's built-in upgrade entrypoint time to finish,"$'\n'
CMD_LINES+="# then confirm the new version is active (watch for versionstring: ${CORE_NEW_VERSION})"$'\n'
CMD_LINES+="sleep 30"$'\n'
CMD_LINES+="sudo docker exec -u www-data nextcloud php occ status"$'\n\n'
fi
if [[ "$NEEDS_UPGRADE" == "true" ]]; then
CMD_LINES+=$'# Run database upgrade\nsudo docker exec -u www-data nextcloud php occ upgrade --no-interaction\n\n'

View file

@ -1,288 +0,0 @@
#!/bin/bash
# =============================================================
# nextcloud_update_check.sh
# Checks for Nextcloud core and app updates daily.
# Sends an HTML email report with update commands ready to
# copy-paste into terminal.
#
# Schedule: Daily (recommended 7:00 AM)
# Install: /usr/scripts/omv/nextcloud_update_check.sh
# Log: /var/log/nextcloud-updates/
# =============================================================
# --- Config --------------------------------------------------
CONTAINER="nextcloud"
OCC="sudo docker exec -u www-data ${CONTAINER} php occ"
SMTP_SERVER="smtppro.zoho.com"
SMTP_PORT="465"
SMTP_USER="rufus.king@kingdezigns.com"
SMTP_PASS_FILE="/etc/nextcloud-smtp-pass"
FROM_NAME="NAS08"
FROM_EMAIL="rufus.king@kingdezigns.com"
TO_EMAIL="rufus.king@kingdezigns.com"
HOSTNAME_LABEL="NAS08"
LOG_DIR="/var/log/nextcloud-updates"
LOG_RETENTION_DAYS=90
# -------------------------------------------------------------
mkdir -p "$LOG_DIR"
DATESTAMP=$(date +"%Y%m%d")
TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S")
LOGFILE="$LOG_DIR/update-check-${DATESTAMP}.log"
log() { echo "[$(date +"%H:%M:%S")] $*" | tee -a "$LOGFILE"; }
# --- Load SMTP password --------------------------------------
if [[ ! -f "$SMTP_PASS_FILE" ]]; then
log "ERROR: SMTP password file not found at $SMTP_PASS_FILE"
exit 1
fi
SMTP_PASS=$(cat "$SMTP_PASS_FILE")
# --- Check container is running ------------------------------
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
log "ERROR: Container '${CONTAINER}' is not running."
# Send critical alert
STATUS_COLOR="#b91c1c"
STATUS_LABEL="CRITICAL"
STATUS_MSG="⛔ &nbsp;Nextcloud container is not running"
BODY_HTML="<p style='color:#b91c1c;font-weight:600;'>The Nextcloud Docker container was not found running on ${HOSTNAME_LABEL}. No update check could be performed.</p>"
NEEDS_ACTION=true
APP_ROWS=""
CORE_ROW=""
CMD_BLOCK=""
else
# --- Get Nextcloud status ----------------------------------
log "Checking Nextcloud status..."
NC_STATUS=$($OCC status 2>/dev/null)
MAINTENANCE=$(echo "$NC_STATUS" | grep "maintenance:" | awk '{print $3}')
NEEDS_UPGRADE=$(echo "$NC_STATUS" | grep "needsDbUpgrade:" | awk '{print $3}')
NC_VERSION=$(echo "$NC_STATUS" | grep "versionstring:" | awk '{print $3}')
log "Nextcloud version: $NC_VERSION | maintenance: $MAINTENANCE | needsDbUpgrade: $NEEDS_UPGRADE"
# --- Check for app updates --------------------------------
log "Checking app updates..."
APP_UPDATE_RAW=$($OCC app:update --all --dry-run 2>/dev/null)
# Fall back if --dry-run not supported (older NC versions)
if echo "$APP_UPDATE_RAW" | grep -q "Unknown option"; then
log "dry-run not supported, using app:list to detect updates..."
APP_UPDATE_RAW=$($OCC app:list 2>/dev/null | grep -E "\(installed")
fi
# Parse apps with available updates
# Format from occ app:update --dry-run: "appname new version available: X.Y.Z"
UPDATABLE_APPS=()
while IFS= read -r line; do
if echo "$line" | grep -q "new version available"; then
APP_NAME=$(echo "$line" | awk '{print $1}')
NEW_VER=$(echo "$line" | awk '{print $NF}')
UPDATABLE_APPS+=("${APP_NAME}|||${NEW_VER}")
log "Update available: $APP_NAME → $NEW_VER"
fi
done <<< "$APP_UPDATE_RAW"
APP_COUNT=${#UPDATABLE_APPS[@]}
# --- Build status -----------------------------------------
NEEDS_ACTION=false
WARNINGS=()
if [[ "$MAINTENANCE" == "true" ]]; then
WARNINGS+=("Nextcloud is currently in maintenance mode")
NEEDS_ACTION=true
fi
if [[ "$NEEDS_UPGRADE" == "true" ]]; then
WARNINGS+=("Database upgrade required (needsDbUpgrade: true)")
NEEDS_ACTION=true
fi
if [[ $APP_COUNT -gt 0 ]]; then
NEEDS_ACTION=true
fi
# --- Determine overall status color -----------------------
if [[ "$MAINTENANCE" == "true" ]] || [[ "$NEEDS_UPGRADE" == "true" ]]; then
STATUS_COLOR="#b91c1c"
STATUS_LABEL="CRITICAL"
STATUS_MSG="⛔ &nbsp;Nextcloud requires immediate attention"
elif [[ $APP_COUNT -gt 0 ]]; then
STATUS_COLOR="#b45309"
STATUS_LABEL="UPDATES AVAILABLE"
STATUS_MSG="🔄 &nbsp;${APP_COUNT} app update(s) available"
else
STATUS_COLOR="#1a7f4b"
STATUS_LABEL="UP TO DATE"
STATUS_MSG="✅ &nbsp;Nextcloud is fully up to date"
fi
# --- Build app update rows --------------------------------
APP_ROWS=""
if [[ $APP_COUNT -gt 0 ]]; then
for entry in "${UPDATABLE_APPS[@]}"; do
APP_NAME=$(echo "$entry" | cut -d'|' -f1)
NEW_VER=$(echo "$entry" | cut -d'|' -f4)
APP_ROWS+="<tr>
<td style='padding:9px 14px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#111827;font-family:monospace;'>${APP_NAME}</td>
<td style='padding:9px 14px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#1a7f4b;font-weight:600;'>${NEW_VER}</td>
</tr>"
done
fi
# --- Build warning rows -----------------------------------
WARN_HTML=""
for w in "${WARNINGS[@]}"; do
WARN_HTML+="<div style='margin-top:10px;padding:10px 14px;background:#fff1f2;border-left:4px solid #b91c1c;border-radius:4px;'>
<span style='font-size:13px;color:#7f1d1d;font-weight:600;'>⚠ ${w}</span>
</div>"
done
# --- Build copy-paste command block -----------------------
CMD_LINES=""
if [[ "$MAINTENANCE" == "true" ]]; then
CMD_LINES+="# Disable maintenance mode\nsudo docker exec -u www-data nextcloud php occ maintenance:mode --off\n\n"
fi
if [[ "$NEEDS_UPGRADE" == "true" ]]; then
CMD_LINES+="# Run database upgrade\nsudo docker exec -u www-data nextcloud php occ upgrade --no-interaction\n\n"
fi
if [[ $APP_COUNT -gt 0 ]]; then
CMD_LINES+="# Update all apps\nsudo docker exec -u www-data nextcloud php occ app:update --all\n\n"
fi
if [[ -z "$CMD_LINES" ]]; then
CMD_LINES="# No action required — everything is up to date"
fi
CMD_LINES+="# Verify status after updates\nsudo docker exec -u www-data nextcloud php occ status"
CMD_BLOCK="<div style='margin-top:16px;'>
<div style='font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;'>📋 Commands to run on NAS08</div>
<pre style='margin:0;background:#1e293b;color:#e2e8f0;padding:14px;border-radius:6px;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;'>${CMD_LINES}</pre>
</div>"
# --- Maintenance/upgrade block ----------------------------
MAINT_BLOCK=""
if [[ -n "$WARN_HTML" ]]; then
MAINT_BLOCK="<div style='margin-top:20px;'>
<div style='font-size:11px;font-weight:700;color:#b91c1c;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;'>⚠ Issues Detected</div>
${WARN_HTML}
</div>"
fi
# --- App update table -------------------------------------
APP_TABLE=""
if [[ $APP_COUNT -gt 0 ]]; then
APP_TABLE="<div style='margin-top:20px;background:white;border-radius:10px;border:1px solid #e5e7eb;padding:18px 22px;'>
<div style='font-size:13px;font-weight:700;color:#374151;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px;'>🔄 App Updates Available (${APP_COUNT})</div>
<table width='100%' cellspacing='0' cellpadding='0' style='border-collapse:collapse;font-size:13px;'>
<thead>
<tr>
<th style='text-align:left;padding:8px 14px;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-weight:500;'>App</th>
<th style='text-align:left;padding:8px 14px;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#9ca3af;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-weight:500;'>New Version</th>
</tr>
</thead>
<tbody>${APP_ROWS}</tbody>
</table>
</div>"
else
APP_TABLE="<div style='margin-top:20px;padding:12px 14px;background:#f0fdf4;border-radius:6px;border:1px solid #bbf7d0;'>
<span style='font-size:13px;color:#166534;font-weight:600;'>✓ All apps are up to date</span>
</div>"
fi
BODY_HTML="${MAINT_BLOCK}
<!-- NC Version info -->
<div style='margin-top:20px;padding:12px 14px;background:#f8fafc;border-radius:6px;border:1px solid #e5e7eb;'>
<span style='font-size:12px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.5px;'>Nextcloud Version</span>
<span style='margin-left:10px;font-size:13px;color:#374151;font-family:monospace;'>${NC_VERSION}</span>
<span style='margin-left:20px;font-size:12px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.5px;'>Maintenance Mode</span>
<span style='margin-left:10px;font-size:13px;color:${MAINTENANCE_COLOR:-#374151};font-family:monospace;'>${MAINTENANCE:-false}</span>
</div>
${APP_TABLE}
${CMD_BLOCK}"
fi
# --- Determine email subject ---------------------------------
if [[ "$NEEDS_ACTION" == "true" ]]; then
SUBJECT="[NAS08] Nextcloud — ${STATUS_LABEL} — Action Required"
else
SUBJECT="[NAS08] Nextcloud — All Up To Date"
fi
log "Composing email: $SUBJECT"
# --- Build full HTML email -----------------------------------
HTML_EMAIL=$(cat <<HTML
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellspacing="0" cellpadding="0" style="background:#f1f5f9;padding:24px 16px;">
<tr><td align="center">
<table width="100%" cellspacing="0" cellpadding="0" style="max-width:720px;">
<!-- HEADER -->
<tr><td style="background:#0f172a;border-radius:10px 10px 0 0;padding:28px 32px;">
<table width="100%" cellspacing="0" cellpadding="0"><tr>
<td>
<div style="font-size:11px;color:#94a3b8;letter-spacing:1px;text-transform:uppercase;">Nextcloud · Daily Update Report</div>
<div style="font-size:26px;font-weight:800;color:white;margin-top:4px;">☁ ${HOSTNAME_LABEL}</div>
<div style="font-size:13px;color:#94a3b8;margin-top:4px;">${TIMESTAMP} | cloud.kingdezigns.com</div>
</td>
<td align="right">
<div style="background:${STATUS_COLOR};color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;">${STATUS_LABEL}</div>
</td>
</tr></table>
</td></tr>
<!-- STATUS BANNER -->
<tr><td style="background:${STATUS_COLOR};padding:12px 32px;">
<span style="color:white;font-size:14px;font-weight:600;">${STATUS_MSG}</span>
</td></tr>
<!-- BODY -->
<tr><td style="background:white;border-radius:0 0 10px 10px;padding:28px 32px;">
${BODY_HTML}
</td></tr>
<!-- FOOTER -->
<tr><td style="background:#0f172a;border-radius:0 0 10px 10px;padding:16px 32px;text-align:center;margin-top:8px;">
<span style="font-size:11px;color:#64748b;">Nextcloud Update Check &nbsp;|&nbsp; ${HOSTNAME_LABEL} &nbsp;|&nbsp; KingDezigns Infrastructure</span>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
HTML
)
# --- Send email via curl/SMTP --------------------------------
log "Sending email to ${TO_EMAIL}..."
SEND_RESULT=$(curl --silent --show-error \
--url "smtps://${SMTP_SERVER}:${SMTP_PORT}" \
--ssl-reqd \
--mail-from "${FROM_EMAIL}" \
--mail-rcpt "${TO_EMAIL}" \
--user "${SMTP_USER}:${SMTP_PASS}" \
--upload-file - <<EOF 2>&1
From: ${FROM_NAME} <${FROM_EMAIL}>
To: ${TO_EMAIL}
Subject: ${SUBJECT}
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
${HTML_EMAIL}
EOF
)
if [[ $? -eq 0 ]]; then
log "Email sent successfully."
else
log "ERROR sending email: ${SEND_RESULT}"
fi
# --- Rotate old logs -----------------------------------------
find "$LOG_DIR" -name "update-check-*.log" -mtime +${LOG_RETENTION_DAYS} -delete
log "Done."

View file

@ -1,506 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KingDezigns — NAS08 ZFS Pool Maintenance Scripts</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f7f6f2;
--surface: #ffffff;
--surface-alt: #f1f0eb;
--border: #e2e0d8;
--border-strong: #c8c5ba;
--text-primary: #1a1917;
--text-secondary: #6b6960;
--text-muted: #9b9890;
--green-bg: #eaf3de;
--green-border: #97c459;
--green-text: #2d5a0e;
--red-bg: #fcebeb;
--red-border: #f09595;
--red-text: #7a1f1f;
--amber-bg: #faeeda;
--amber-border: #fac775;
--amber-text: #633806;
--coral-bg: #faece7;
--coral-border: #f0997b;
--coral-text: #4a1b0c;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}
body {
font-family: var(--sans);
font-size: 14px;
background: var(--bg);
color: var(--text-primary);
line-height: 1.7;
padding: 2.5rem 1.5rem;
}
.page { max-width: 860px; margin: 0 auto; }
header {
border-bottom: 1px solid var(--border-strong);
padding-bottom: 1.25rem;
margin-bottom: 2rem;
}
header h1 {
font-family: var(--mono);
font-size: 18px;
font-weight: 500;
letter-spacing: -0.02em;
margin-bottom: 6px;
}
.meta {
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.section { margin-bottom: 2.25rem; }
.section-title {
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before {
content: attr(data-num);
background: var(--text-primary);
color: var(--bg);
font-size: 10px;
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.section-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
p { margin-bottom: 0.75rem; color: var(--text-primary); }
p:last-child { margin-bottom: 0; }
.badge-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 1rem; }
.badge {
font-family: var(--mono);
font-size: 11px;
padding: 3px 10px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
}
.badge.red { background: var(--red-bg); color: var(--red-text); border-color: var(--red-border); }
.badge.green { background: var(--green-bg); color: var(--green-text); border-color: var(--green-border); }
.badge.amber { background: var(--amber-bg); color: var(--amber-text); border-color: var(--amber-border); }
.step {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem 1.25rem;
margin-bottom: 0.6rem;
}
.step-header {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 8px;
}
.step-num {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 50%;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
flex-shrink: 0;
color: var(--text-muted);
}
pre {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 12px;
font-family: var(--mono);
font-size: 12px;
overflow-x: auto;
margin: 0.5rem 0;
white-space: pre;
line-height: 1.6;
}
code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface-alt);
padding: 1px 5px;
border-radius: 3px;
border: 1px solid var(--border);
}
.why {
font-size: 12px;
color: var(--text-muted);
margin-top: 0.4rem;
padding-left: 2px;
}
.why::before { content: '↳ why: '; font-family: var(--mono); }
.note {
font-size: 12px;
color: var(--text-secondary);
margin-top: 0.5rem;
padding-left: 2px;
}
.note::before { content: '📌 '; }
.result { font-size: 12px; margin-top: 0.35rem; padding-left: 2px; }
.result.pass { color: var(--green-text); }
.result.pass::before { content: '✓ '; font-family: var(--mono); }
.result.fail { color: var(--red-text); }
.result.fail::before { content: '✗ '; font-family: var(--mono); }
.result.neutral { color: var(--text-secondary); }
.result.neutral::before { content: '→ '; font-family: var(--mono); }
.callout { border-radius: 6px; padding: 1rem 1.25rem; margin-bottom: 0.75rem; }
.callout.coral { background: var(--coral-bg); border: 1px solid var(--coral-border); }
.callout.coral p { color: var(--coral-text); }
.callout.coral pre { background: #fdf0eb; border-color: var(--coral-border); }
.callout.amber { background: var(--amber-bg); border: 1px solid var(--amber-border); }
.callout.amber p { color: var(--amber-text); }
.callout.amber pre { background: #fdf5e6; border-color: var(--amber-border); }
.callout.amber code { background: #fdf5e6; border-color: var(--amber-border); color: var(--amber-text); }
.callout.green { background: var(--green-bg); border: 1px solid var(--green-border); }
.callout.green p { color: var(--green-text); }
.callout.green pre { background: #d8efc0; border-color: var(--green-border); }
.clue-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--amber-text);
margin-bottom: 8px;
}
.phase-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-primary);
background: var(--surface-alt);
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 5px 12px;
margin: 1.5rem 0 0.6rem 0;
display: inline-block;
}
.script-box { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-bottom: 1rem; }
.script-row { padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); }
.script-row:last-child { border-bottom: none; }
.script-label { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); margin-bottom: 6px; }
.script-row pre { margin: 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
th { text-align: left; padding: 8px 14px; font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); background: var(--surface-alt); border-bottom: 1px solid var(--border); font-weight: 500; }
td { padding: 9px 14px; border-bottom: 1px solid var(--border); vertical-align: top; line-height: 1.5; }
tr:last-child td { border-bottom: none; }
.footer {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
</style>
</head>
<body>
<div class="page">
<header>
<h1>KingDezigns — NAS08 ZFS Pool Maintenance Scripts</h1>
<div class="meta">
<span>📅 2026-05-14</span>
<span>🖥 NAS08 / Raspberry Pi 5</span>
<span>⚙️ OpenMediaVault (OMV)</span>
<span>🐳 Docker Compose</span>
<span>💾 ZFS RAIDZ2 — Penta SATA HAT</span>
</div>
</header>
<!-- SECTION 1 — THE GOAL -->
<div class="section">
<div class="section-title" data-num="1">The goal</div>
<p>NAS08 serves as a Pi-Hole DNS server, nginx proxy server, Vaultwarden Server, Plex server, as well as an OpenMediaVault Nas Server</p>
<div class="badge-row">
<span class="badge amber">ZFS Scrubbing</span>
<span class="badge amber">ZFS Reporting</span>
</div>
<p>The goal is to create regular maintenance to the ZFS pools for this NAS. This is done to protect and keep the pools healthy.</p>
<div class="callout amber" style="margin-top:1rem;">
<div class="clue-label">⚠ Critical — store this document off NAS16</div>
<p>Keep a copy of this document on your workstation, a USB drive, or in print. If NAS08 is down you cannot read files stored on it.</p>
</div>
</div>
<!-- SECTION 2 — Script to Create ZFS report -->
<div class="section">
<div class="section-title" data-num="2">Script to Create ZFS report</div>
<div class="step">
<div class="step-header"><span class="step-num">1</span> Create the folders on NAS08</div>
<p class="note">This script gets the current ZFS pool status and reports it back to the administrator via email. Use these commands to setup the scripts on the NAS.</p>
<pre>ssh rufusking@192.168.150.35
sudo mkdir /usr/scripts/zfs
sudo nano nas08_zfs_report.sh</pre>
</div>
<div class="step">
<div class="step-header"><span class="step-num">2</span> Copy the content of the entire nas08_zfs_report.sh script and paste it in the terminal window. Save (CTRL + X) and commit (Y) the changes</div>
<pre><a href="nas08_zfs_report.sh">nas08_zfs_report.sh</a></pre>
</div>
<div class="step">
<div class="step-header"><span class="step-num">3</span> Make the script executable — NAS08</div>
<pre>chmod +x /usr/scripts/zfs/nas08_zfs_report.sh</pre>
</div>
<div class="step">
<div class="step-header"><span class="step-num">4</span> Run a test report — NAS08</div>
<pre>sudo /usr/scripts/zfs/nas08_zfs_report.sh</pre>
<p>Watch for the report in your inbox</p>
</div>
</div>
<!-- SECTION 3 — Script to perform a ZFS pool scrub -->
<div class="section">
<div class="section-title" data-num="3">Script to perform a ZFS pool scrub</div>
<div class="step">
<div class="step-header"><span class="step-num">1</span> Create the folders on NAS08</div>
<p class="note">This script initiates a ZFS pool scrub and reports back the status to the administrator via email. Use these commands to setup this script on the NAS.</p>
<pre>ssh rufusking@192.168.150.35
sudo mkdir /usr/scripts/zfs
sudo nano nas08_zfs_scrub.sh</pre>
</div>
<div class="step">
<div class="step-header"><span class="step-num">2</span> Copy the content of the entire nas08_zfs_scrub.sh script and paste it in the terminal window. Save (CTRL + X) and commit (Y) the changes</div>
<pre><a href="nas08_zfs_scrub.sh">nas08_zfs_scrub.sh</a></pre>
</div>
<div class="step">
<div class="step-header"><span class="step-num">3</span> Make the script executable — NAS08</div>
<pre>chmod +x /usr/scripts/zfs/nas08_zfs_scrub.sh</pre>
</div>
<div class="step">
<div class="step-header"><span class="step-num">4</span> Run a test report — NAS08</div>
<pre>sudo /usr/scripts/zfs/nas08_zfs_report.sh</pre>
<p>Watch for the report in your inbox</p>
</div>
</div>
<!-- SECTION 4 — KEY DIFFERENCES FROM NAS16 -->
<div class="section">
<div class="section-title" data-num="4">Key differences from NAS16</div>
<table>
<thead>
<tr><th>Area</th><th>NAS08</th><th>NAS16</th></tr>
</thead>
<tbody>
<tr>
<td>Primary role</td>
<td>Docker containers (Pi-hole, Plex, Vaultwarden, Nextcloud, NPM)</td>
<td>Web server (Apache/PHP), databases (MariaDB), Webmin admin</td>
</tr>
<tr>
<td>What's backed up beyond /etc + OMV</td>
<td>Docker Compose files, Pi-hole data, Plex config/metadata</td>
<td>Apache vhosts, PHP config, MariaDB dumps, Webmin config</td>
</tr>
<tr>
<td>Website files</td>
<td>N/A</td>
<td>Intentionally excluded — stored on NAS drives, covered by redundancy</td>
</tr>
<tr>
<td>Database backup</td>
<td>None (no databases)</td>
<td>Full mysqldump of all user databases, .sql.gz per database</td>
</tr>
<tr>
<td>Backup destination</td>
<td>/export/kingdezigns-all/Backups/NAS08/</td>
<td>/export/kingdezignsnas-16/Backups/NAS16/</td>
</tr>
<tr>
<td>ZFS pool name</td>
<td>kingdezignsnas</td>
<td>kingdezignsnas-16 <em>(assumed — verify with <code>sudo zpool list</code>)</em></td>
</tr>
<tr>
<td>Recovery phases</td>
<td>9 phases, 34 steps</td>
<td>10 phases, 40 steps</td>
</tr>
</tbody>
</table>
</div>
<!-- SECTION 5 — NOTES & ASSUMPTIONS -->
<div class="section">
<div class="section-title" data-num="5">Notes &amp; assumptions to verify</div>
<div class="callout amber">
<div class="clue-label">⚠ Verify these before first run</div>
<p>The script was written based on information provided and follows Raspberry Pi OS / Debian conventions. Verify the following on NAS16 before treating any backup as production-ready.</p>
</div>
<table>
<thead>
<tr><th>Item</th><th>Assumed value</th><th>How to verify</th></tr>
</thead>
<tbody>
<tr>
<td>ZFS pool name</td>
<td><code>kingdezignsnas</code></td>
<td><code>sudo zpool list</code></td>
</tr>
<tr>
<td>Backup destination path</td>
<td><code>/export/kingdezigns08/Backups/NAS08/</code></td>
<td><code>ls /export/</code> — confirm share name matches</td>
</tr>
<tr>
<td>MariaDB auth method</td>
<td>Uses <code>/etc/mysql/debian.cnf</code> (maintenance account, no password when run as root)</td>
<td>Run: <code>sudo mysql --defaults-file=/etc/mysql/debian.cnf -e "SHOW DATABASES;"</code> — should work without password</td>
</tr>
<tr>
<td>PHP version(s)</td>
<td>Auto-detected from <code>/etc/php/*/</code></td>
<td><code>php -v</code> and <code>ls /etc/php/</code></td>
</tr>
<tr>
<td>Apache config location</td>
<td><code>/etc/apache2/</code> (standard Debian)</td>
<td><code>apache2 -V</code> — confirms config path</td>
</tr>
<tr>
<td>Webmin config location</td>
<td><code>/etc/webmin/</code></td>
<td><code>ls /etc/webmin/</code></td>
</tr>
</tbody>
</table>
</div>
<!-- SECTION 6 — NETWORK MAP UPDATES -->
<div class="section">
<div class="section-title" data-num="6">Network map — items to update</div>
<table>
<thead>
<tr><th>Item</th><th>Old value</th><th>New value</th></tr>
</thead>
<tbody>
<tr>
<td>NAS16 — Backup status</td>
<td><code>None</code></td>
<td><code>Automated — every 3 days — 30-day retention</code></td>
</tr>
<tr>
<td>NAS16 — Backup destination</td>
<td><code>None</code></td>
<td><code>/export/kingdezignsnas-16/Backups/NAS16/</code></td>
</tr>
<tr>
<td>NAS16 — Backup script</td>
<td><code>None</code></td>
<td><code>/usr/scripts/omv/nas16-backup.sh</code></td>
</tr>
<tr>
<td>NAS16 — PCIe requirement</td>
<td><code>not documented</code></td>
<td><code>dtparam=pciex1 + dtparam=pciex1_gen=3 required in /boot/firmware/config.txt on fresh OS</code></td>
</tr>
<tr>
<td>NAS16 — Web stack</td>
<td><code>not documented</code></td>
<td><code>Apache · PHP · MariaDB · Webmin · Adminer</code></td>
</tr>
<tr>
<td>NAS16 — Recovery plan</td>
<td><code>None</code></td>
<td><code>NAS16-Backup-Summary.html — 40 steps across 10 phases + ZFS troubleshooting</code></td>
</tr>
</tbody>
</table>
</div>
<div class="footer">
<span>KingDezigns Home Network</span>
<span>Generated 2026-05-12</span>
</div>
</div>
</body>
</html>

View file

@ -1,322 +0,0 @@
#!/bin/bash
# =============================================================================
# ZFS Pool Scrub & Email Report — NAS08
# Location: /usr/scripts/zfs/nas08_zfs_scrub.sh
# Schedule via OMV Scheduler (cron)
# =============================================================================
# ── Configuration ─────────────────────────────────────────────────────────────
HOSTNAME="NAS08"
REPORT_TO="rufus.king@kingdezigns.com"
REPORT_FROM="zfs-monitor@nas08.local" # Display only — Postfix uses your Gmail relay
SCRUB_WAIT_SECONDS=28800 # Max wait per pool scrub (8 hrs for large pools)
CAPACITY_WARN_THRESHOLD=80 # % capacity to flag as WARNING
SCRUB_AGE_WARN_DAYS=8 # Days since last scrub before flagging stale
LOG_FILE="/var/log/zfs_scrub_nas08.log"
# Note: Email is sent via OMV-configured Postfix (Gmail relay). No SMTP config needed here.
# ──────────────────────────────────────────────────────────────────────────────
REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S")
POOLS=$(zpool list -H -o name)
POOL_COUNT=$(echo "$POOLS" | wc -l)
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
log "===== ZFS Scrub started on $HOSTNAME ====="
# ── Run scrubs sequentially ───────────────────────────────────────────────────
for pool in $POOLS; do
log "Starting scrub on pool: $pool"
zpool scrub "$pool"
ELAPSED=0
while true; do
SCRUB_STATE=$(zpool status "$pool" | grep -E "scan:" | awk '{print $2}')
[[ "$SCRUB_STATE" == "scrub" ]] || break
sleep 30
ELAPSED=$((ELAPSED + 30))
if [[ $ELAPSED -ge $SCRUB_WAIT_SECONDS ]]; then
log "WARNING: Scrub on $pool exceeded wait time. Moving on."
break
fi
done
log "Scrub complete (or timed out) for pool: $pool"
done
# ── Gather data & build pool cards ───────────────────────────────────────────
OVERALL_STATUS="HEALTHY" # HEALTHY | WARNING | CRITICAL
POOLS_WITH_ISSUES=0
POOL_CARDS_HTML=""
for pool in $POOLS; do
STATUS_RAW=$(zpool list -H -o health "$pool")
SIZE=$(zpool list -H -o size "$pool")
USED=$(zpool list -H -o alloc "$pool")
FREE=$(zpool list -H -o free "$pool")
CAP=$(zpool list -H -o cap "$pool" | tr -d '%')
FRAG=$(zpool list -H -o frag "$pool")
SCRUB_LINE=$(zpool status "$pool" | grep -E "scan:")
ERRORS_LINE=$(zpool status "$pool" | grep "errors:")
CONFIG_BLOCK=$(zpool status "$pool" | awk '/config:/,/errors:/' | head -n -1)
FAULTED_LINES=$(echo "$CONFIG_BLOCK" | grep -E "FAULTED|DEGRADED|UNAVAIL|REMOVED" | grep -v "^$")
# Per-pool severity
POOL_SEVERITY="HEALTHY"
ISSUE_DETAIL=""
if [[ "$STATUS_RAW" == "DEGRADED" || "$STATUS_RAW" == "FAULTED" || "$STATUS_RAW" == "UNAVAIL" ]]; then
POOL_SEVERITY="CRITICAL"
OVERALL_STATUS="CRITICAL"
ISSUE_DETAIL="$FAULTED_LINES"
elif [[ "$CAP" -ge "$CAPACITY_WARN_THRESHOLD" ]]; then
POOL_SEVERITY="WARNING"
[[ "$OVERALL_STATUS" != "CRITICAL" ]] && OVERALL_STATUS="WARNING"
fi
# Scrub error count
SCRUB_ERRORS=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1)
[[ -z "$SCRUB_ERRORS" ]] && SCRUB_ERRORS="0 errors"
# Scrub date
SCRUB_DATE=$(echo "$SCRUB_LINE" | grep -oP '[A-Z][a-z]{2} \d+ \d+:\d+:\d+ \d{4}' | head -1)
[[ -z "$SCRUB_DATE" ]] && SCRUB_DATE="No scrub data"
# Scrub age warning
SCRUB_AGE_NOTE=""
if [[ -n "$SCRUB_DATE" && "$SCRUB_DATE" != "No scrub data" ]]; then
SCRUB_EPOCH=$(date -d "$SCRUB_DATE" +%s 2>/dev/null)
NOW_EPOCH=$(date +%s)
DIFF_DAYS=$(( (NOW_EPOCH - SCRUB_EPOCH) / 86400 ))
if [[ $DIFF_DAYS -ge $SCRUB_AGE_WARN_DAYS ]]; then
SCRUB_AGE_NOTE=" — ⚠️ Last scrub was ${DIFF_DAYS} days ago"
[[ "$POOL_SEVERITY" == "HEALTHY" ]] && POOL_SEVERITY="WARNING"
[[ "$OVERALL_STATUS" != "CRITICAL" ]] && OVERALL_STATUS="WARNING"
fi
fi
[[ "$POOL_SEVERITY" != "HEALTHY" ]] && POOLS_WITH_ISSUES=$((POOLS_WITH_ISSUES + 1))
# Colors
case "$STATUS_RAW" in
ONLINE) HEALTH_COLOR="#1a7f4b" ;;
DEGRADED) HEALTH_COLOR="#b91c1c" ;;
FAULTED) HEALTH_COLOR="#b91c1c" ;;
*) HEALTH_COLOR="#b45309" ;;
esac
case "$POOL_SEVERITY" in
CRITICAL) CARD_BORDER="#fca5a5"; CARD_HEADER_BG="#fff1f2"; POOL_BADGE_BG="#b91c1c"; POOL_BADGE_LABEL="$STATUS_RAW" ;;
WARNING) CARD_BORDER="#fcd34d"; CARD_HEADER_BG="#fffbeb"; POOL_BADGE_BG="#b45309"; POOL_BADGE_LABEL="WARNING" ;;
*) CARD_BORDER="#86efac"; CARD_HEADER_BG="#f0fdf4"; POOL_BADGE_BG="#1a7f4b"; POOL_BADGE_LABEL="HEALTHY" ;;
esac
CAP_COLOR="#111827"
[[ "$CAP" -ge "$CAPACITY_WARN_THRESHOLD" ]] && CAP_COLOR="#b45309"
[[ "$CAP" -ge 90 ]] && CAP_COLOR="#b91c1c"
# Issue block
ISSUE_BLOCK=""
if [[ -n "$ISSUE_DETAIL" ]]; then
ESCAPED_DETAIL=$(echo "$ISSUE_DETAIL" | sed 's/</\&lt;/g; s/>/\&gt;/g')
ISSUE_BLOCK="
<tr><td colspan=\"7\" style=\"padding:10px 0 0;\">
<div style=\"padding:10px 14px;background:#fff1f2;border-left:4px solid #b91c1c;border-radius:4px;font-family:monospace;font-size:12px;color:#7f1d1d;white-space:pre;\">$ESCAPED_DETAIL</div>
</td></tr>"
fi
# Config block
ESCAPED_CONFIG=$(echo "$CONFIG_BLOCK" | sed 's/</\&lt;/g; s/>/\&gt;/g')
POOL_CARDS_HTML+="
<div style=\"background:white;border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.08);margin-bottom:20px;overflow:hidden;border:1px solid ${CARD_BORDER};\">
<!-- Pool card header -->
<div style=\"background:${CARD_HEADER_BG};padding:14px 20px;border-bottom:1px solid ${CARD_BORDER};\">
<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">
<tr>
<td>
<span style=\"font-size:16px;font-weight:700;color:#111827;\">🗄️ $pool</span>
<span style=\"margin-left:8px;font-size:12px;color:#6b7280;\">pool</span>
</td>
<td align=\"right\">
<span style=\"background:${POOL_BADGE_BG};color:white;padding:4px 14px;border-radius:20px;font-size:11px;font-weight:700;letter-spacing:.5px;\">$POOL_BADGE_LABEL</span>
</td>
</tr>
</table>
</div>
<!-- Pool stats -->
<div style=\"padding:18px 20px;\">
<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">
<tr>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Total</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$SIZE</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Used</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$USED</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Free</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$FREE</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Capacity</div>
<div style=\"font-size:18px;font-weight:700;color:${CAP_COLOR};margin-top:2px;\">${CAP}%</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Frag</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$FRAG</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Health</div>
<div style=\"font-size:18px;font-weight:700;color:${HEALTH_COLOR};margin-top:2px;\">$STATUS_RAW</div>
</td>
</tr>
$ISSUE_BLOCK
</table>
<!-- Last scrub row -->
<div style=\"margin-top:14px;padding:10px 14px;background:#f8fafc;border-radius:6px;border:1px solid #e5e7eb;\">
<span style=\"font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;\">🔍 Last Scrub</span>
<span style=\"margin-left:10px;font-size:13px;color:#374151;\">Completed &nbsp;|&nbsp; $SCRUB_DATE &nbsp;|&nbsp; $SCRUB_ERRORS${SCRUB_AGE_NOTE}</span>
</div>
<!-- vdev config collapsible -->
<details style=\"margin-top:12px;\">
<summary style=\"cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;letter-spacing:.3px;padding:6px 0;\">▶ vdev / drive configuration</summary>
<pre style=\"margin-top:8px;background:#1e293b;color:#e2e8f0;padding:14px;border-radius:6px;font-size:12px;line-height:1.6;overflow-x:auto;\">$ESCAPED_CONFIG</pre>
</details>
</div>
</div>"
done
# ── Overall banner & badge ────────────────────────────────────────────────────
case "$OVERALL_STATUS" in
CRITICAL)
BADGE_HTML="<div style=\"background:#b91c1c;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">🚨 CRITICAL</div>"
BANNER_HTML="<tr><td style=\"background:#b91c1c;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">🚨 &nbsp;CRITICAL issue detected — immediate action required!</span></td></tr>"
ISSUES_COLOR="#b91c1c"
;;
WARNING)
BADGE_HTML="<div style=\"background:#b45309;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">⚠️ WARNING</div>"
BANNER_HTML="<tr><td style=\"background:#b45309;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">⚠️ &nbsp;Warning condition detected — review recommended.</span></td></tr>"
ISSUES_COLOR="#b45309"
;;
*)
BADGE_HTML="<div style=\"background:#1a7f4b;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">✅ ALL HEALTHY</div>"
BANNER_HTML="<tr><td style=\"background:#1a7f4b;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">✅ &nbsp;All pools are healthy — no action required.</span></td></tr>"
ISSUES_COLOR="#1a7f4b"
;;
esac
# ── Compose full HTML email ───────────────────────────────────────────────────
HTML_BODY=$(cat <<EOF
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellspacing="0" cellpadding="0" style="background:#f1f5f9;padding:30px 0;">
<tr><td align="center">
<table width="680" cellspacing="0" cellpadding="0" style="max-width:680px;width:100%;">
<!-- Header -->
<tr><td style="background:#0f172a;border-radius:10px 10px 0 0;padding:28px 32px;">
<table width="100%" cellspacing="0" cellpadding="0">
<tr>
<td>
<div style="font-size:11px;color:#94a3b8;letter-spacing:1px;text-transform:uppercase;">ZFS Pool Health Monitor</div>
<div style="font-size:26px;font-weight:800;color:white;margin-top:4px;">🔌 $HOSTNAME</div>
<div style="font-size:13px;color:#94a3b8;margin-top:4px;">$REPORT_TIME &nbsp;|&nbsp; $POOL_COUNT pool(s) monitored</div>
</td>
<td align="right">$BADGE_HTML</td>
</tr>
</table>
</td></tr>
<!-- Status banner -->
$BANNER_HTML
<!-- Pool cards -->
<tr><td style="padding:28px 24px 8px;">
$POOL_CARDS_HTML
</td></tr>
<!-- Report summary card -->
<tr><td style="padding:0 24px 28px;">
<div style="background:white;border-radius:10px;border:1px solid #e5e7eb;padding:18px 22px;">
<div style="font-size:13px;font-weight:700;color:#374151;margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px;">📊 Report Summary</div>
<table width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="font-size:13px;color:#6b7280;">Host</td>
<td style="font-size:13px;color:#111827;font-weight:600;">$HOSTNAME</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Report Time</td>
<td style="font-size:13px;color:#111827;font-weight:600;">$REPORT_TIME</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Pools Checked</td>
<td style="font-size:13px;color:#111827;font-weight:600;">$POOL_COUNT</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Pools with Issues</td>
<td style="font-size:13px;font-weight:700;color:${ISSUES_COLOR};">$POOLS_WITH_ISSUES</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Scrub Age Warning Threshold</td>
<td style="font-size:13px;color:#111827;font-weight:600;">$SCRUB_AGE_WARN_DAYS days</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Capacity Warning Threshold</td>
<td style="font-size:13px;color:#111827;font-weight:600;">${CAPACITY_WARN_THRESHOLD}%</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Overall Status</td>
<td style="font-size:13px;font-weight:700;color:${ISSUES_COLOR};">$OVERALL_STATUS</td>
</tr>
</table>
</div>
</td></tr>
<!-- Footer -->
<tr><td style="background:#0f172a;border-radius:0 0 10px 10px;padding:16px 32px;text-align:center;">
<span style="font-size:11px;color:#64748b;">Automated ZFS Monitor &nbsp;|&nbsp; $HOSTNAME &nbsp;|&nbsp; KingDezigns Infrastructure</span>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
EOF
)
# ── Send email via OMV Postfix (Gmail relay) ──────────────────────────────────
SUBJECT="[ZFS] $HOSTNAME — $OVERALL_STATUS | $REPORT_TIME"
{
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_BODY"
} | sendmail -t
log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS"
log "===== ZFS Scrub complete on $HOSTNAME ====="

View file

@ -29,7 +29,6 @@ for pool in $POOLS; do
log "Starting scrub on pool: $pool"
zpool scrub "$pool"
# Wait for scrub to finish
ELAPSED=0
while true; do
SCRUB_STATE=$(zpool status "$pool" | grep -E "scan:" | awk '{print $2}')
@ -44,7 +43,7 @@ for pool in $POOLS; do
log "Scrub complete (or timed out) for pool: $pool"
done
# ── Gather data & build HTML report ──────────────────────────────────────────
# ── Gather data & build pool cards ───────────────────────────────────────────
OVERALL_STATUS="HEALTHY" # HEALTHY | WARNING | CRITICAL
POOLS_WITH_ISSUES=0
@ -62,7 +61,7 @@ for pool in $POOLS; do
CONFIG_BLOCK=$(zpool status "$pool" | awk '/config:/,/errors:/' | head -n -1)
FAULTED_LINES=$(echo "$CONFIG_BLOCK" | grep -E "FAULTED|DEGRADED|UNAVAIL|REMOVED" | grep -v "^$")
# Determine per-pool severity
# Per-pool severity
POOL_SEVERITY="HEALTHY"
ISSUE_DETAIL=""
@ -75,7 +74,7 @@ for pool in $POOLS; do
[[ "$OVERALL_STATUS" != "CRITICAL" ]] && OVERALL_STATUS="WARNING"
fi
# Scrub error count from status
# Scrub error count
SCRUB_ERRORS=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1)
[[ -z "$SCRUB_ERRORS" ]] && SCRUB_ERRORS="0 errors"
@ -98,7 +97,7 @@ for pool in $POOLS; do
[[ "$POOL_SEVERITY" != "HEALTHY" ]] && POOLS_WITH_ISSUES=$((POOLS_WITH_ISSUES + 1))
# Health value color
# Colors
case "$STATUS_RAW" in
ONLINE) HEALTH_COLOR="#1a7f4b" ;;
DEGRADED) HEALTH_COLOR="#b91c1c" ;;
@ -106,97 +105,115 @@ for pool in $POOLS; do
*) HEALTH_COLOR="#b45309" ;;
esac
# Pool card header color
case "$POOL_SEVERITY" in
CRITICAL) CARD_BG="#fff1f2"; BADGE_BG="#b91c1c"; BADGE_LABEL="$STATUS_RAW" ;;
WARNING) CARD_BG="#fffbeb"; BADGE_BG="#b45309"; BADGE_LABEL="WARNING" ;;
*) CARD_BG="#f0fdf4"; BADGE_BG="#1a7f4b"; BADGE_LABEL="HEALTHY" ;;
CRITICAL) CARD_BORDER="#fca5a5"; CARD_HEADER_BG="#fff1f2"; POOL_BADGE_BG="#b91c1c"; POOL_BADGE_LABEL="$STATUS_RAW" ;;
WARNING) CARD_BORDER="#fcd34d"; CARD_HEADER_BG="#fffbeb"; POOL_BADGE_BG="#b45309"; POOL_BADGE_LABEL="WARNING" ;;
*) CARD_BORDER="#86efac"; CARD_HEADER_BG="#f0fdf4"; POOL_BADGE_BG="#1a7f4b"; POOL_BADGE_LABEL="HEALTHY" ;;
esac
# Capacity color
CAP_COLOR="#111827"
[[ "$CAP" -ge "$CAPACITY_WARN_THRESHOLD" ]] && CAP_COLOR="#b45309"
[[ "$CAP" -ge 90 ]] && CAP_COLOR="#b91c1c"
# Issue block (only shown when there's a problem)
# Issue block
ISSUE_BLOCK=""
if [[ -n "$ISSUE_DETAIL" ]]; then
ESCAPED_DETAIL=$(echo "$ISSUE_DETAIL" | sed 's/</\&lt;/g; s/>/\&gt;/g')
ISSUE_BLOCK="<div style=\"margin-top:10px;padding:10px 14px;background:#fff1f2;border-left:4px solid #b91c1c;border-radius:4px;font-family:monospace;font-size:13px;color:#7f1d1d;white-space:pre;\">$ESCAPED_DETAIL</div>"
ISSUE_BLOCK="
<tr><td colspan=\"7\" style=\"padding:10px 0 0;\">
<div style=\"padding:10px 14px;background:#fff1f2;border-left:4px solid #b91c1c;border-radius:4px;font-family:monospace;font-size:12px;color:#7f1d1d;white-space:pre;\">$ESCAPED_DETAIL</div>
</td></tr>"
fi
# Config block
ESCAPED_CONFIG=$(echo "$CONFIG_BLOCK" | sed 's/</\&lt;/g; s/>/\&gt;/g')
POOL_CARDS_HTML+="
<div style=\"background:white;border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.10);margin-bottom:28px;overflow:hidden;border:1px solid #e5e7eb;\">
<div style=\"background:$CARD_BG;padding:16px 22px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e5e7eb;\">
<div>
<span style=\"font-size:18px;font-weight:700;color:#111827;\">🗄️ $pool</span>
<span style=\"margin-left:8px;font-size:12px;color:#6b7280;\">pool</span>
</div>
<span style=\"background:$BADGE_BG;color:white;padding:5px 14px;border-radius:20px;font-size:12px;font-weight:700;letter-spacing:.5px;\">$BADGE_LABEL</span>
</div>
<div style=\"padding:20px 22px;\">
<div style=\"background:white;border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.08);margin-bottom:20px;overflow:hidden;border:1px solid ${CARD_BORDER};\">
<!-- Pool card header -->
<div style=\"background:${CARD_HEADER_BG};padding:14px 20px;border-bottom:1px solid ${CARD_BORDER};\">
<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">
<tr>
<td style=\"width:16%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Total Size</div>
<div style=\"font-size:20px;font-weight:700;color:#111827;margin-top:2px;\">$SIZE</div>
<td>
<span style=\"font-size:16px;font-weight:700;color:#111827;\">🗄️ $pool</span>
<span style=\"margin-left:8px;font-size:12px;color:#6b7280;\">pool</span>
</td>
<td style=\"width:4%;\"></td>
<td style=\"width:16%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Used</div>
<div style=\"font-size:20px;font-weight:700;color:#111827;margin-top:2px;\">$USED</div>
</td>
<td style=\"width:4%;\"></td>
<td style=\"width:16%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Free</div>
<div style=\"font-size:20px;font-weight:700;color:#111827;margin-top:2px;\">$FREE</div>
</td>
<td style=\"width:4%;\"></td>
<td style=\"width:16%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Capacity</div>
<div style=\"font-size:20px;font-weight:700;color:$CAP_COLOR;margin-top:2px;\">${CAP}%</div>
</td>
<td style=\"width:4%;\"></td>
<td style=\"width:16%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Fragmentation</div>
<div style=\"font-size:20px;font-weight:700;color:#111827;margin-top:2px;\">$FRAG</div>
</td>
<td style=\"width:4%;\"></td>
<td style=\"width:16%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Health</div>
<div style=\"font-size:20px;font-weight:700;color:$HEALTH_COLOR;margin-top:2px;\">$STATUS_RAW</div>
<td align=\"right\">
<span style=\"background:${POOL_BADGE_BG};color:white;padding:4px 14px;border-radius:20px;font-size:11px;font-weight:700;letter-spacing:.5px;\">$POOL_BADGE_LABEL</span>
</td>
</tr>
</table>
<div style=\"margin-top:16px;padding:12px 14px;background:#f8fafc;border-radius:6px;border:1px solid #e5e7eb;\">
<span style=\"font-size:12px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.5px;\">🔍 Last Scrub</span>
<span style=\"margin-left:10px;font-size:13px;color:#374151;\">Completed &nbsp;|&nbsp; $SCRUB_DATE &nbsp;|&nbsp; $SCRUB_ERRORS$SCRUB_AGE_NOTE</span>
</div>
<!-- Pool stats -->
<div style=\"padding:18px 20px;\">
<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">
<tr>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Total</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$SIZE</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Used</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$USED</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Free</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$FREE</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Capacity</div>
<div style=\"font-size:18px;font-weight:700;color:${CAP_COLOR};margin-top:2px;\">${CAP}%</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Frag</div>
<div style=\"font-size:18px;font-weight:700;color:#111827;margin-top:2px;\">$FRAG</div>
</td>
<td style=\"width:3%;\"></td>
<td style=\"width:14%;padding:8px 10px;background:#f9fafb;border-radius:6px;text-align:center;\">
<div style=\"font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Health</div>
<div style=\"font-size:18px;font-weight:700;color:${HEALTH_COLOR};margin-top:2px;\">$STATUS_RAW</div>
</td>
</tr>
$ISSUE_BLOCK
<details style=\"margin-top:14px;\">
<summary style=\"cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;letter-spacing:.3px;\">▶ vdev / drive configuration</summary>
</table>
<!-- Last scrub row -->
<div style=\"margin-top:14px;padding:10px 14px;background:#f8fafc;border-radius:6px;border:1px solid #e5e7eb;\">
<span style=\"font-size:11px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;\">🔍 Last Scrub</span>
<span style=\"margin-left:10px;font-size:13px;color:#374151;\">Completed &nbsp;|&nbsp; $SCRUB_DATE &nbsp;|&nbsp; $SCRUB_ERRORS${SCRUB_AGE_NOTE}</span>
</div>
<!-- vdev config collapsible -->
<details style=\"margin-top:12px;\">
<summary style=\"cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;letter-spacing:.3px;padding:6px 0;\">▶ vdev / drive configuration</summary>
<pre style=\"margin-top:8px;background:#1e293b;color:#e2e8f0;padding:14px;border-radius:6px;font-size:12px;line-height:1.6;overflow-x:auto;\">$ESCAPED_CONFIG</pre>
</details>
</div>
</div>"
done
# ── Overall banner ────────────────────────────────────────────────────────────
# ── Overall banner & badge ────────────────────────────────────────────────────
case "$OVERALL_STATUS" in
CRITICAL)
BADGE_HTML="<div style=\"background:#b91c1c;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">🚨 CRITICAL</div>"
BANNER_HTML="<tr><td style=\"background:#b91c1c;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">🚨 &nbsp;CRITICAL issue detected — immediate action required!</span></td></tr>"
ISSUES_COLOR="#b91c1c"
;;
WARNING)
BADGE_HTML="<div style=\"background:#b45309;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">⚠️ WARNING</div>"
BANNER_HTML="<tr><td style=\"background:#b45309;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">⚠️ &nbsp;Warning condition detected — review recommended.</span></td></tr>"
ISSUES_COLOR="#b45309"
;;
*)
BADGE_HTML="<div style=\"background:#1a7f4b;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">✅ ALL HEALTHY</div>"
BANNER_HTML="<tr><td style=\"background:#1a7f4b;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">✅ &nbsp;All pools are healthy — no action required.</span></td></tr>"
ISSUES_COLOR="#1a7f4b"
;;
esac
@ -210,6 +227,7 @@ HTML_BODY=$(cat <<EOF
<tr><td align="center">
<table width="680" cellspacing="0" cellpadding="0" style="max-width:680px;width:100%;">
<!-- Header -->
<tr><td style="background:#0f172a;border-radius:10px 10px 0 0;padding:28px 32px;">
<table width="100%" cellspacing="0" cellpadding="0">
<tr>
@ -223,15 +241,18 @@ HTML_BODY=$(cat <<EOF
</table>
</td></tr>
<!-- Status banner -->
$BANNER_HTML
<!-- Pool cards -->
<tr><td style="padding:28px 24px 8px;">
$POOL_CARDS_HTML
</td></tr>
<!-- Report summary card -->
<tr><td style="padding:0 24px 28px;">
<div style="background:white;border-radius:10px;border:1px solid #e5e7eb;padding:18px 22px;">
<div style="font-size:13px;font-weight:700;color:#374151;margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px;">📋 Report Summary</div>
<div style="font-size:13px;font-weight:700;color:#374151;margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px;">📊 Report Summary</div>
<table width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="font-size:13px;color:#6b7280;">Host</td>
@ -250,7 +271,7 @@ HTML_BODY=$(cat <<EOF
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Pools with Issues</td>
<td style="font-size:13px;font-weight:700;color:#b91c1c;">$POOLS_WITH_ISSUES</td>
<td style="font-size:13px;font-weight:700;color:${ISSUES_COLOR};">$POOLS_WITH_ISSUES</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
@ -262,10 +283,16 @@ HTML_BODY=$(cat <<EOF
<td style="font-size:13px;color:#6b7280;">Capacity Warning Threshold</td>
<td style="font-size:13px;color:#111827;font-weight:600;">${CAPACITY_WARN_THRESHOLD}%</td>
</tr>
<tr><td colspan="2" style="height:6px;"></td></tr>
<tr>
<td style="font-size:13px;color:#6b7280;">Overall Status</td>
<td style="font-size:13px;font-weight:700;color:${ISSUES_COLOR};">$OVERALL_STATUS</td>
</tr>
</table>
</div>
</td></tr>
<!-- Footer -->
<tr><td style="background:#0f172a;border-radius:0 0 10px 10px;padding:16px 32px;text-align:center;">
<span style="font-size:11px;color:#64748b;">Automated ZFS Monitor &nbsp;|&nbsp; $HOSTNAME &nbsp;|&nbsp; KingDezigns Infrastructure</span>
</td></tr>