Initial import
This commit is contained in:
commit
f50008c41d
24 changed files with 6813 additions and 0 deletions
923
Backup Scripts/NAS08-Backup-Summary.html
Normal file
923
Backup Scripts/NAS08-Backup-Summary.html
Normal file
|
|
@ -0,0 +1,923 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KingDezigns â NAS08 Automated Backup Setup Summary</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 Automated Backup Setup summary</h1>
|
||||
<div class="meta">
|
||||
<span>ğ
2026-05-11</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 had no automated recovery path. A software or hardware failure â corrupt SD card, bad OMV update, runaway script â would have required a full manual rebuild of OMV, all Docker containers, Pi-hole, Plex, Vaultwarden, Nextcloud, and nginx-proxy-manager with no documented procedure and no saved state.</p>
|
||||
|
||||
<div class="badge-row">
|
||||
<span class="badge amber">No OS backup</span>
|
||||
<span class="badge amber">No automated recovery plan</span>
|
||||
<span class="badge amber">No ZFS restore procedure</span>
|
||||
<span class="badge amber">Plex music library previously lost to a bad script</span>
|
||||
</div>
|
||||
|
||||
<p>The goal was to create a fully automated, scheduled backup of the OS and configuration layer â not the NAS data drives â that could restore NAS08 to full operation within approximately 90 minutes after a failure, with no manual intervention during normal operation.</p>
|
||||
|
||||
<div class="callout amber" style="margin-top:1rem;">
|
||||
<div class="clue-label">â Critical â store this document off NAS08</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 â DIAGNOSTIC COMMANDS -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="2">Discovery commands run & why</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Locate Docker app data directories</div>
|
||||
<pre>ls /export/kingdezigns-all/Docker
|
||||
ls /export/kingdezigns-all/Docker/Compose</pre>
|
||||
<p class="why">Needed to confirm where application configs and compose files lived before writing backup paths into the script.</p>
|
||||
<p class="result pass">Docker root confirmed at <code>/export/kingdezigns-all/Docker/</code> with Compose subfolder containing all stacks: nextcloud, nginx-proxy-manager, pihole, plex, vaultwarden.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Check first archive size and contents</div>
|
||||
<pre>ls -lh /export/kingdezigns-all/Backups/NAS08/nas08-backup-2026-05-11.tar.gz
|
||||
tar -tzf /export/kingdezigns-all/Backups/NAS08/nas08-backup-2026-05-11.tar.gz | head -40</pre>
|
||||
<p class="why">Verified the archive was created and inspected its contents to catch bloat or missing sections.</p>
|
||||
<p class="result fail">Archive was 1.4GB â Vaultwarden icon_cache and full Plex config were included without exclusions, inflating the size unnecessarily.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Measure sizes across all Docker folders</div>
|
||||
<pre>sudo du -sh /export/kingdezigns-all/Docker/Compose/*/
|
||||
sudo du -sh /export/kingdezigns-all/Docker/pihole/
|
||||
sudo du -sh /export/kingdezigns-all/Docker/plex/</pre>
|
||||
<p class="why">Identify where archive bulk was coming from before adding exclusions.</p>
|
||||
<p class="result neutral">Plex at 1.8GB was the dominant size. Pi-hole at 27MB acceptable as-is. Compose folders were tiny (3.5Kâ7.4M each).</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">4</span> Drill into Plex config subfolder breakdown</div>
|
||||
<pre>sudo du -sh /export/kingdezigns-all/Docker/plex/config/Library/Application\ Support/Plex\ Media\ Server/*/</pre>
|
||||
<p class="why">Identify which Plex subfolders were safe to exclude vs which contained manually curated data worth preserving.</p>
|
||||
<p class="result pass">Cache (24M), Logs (2M), Crash Reports (23K) identified as safe exclusions. Media (655M) and Metadata (956M) confirmed as music organization and library data â kept.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">5</span> Inspect ZFS pool configuration</div>
|
||||
<pre>sudo zpool list
|
||||
sudo zpool status
|
||||
sudo zfs list</pre>
|
||||
<p class="why">Document exact pool name, drive identifiers, RAID type, and mount point needed for recovery plan ZFS steps.</p>
|
||||
<p class="result pass">Pool: <code>kingdezignsnas</code> â RAIDZ2 across 4 Ã 1TB SSDs â ONLINE â mounted at <code>/kingdezignsnas</code> â last scrub clean, no errors.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 3 â ISSUES ENCOUNTERED -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="3">Issues encountered during setup</div>
|
||||
|
||||
<div class="callout coral">
|
||||
<p>On first run the backup destination directory did not exist, causing the logging system to fail immediately before any backup work could begin.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout amber">
|
||||
<div class="clue-label">â Error â first run</div>
|
||||
<p style="margin-bottom:6px;">Script failed with a tee write error before reaching any backup steps.</p>
|
||||
<pre>tee: /export/kingdezigns-all/Backups/NAS08/backup.log: No such file or directory</pre>
|
||||
<p style="margin-top:8px;"><code>Backups/NAS08/</code> had not been created yet. Fixed by creating it manually. The script now handles this automatically via <code>mkdir -p</code> on every run.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout coral" style="margin-top: 0.75rem;">
|
||||
<p>SCP to <code>/usr/local/bin/</code> failed with permission denied â that path requires root SCP access which rufusking does not have.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout green">
|
||||
<p><strong>Resolution:</strong> Created <code>/usr/scripts/omv/</code> owned by rufusking. SCP now works without root. Script still runs with <code>sudo</code> for root-level operations at runtime.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 4 â SCRIPTS / FILES CREATED -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="4">Scripts / files created</div>
|
||||
|
||||
<div class="script-box">
|
||||
<div class="script-row">
|
||||
<div class="script-label">Backup Script Location</div>
|
||||
<pre>/usr/scripts/omv/nas08-backup.sh</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">What It Backs Up</div>
|
||||
<pre>OMV config â omv-confbak export + raw /etc/openmediavault/config.xml
|
||||
/etc â full system config (network, fstab, nginx, cron, etc.)
|
||||
Docker Compose â all stacks + global.env (vaultwarden icon_cache excluded)
|
||||
Pi-hole data â /export/kingdezigns-all/Docker/pihole/
|
||||
Plex data â full config minus Cache/, Logs/, Crash Reports/
|
||||
Media/ and Metadata/ KEPT (music org + library data)
|
||||
Shared folder â /export/kingdezigns-all/Docker/Shared/
|
||||
Crontab â root crontab + /etc/cron.d entries
|
||||
MANIFEST.txt â baked-in restore instructions and OMV version info</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Output Paths</div>
|
||||
<pre>/export/kingdezigns-all/Backups/NAS08/nas08-backup-YYYY-MM-DD.tar.gz
|
||||
/export/kingdezigns-all/Backups/NAS08/backup.log</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Retention</div>
|
||||
<pre>10 archives retained (~30 days coverage) â older archives auto-deleted on each run</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Expected Size</div>
|
||||
<pre>~1.4GB per archive â ~14GB total at max retention
|
||||
450GB free on data drive â well within capacity</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Schedule via OMV UI</div>
|
||||
<pre>System â Scheduled Jobs â Add
|
||||
Name: NAS08 Backup
|
||||
Command: /usr/scripts/omv/nas08-backup.sh
|
||||
Minute: 0
|
||||
Hour: 2
|
||||
Day of month: */3
|
||||
Month: *
|
||||
Day of week: *
|
||||
Runs automatically at 2:00 AM every 3 days</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Manual Run</div>
|
||||
<pre>sudo /usr/scripts/omv/nas08-backup.sh
|
||||
# or: OMV UI â System â Scheduled Jobs â Run</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 5 â FULL RECOVERY PROCEDURE -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="5">Full recovery procedure</div>
|
||||
|
||||
<div class="callout amber">
|
||||
<div class="clue-label">â Before You Start</div>
|
||||
<p>You need: a workstation with internet, a replacement SD card (16GB+), Raspberry Pi Imager, SSH access, and your rufusking password. Physically connect all 4 ZFS drives to the Penta SATA HAT before powering on.</p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 1 -->
|
||||
<div class="phase-label">Phase 1 â Flash the OS</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Download Raspberry Pi Imager â workstation</div>
|
||||
<pre>https://www.raspberrypi.com/software/</pre>
|
||||
<p class="note">Download and install for your OS (Windows / Mac / Linux).</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Flash Raspberry Pi OS Lite (64-bit) â workstation</div>
|
||||
<pre>Open Raspberry Pi Imager
|
||||
â Choose Device : Raspberry Pi 5
|
||||
â Choose OS : Raspberry Pi OS Lite (64-bit) â NOT the Desktop version
|
||||
â Choose Storage : your SD card
|
||||
â Next â Edit Settings:
|
||||
Hostname : NAS08
|
||||
Username : rufusking
|
||||
Password : your usual password
|
||||
Services : Enable SSH â Use password authentication
|
||||
â Save â Yes â Yes (confirm flash and verify)</pre>
|
||||
<p class="note">Wait for the flash and verify to complete fully before removing the SD card.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Boot the Raspberry Pi</div>
|
||||
<pre>1. Insert SD card into Raspberry Pi 5
|
||||
2. Connect Penta SATA HAT with all 4 ZFS drives plugged in
|
||||
3. Power on
|
||||
4. Wait 2â3 minutes for first boot to complete</pre>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 2 -->
|
||||
<div class="phase-label">Phase 2 â Install OpenMediaVault</div>
|
||||
|
||||
<div class="callout amber">
|
||||
<div class="clue-label">â Note</div>
|
||||
<p>The OMV install method may change in the future. Verify the current script at <code>https://github.com/OpenMediaVault-Plugin-Developers/installScript</code> before running. Steps below reflect the method as of 2026-05-11.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">4</span> SSH into NAS08 â workstation</div>
|
||||
<pre>ssh rufusking@192.168.150.35</pre>
|
||||
<p class="note">If the static IP is not yet assigned, log into UniFi at <code>http://192.168.100.1</code>, find NAS08 in Clients, and SSH to its temporary IP instead.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">5</span> Update the OS â NAS08</div>
|
||||
<pre>sudo apt-get update && sudo apt-get upgrade -y</pre>
|
||||
<p class="note">Wait for the prompt to return before continuing. This may take a few minutes.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">6</span> Install OpenMediaVault â NAS08</div>
|
||||
<pre>sudo wget -O - https://github.com/OpenMediaVault-Plugin-Developers/installScript/raw/master/install | sudo bash</pre>
|
||||
<p class="note">Takes 10â20 minutes. The Pi may reboot automatically. If it does, SSH back in and wait for the script to finish. Do not interrupt it.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">7</span> Verify OMV is running â browser on workstation</div>
|
||||
<pre>http://192.168.150.35
|
||||
|
||||
Default login:
|
||||
Username: admin
|
||||
Password: openmediavault</pre>
|
||||
<p class="note">Confirm the OMV login page loads. Do not configure anything yet â the restore handles all configuration.</p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 3 -->
|
||||
<div class="phase-label">Phase 3 â Enable PCIe for Penta SATA HAT</div>
|
||||
|
||||
<div class="callout coral">
|
||||
<p>The Raspberry Pi 5 does not expose PCIe by default. Without enabling it, the Penta SATA HAT and all 4 ZFS drives are completely invisible to the OS. This must be done before any ZFS commands will work.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">8</span> Edit boot config to enable PCIe â NAS08</div>
|
||||
<pre>sudo nano /boot/firmware/config.txt</pre>
|
||||
<p>Scroll to find the line <code>arm_boost=1</code> and add these lines immediately after it:</p>
|
||||
<pre># Enable PCIe
|
||||
dtparam=pciex1
|
||||
dtparam=pciex1_gen=3</pre>
|
||||
<p>Save and exit:</p>
|
||||
<pre>Ctrl+X â Y â Enter</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">9</span> Reboot to apply PCIe config â NAS08</div>
|
||||
<pre>sudo reboot</pre>
|
||||
<p>Wait 2â3 minutes, then SSH back in:</p>
|
||||
<pre>ssh rufusking@192.168.150.35</pre>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 4 -->
|
||||
<div class="phase-label">Phase 4 â Import ZFS Pool</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">10</span> Install ZFS tools â NAS08</div>
|
||||
<pre>sudo apt-get install -y zfsutils-linux</pre>
|
||||
<p class="note">Installs the ZFS kernel modules and CLI tools. Required on every fresh OS install.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">11</span> Verify all 4 drives are visible â NAS08</div>
|
||||
<pre>lsblk</pre>
|
||||
<p>You should see 4 drives listed (typically <code>sda</code> through <code>sdd</code>). If any are missing, check physical connections and reboot:</p>
|
||||
<pre>sudo reboot
|
||||
# SSH back in, then run lsblk again</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">12</span> Import the ZFS pool â NAS08</div>
|
||||
<pre>sudo zpool import kingdezignsnas</pre>
|
||||
<p class="note">The pool name <code>kingdezignsnas</code> is stored on the drives themselves â it will be recognized on a fresh OS automatically. If the previous OS failed uncleanly, force the import:</p>
|
||||
<pre># Only use -f if the standard import fails:
|
||||
sudo zpool import -f kingdezignsnas</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">13</span> Verify pool imported correctly â NAS08</div>
|
||||
<pre>sudo zpool status</pre>
|
||||
<p>All 4 drives must show <strong>ONLINE</strong>. Expected output:</p>
|
||||
<pre> pool: kingdezignsnas
|
||||
state: ONLINE
|
||||
config:
|
||||
kingdezignsnas ONLINE
|
||||
raidz2-0 ONLINE
|
||||
ata-CT1000BX500SSD1_2539E9CB8529 ONLINE
|
||||
ata-CT1000BX500SSD1_2534E9C97DAD ONLINE
|
||||
ata-CT1000BX500SSD1_2530E9C74CD1 ONLINE
|
||||
ata-CT1000BX500SSD1_2539E9CB84F8 ONLINE
|
||||
errors: No known data errors</pre>
|
||||
<p class="note">If any drive shows FAULTED or DEGRADED, do not proceed. See ZFS Troubleshooting at the bottom of this document.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">14</span> Confirm backup files are accessible â NAS08</div>
|
||||
<pre>sudo zfs list
|
||||
ls /kingdezignsnas/
|
||||
ls /kingdezignsnas/Backups/NAS08/
|
||||
|
||||
# List archives sorted newest first:
|
||||
ls -lht /kingdezignsnas/Backups/NAS08/nas08-backup-*.tar.gz</pre>
|
||||
<p class="note">Use <code>/kingdezignsnas/</code> directly at this stage. The <code>/export/kingdezigns-all/</code> path is recreated by OMV after the config restore in Phase 6.</p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 5 -->
|
||||
<div class="phase-label">Phase 5 â Extract the Backup Archive</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">15</span> Create restore working directory â NAS08</div>
|
||||
<pre>sudo mkdir -p /tmp/nas08-restore</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">16</span> Extract the most recent backup â NAS08</div>
|
||||
<pre># Replace YYYY-MM-DD with the actual archive date from step 14
|
||||
sudo tar -xzf /kingdezignsnas/Backups/NAS08/nas08-backup-YYYY-MM-DD.tar.gz \
|
||||
-C /tmp/nas08-restore</pre>
|
||||
<p class="note">This takes a few minutes â the archive is ~1.4GB. Wait for the prompt to return.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">17</span> Confirm extraction and read the manifest â NAS08</div>
|
||||
<pre># Confirm all folders are present:
|
||||
ls /tmp/nas08-restore/
|
||||
ls /tmp/nas08-restore/nas08-backup-*/
|
||||
|
||||
# Read the manifest:
|
||||
cat /tmp/nas08-restore/nas08-backup-*/MANIFEST.txt</pre>
|
||||
<p>You should see: <code>omv-config/</code> <code>etc/</code> <code>docker-compose/</code> <code>app-data/</code> <code>cron/</code> <code>MANIFEST.txt</code></p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 6 -->
|
||||
<div class="phase-label">Phase 6 â Restore System Configuration</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">18</span> Restore /etc â NAS08</div>
|
||||
<pre>sudo rsync -a /tmp/nas08-restore/nas08-backup-*/etc/ /etc/</pre>
|
||||
<p class="note">Restores all system configuration: network settings, nginx, fstab, cron, and everything else under /etc.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">19</span> Restore OMV configuration â NAS08</div>
|
||||
<pre># Copy saved OMV config back into place:
|
||||
sudo cp /tmp/nas08-restore/nas08-backup-*/omv-config/config.xml.raw \
|
||||
/etc/openmediavault/config.xml
|
||||
|
||||
# Load it into the OMV database:
|
||||
sudo omv-confdbadm populate
|
||||
|
||||
# Re-apply all settings to the system (shares, users, plugins, schedules):
|
||||
sudo omv-salt deploy run all</pre>
|
||||
<p class="note">The last command takes several minutes. Do not interrupt it â let it complete fully.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">20</span> Reboot and verify OMV â NAS08 then browser</div>
|
||||
<pre>sudo reboot</pre>
|
||||
<p>After 2â3 minutes, SSH back in and check:</p>
|
||||
<pre>ssh rufusking@192.168.150.35</pre>
|
||||
<p>Then open a browser on your workstation:</p>
|
||||
<pre>http://192.168.150.35</pre>
|
||||
<p>Log in and verify: shares, users, and settings are present. Go to <strong>Storage â File Systems</strong> and confirm the ZFS pool is mounted and <code>/export/kingdezigns-all</code> is available.</p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 7 -->
|
||||
<div class="phase-label">Phase 7 â Restore Docker and Application Data</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">21</span> Verify Docker is installed â NAS08</div>
|
||||
<pre>sudo docker ps</pre>
|
||||
<p>If Docker is not found, install it:</p>
|
||||
<pre>sudo apt-get install -y docker-compose-plugin</pre>
|
||||
<p>If the OMV Compose plugin is missing, reinstall via OMV UI:</p>
|
||||
<pre>OMV UI â System â Plugins â search "compose" â Install</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">22</span> Restore Docker Compose files â NAS08</div>
|
||||
<pre>sudo rsync -a /tmp/nas08-restore/nas08-backup-*/docker-compose/ \
|
||||
/export/kingdezigns-all/Docker/Compose/
|
||||
|
||||
# Verify files landed correctly:
|
||||
ls /export/kingdezigns-all/Docker/Compose/</pre>
|
||||
<p>Expected: <code>nextcloud/</code> <code>nginx-proxy-manager/</code> <code>pihole/</code> <code>plex/</code> <code>vaultwarden/</code> <code>global.env</code></p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">23</span> Restore Pi-hole data â NAS08</div>
|
||||
<pre>sudo rsync -a /tmp/nas08-restore/nas08-backup-*/app-data/pihole/ \
|
||||
/export/kingdezigns-all/Docker/pihole/</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">24</span> Restore Plex data â NAS08</div>
|
||||
<pre>sudo rsync -a /tmp/nas08-restore/nas08-backup-*/app-data/plex/ \
|
||||
/export/kingdezigns-all/Docker/plex/</pre>
|
||||
<p class="note">This restores your music organization (Media/), library metadata (Metadata/), and watch history (Plug-in Support/). These are the folders that represent weeks of manual work and are the primary reason this backup exists.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">25</span> Restore Shared folder â NAS08</div>
|
||||
<pre>sudo rsync -a /tmp/nas08-restore/nas08-backup-*/app-data/shared/ \
|
||||
/export/kingdezigns-all/Docker/Shared/</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">26</span> Fix permissions on all Docker folders â NAS08</div>
|
||||
<pre>sudo chown -R root:users /export/kingdezigns-all/Docker/
|
||||
sudo chmod -R 775 /export/kingdezigns-all/Docker/</pre>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 8 -->
|
||||
<div class="phase-label">Phase 8 â Bring Containers Back Up</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">27</span> Start each container one at a time â NAS08</div>
|
||||
<pre>cd /export/kingdezigns-all/Docker/Compose/pihole && sudo docker compose up -d
|
||||
cd /export/kingdezigns-all/Docker/Compose/nginx-proxy-manager && sudo docker compose up -d
|
||||
cd /export/kingdezigns-all/Docker/Compose/vaultwarden && sudo docker compose up -d
|
||||
cd /export/kingdezigns-all/Docker/Compose/nextcloud && sudo docker compose up -d
|
||||
cd /export/kingdezigns-all/Docker/Compose/plex && sudo docker compose up -d</pre>
|
||||
<p class="note">Start one at a time â not all at once â so errors from individual containers are not lost in combined output.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">28</span> Verify all containers are running â NAS08</div>
|
||||
<pre>sudo docker ps</pre>
|
||||
<p>All containers should show status <strong>Up</strong>. If any show <strong>Exited</strong>, check that container's logs:</p>
|
||||
<pre>sudo docker logs pihole
|
||||
sudo docker logs nginx-proxy-manager
|
||||
sudo docker logs vaultwarden
|
||||
sudo docker logs nextcloud
|
||||
sudo docker logs plex</pre>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 9 -->
|
||||
<div class="phase-label">Phase 9 â Final Steps & Verification</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">29</span> Verify the backup script is in place â NAS08</div>
|
||||
<pre>ls -lh /usr/scripts/omv/nas08-backup.sh</pre>
|
||||
<p>If missing (it lives outside /etc so may not have restored automatically), copy it from your workstation:</p>
|
||||
<pre># On your workstation:
|
||||
scp nas08-backup.sh rufusking@192.168.150.35:/usr/scripts/omv/nas08-backup.sh
|
||||
|
||||
# On NAS08:
|
||||
chmod +x /usr/scripts/omv/nas08-backup.sh</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">30</span> Restore the crontab â NAS08</div>
|
||||
<pre>sudo crontab /tmp/nas08-restore/nas08-backup-*/cron/root-crontab.txt
|
||||
|
||||
# Verify it was applied:
|
||||
sudo crontab -l</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">31</span> Reschedule the backup job in OMV UI</div>
|
||||
<pre>OMV UI â System â Scheduled Jobs â Add
|
||||
Enabled: Yes
|
||||
Name: NAS08 Backup
|
||||
Command: /usr/scripts/omv/nas08-backup.sh
|
||||
Minute: 0
|
||||
Hour: 2
|
||||
Day of month: */3
|
||||
Month: *
|
||||
Day of week: *
|
||||
â Save</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">32</span> Clean up the restore working directory â NAS08</div>
|
||||
<pre>sudo rm -rf /tmp/nas08-restore</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">33</span> Run a fresh backup immediately to create a new baseline</div>
|
||||
<pre># Option A â OMV UI:
|
||||
System â Scheduled Jobs â NAS08 Backup â Run
|
||||
|
||||
# Option B â SSH on NAS08:
|
||||
sudo /usr/scripts/omv/nas08-backup.sh
|
||||
|
||||
# Verify it completed successfully:
|
||||
ls -lh /export/kingdezigns-all/Backups/NAS08/
|
||||
cat /export/kingdezigns-all/Backups/NAS08/backup.log</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">34</span> Run a ZFS scrub to verify data integrity â NAS08</div>
|
||||
<pre>sudo zpool scrub kingdezignsnas
|
||||
|
||||
# Check results a few minutes later:
|
||||
sudo zpool status</pre>
|
||||
<p>A healthy result:</p>
|
||||
<pre>scan: scrub repaired 0B in 00:xx:xx with 0 errors</pre>
|
||||
</div>
|
||||
|
||||
<!-- ZFS TROUBLESHOOTING -->
|
||||
<div class="phase-label">ZFS Troubleshooting</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">â</span> Pool shows DEGRADED â 1 drive failed</div>
|
||||
<pre>sudo zpool status â failed drive shows FAULTED or UNAVAIL</pre>
|
||||
<p>RAIDZ2 tolerates up to 2 simultaneous drive failures. The pool is still fully readable and writable. Complete the full restore procedure first, then replace the failed drive. Do not skip recovery steps.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">â</span> Pool shows DEGRADED â 2 drives failed</div>
|
||||
<pre>sudo zpool status â two drives show FAULTED or UNAVAIL</pre>
|
||||
<p>Data is at risk but still accessible. Complete the restore immediately and replace both drives as fast as possible. Seek assistance before doing anything else if unsure.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">â</span> Pool cannot be imported â not found</div>
|
||||
<pre># Scan all connected devices for importable pools:
|
||||
sudo zpool import
|
||||
|
||||
# If kingdezignsnas appears in the list, force import:
|
||||
sudo zpool import -f kingdezignsnas</pre>
|
||||
<p class="note">If no pools are found at all, PCIe is likely not enabled. Verify Steps 8â9 (boot config) were applied correctly and reboot again before retrying.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">â</span> Pool imported but /export/kingdezigns-all is missing</div>
|
||||
<pre># Access backup files directly via the ZFS mount point:
|
||||
ls /kingdezignsnas/Backups/NAS08/</pre>
|
||||
<p class="note"><code>/export/kingdezigns-all/</code> is created by OMV when it mounts the filesystem. Use <code>/kingdezignsnas/</code> as the base path for all restore commands until OMV is fully restored.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 6 â LESSONS LEARNED -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="6">Lessons learned</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Lesson</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>PCIe must be enabled before ZFS can see the drives</td>
|
||||
<td>The Raspberry Pi 5 Penta SATA HAT requires PCIe to be explicitly enabled in <code>/boot/firmware/config.txt</code>. On a fresh OS this is never set. Without it all 4 ZFS drives are invisible and the pool cannot be imported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ZFS pool import must happen before any restore steps</td>
|
||||
<td>Without importing the pool, the backup files are completely inaccessible. It is now Phase 4, immediately after enabling PCIe, before any restore work begins.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Plex Media/ and Metadata/ contain irreplaceable manually curated data</td>
|
||||
<td>These folders hold music organization, artwork, and library structure that took weeks of manual work. They must always be included in backups even though they are large (~1.6GB combined).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Never run scripts that touch OMV without a tested recovery plan first</td>
|
||||
<td>A prior script run by another AI tool broke OMV and wiped Plex music organization. This session was motivated by that incident. Backup first, change second.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Custom scripts belong in /usr/scripts/ not /usr/local/bin/</td>
|
||||
<td>Writing to /usr/local/bin/ requires root SCP access. A dedicated /usr/scripts/ directory owned by rufusking avoids this and keeps custom scripts separated from system binaries.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Always create output directories before the first script run</td>
|
||||
<td>The script failed on first run because /Backups/NAS08/ did not exist. The script now handles this automatically via mkdir -p, but always verify the path exists before the first run on a new system.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Vaultwarden icon_cache is large and completely disposable</td>
|
||||
<td>The icon cache for website favicons was inflating the archive. It regenerates automatically and must always be excluded.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Recovery documentation must be stored off the device being recovered</td>
|
||||
<td>A recovery plan stored only on NAS08 is useless when NAS08 is down. Keep copies on a workstation, USB drive, or in print.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 7 â NETWORK MAP UPDATES -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="7">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>NAS08 â Hardware detail</td>
|
||||
<td><code>Raspberry Pi</code></td>
|
||||
<td><code>Raspberry Pi 5 with Penta SATA HAT</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 â Backup status</td>
|
||||
<td><code>None</code></td>
|
||||
<td><code>Automated â every 3 days â 30-day retention</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 â Backup destination</td>
|
||||
<td><code>None</code></td>
|
||||
<td><code>/export/kingdezigns-all/Backups/NAS08/</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 â Backup script</td>
|
||||
<td><code>None</code></td>
|
||||
<td><code>/usr/scripts/omv/nas08-backup.sh</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 â ZFS pool name</td>
|
||||
<td><code>not documented</code></td>
|
||||
<td><code>kingdezignsnas (RAIDZ2, 4 Ã 1TB SSD)</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 â ZFS mount point</td>
|
||||
<td><code>not documented</code></td>
|
||||
<td><code>/kingdezignsnas</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 â 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>NAS08 â Recovery plan</td>
|
||||
<td><code>None</code></td>
|
||||
<td><code>NAS08-Backup-Summary.html â 34 steps across 9 phases + ZFS troubleshooting</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="footer">
|
||||
<span>KingDezigns Home Network</span>
|
||||
<span>Generated 2026-05-11</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
595
Backup Scripts/nas08-backup.sh
Normal file
595
Backup Scripts/nas08-backup.sh
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# NAS08 System Backup Script
|
||||
# =============================================================================
|
||||
# Backs up OMV config, Docker Compose files, app data, and system /etc
|
||||
# Writes a single dated .tar.gz archive to the NAS data drive
|
||||
# Retains the last 10 backups (~30 days), auto-removes older ones
|
||||
# Sends an HTML email report on completion (via OMV Postfix / Gmail relay).
|
||||
#
|
||||
# Schedule via OMV Scheduled Jobs (cron):
|
||||
# 0 2 */3 * * (2:00 AM every 3 days)
|
||||
#
|
||||
# Backup destination: /export/kingdezigns-all/Backups/NAS08/
|
||||
# Script location: /usr/scripts/omv/nas08-backup.sh
|
||||
# =============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CONFIGURATION
|
||||
# -----------------------------------------------------------------------------
|
||||
HOSTNAME_LABEL=$(hostname -s 2>/dev/null || echo "NAS08")
|
||||
REPORT_TO="rufus.king@kingdezigns.com"
|
||||
REPORT_FROM="backup-monitor@nas08.local" # Display only — Postfix uses your Gmail relay
|
||||
|
||||
BACKUP_ROOT="/export/kingdezigns-all/Backups/NAS08"
|
||||
TMP_BASE="/export/kingdezigns-all/Backups/.tmp"
|
||||
DOCKER_DIR="/export/kingdezigns-all/Docker"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
|
||||
REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
ARCHIVE_NAME="nas08-backup-${DATE}.tar.gz"
|
||||
TMP_DIR="${TMP_BASE}/nas08-backup-${TIMESTAMP}"
|
||||
RETAIN_COUNT=10
|
||||
LOG_FILE="${BACKUP_ROOT}/backup.log"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# STATUS TRACKING
|
||||
# OVERALL_STATUS: SUCCESS | WARNING | FAILED
|
||||
# -----------------------------------------------------------------------------
|
||||
OVERALL_STATUS="SUCCESS"
|
||||
SECTION_ROWS_HTML=""
|
||||
WARNING_COUNT=0
|
||||
ERROR_COUNT=0
|
||||
|
||||
# Append a result row to the section table
|
||||
# Usage: add_section_row "Section name" "STATUS" "Detail message"
|
||||
# STATUS: OK | WARNING | ERROR | SKIPPED
|
||||
add_section_row() {
|
||||
local section="$1" status="$2" detail="$3"
|
||||
|
||||
case "$status" in
|
||||
OK)
|
||||
BADGE="<span style=\"background:#d1fae5;color:#065f46;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">✓ OK</span>"
|
||||
;;
|
||||
WARNING)
|
||||
BADGE="<span style=\"background:#fef3c7;color:#92400e;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">⚠ WARNING</span>"
|
||||
WARNING_COUNT=$((WARNING_COUNT + 1))
|
||||
[[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING"
|
||||
;;
|
||||
ERROR)
|
||||
BADGE="<span style=\"background:#fee2e2;color:#991b1b;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">✗ ERROR</span>"
|
||||
ERROR_COUNT=$((ERROR_COUNT + 1))
|
||||
OVERALL_STATUS="FAILED"
|
||||
;;
|
||||
SKIPPED)
|
||||
BADGE="<span style=\"background:#f3f4f6;color:#6b7280;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">— SKIPPED</span>"
|
||||
;;
|
||||
esac
|
||||
|
||||
SECTION_ROWS_HTML+="
|
||||
<tr>
|
||||
<td style=\"padding:9px 14px;border-bottom:1px solid #f3f4f6;font-size:13px;color:#111827;font-weight:600;\">$section</td>
|
||||
<td style=\"padding:9px 14px;border-bottom:1px solid #f3f4f6;\">$BADGE</td>
|
||||
<td style=\"padding:9px 14px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#6b7280;\">$detail</td>
|
||||
</tr>"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# LOGGING
|
||||
# -----------------------------------------------------------------------------
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log_section() {
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "------------------------------------------------------------" | tee -a "$LOG_FILE"
|
||||
log "$1"
|
||||
echo "------------------------------------------------------------" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PRE-FLIGHT CHECKS
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "NAS08 Backup Starting"
|
||||
|
||||
if [ ! -d "/export/kingdezigns-all" ]; then
|
||||
log "ERROR: /export/kingdezigns-all is not mounted or accessible. Aborting."
|
||||
add_section_row "Pre-flight check" "ERROR" "/export/kingdezigns-all not mounted or accessible"
|
||||
OVERALL_STATUS="FAILED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_ROOT"
|
||||
mkdir -p "$TMP_DIR"
|
||||
log "Temporary working directory: $TMP_DIR"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 1: OMV CONFIGURATION
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Backing up OMV configuration"
|
||||
|
||||
OMV_DIR="${TMP_DIR}/omv-config"
|
||||
mkdir -p "$OMV_DIR"
|
||||
OMV_STATUS="OK"
|
||||
OMV_DETAIL=""
|
||||
|
||||
# omv-confbak is optional — raw config.xml is the primary backup and fully sufficient.
|
||||
if command -v omv-confbak &>/dev/null; then
|
||||
if omv-confbak "${OMV_DIR}/omv-config.xml" 2>/dev/null; then
|
||||
log "omv-confbak completed successfully"
|
||||
OMV_DETAIL="omv-confbak exported + raw config.xml copied"
|
||||
else
|
||||
log "WARNING: omv-confbak failed or returned non-zero — raw config.xml still captured"
|
||||
OMV_DETAIL="omv-confbak failed (non-fatal) + raw config.xml copied"
|
||||
fi
|
||||
else
|
||||
log "omv-confbak not present — using raw config.xml (this is normal)"
|
||||
fi
|
||||
|
||||
if [ -f /etc/openmediavault/config.xml ]; then
|
||||
cp /etc/openmediavault/config.xml "${OMV_DIR}/config.xml.raw"
|
||||
log "Raw OMV config.xml copied"
|
||||
[[ -z "$OMV_DETAIL" ]] && OMV_DETAIL="raw config.xml copied"
|
||||
else
|
||||
log "WARNING: /etc/openmediavault/config.xml not found — OMV config not backed up"
|
||||
OMV_STATUS="WARNING"
|
||||
OMV_DETAIL="config.xml not found at /etc/openmediavault/config.xml"
|
||||
fi
|
||||
|
||||
add_section_row "OMV Configuration" "$OMV_STATUS" "$OMV_DETAIL"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 2: SYSTEM /etc
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Backing up /etc (system configuration)"
|
||||
|
||||
ETC_DIR="${TMP_DIR}/etc"
|
||||
mkdir -p "$ETC_DIR"
|
||||
|
||||
if rsync -a --quiet \
|
||||
--exclude='/etc/mtab' \
|
||||
--exclude='/etc/fstab.d' \
|
||||
--exclude='/etc/blkid.tab' \
|
||||
/etc/ "${ETC_DIR}/" 2>/dev/null; then
|
||||
log "/etc backed up successfully"
|
||||
add_section_row "System /etc" "OK" "rsync completed successfully"
|
||||
else
|
||||
log "WARNING: /etc rsync encountered errors"
|
||||
add_section_row "System /etc" "WARNING" "rsync completed with errors"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 3: DOCKER COMPOSE FILES & GLOBAL ENV
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Backing up Docker Compose files"
|
||||
|
||||
COMPOSE_DIR="${TMP_DIR}/docker-compose"
|
||||
mkdir -p "$COMPOSE_DIR"
|
||||
|
||||
if [ -d "${DOCKER_DIR}/Compose" ]; then
|
||||
if rsync -a --quiet \
|
||||
--exclude='vaultwarden/icon_cache/' \
|
||||
"${DOCKER_DIR}/Compose/" "${COMPOSE_DIR}/" 2>/dev/null; then
|
||||
log "Docker Compose directory backed up (Vaultwarden icon_cache excluded)"
|
||||
# Count compose files for the detail line
|
||||
COMPOSE_COUNT=$(find "${COMPOSE_DIR}" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null | wc -l)
|
||||
add_section_row "Docker Compose Files" "OK" "${COMPOSE_COUNT} compose file(s) · vaultwarden/icon_cache excluded"
|
||||
else
|
||||
log "WARNING: Docker Compose rsync encountered errors"
|
||||
add_section_row "Docker Compose Files" "WARNING" "rsync completed with errors"
|
||||
fi
|
||||
else
|
||||
log "WARNING: ${DOCKER_DIR}/Compose not found — skipping"
|
||||
add_section_row "Docker Compose Files" "WARNING" "${DOCKER_DIR}/Compose not found"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 4: APP DATA (Pi-hole, Plex, Shared)
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Backing up application data"
|
||||
|
||||
APPDATA_DIR="${TMP_DIR}/app-data"
|
||||
mkdir -p "$APPDATA_DIR"
|
||||
APPDATA_WARN=0
|
||||
APPDATA_DETAIL=""
|
||||
|
||||
# Pi-hole
|
||||
if [ -d "${DOCKER_DIR}/pihole" ]; then
|
||||
if rsync -a --quiet "${DOCKER_DIR}/pihole/" "${APPDATA_DIR}/pihole/" 2>/dev/null; then
|
||||
log "Pi-hole data backed up"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole ✓"
|
||||
else
|
||||
log "WARNING: Pi-hole rsync encountered errors"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole ⚠"
|
||||
APPDATA_WARN=$((APPDATA_WARN + 1))
|
||||
fi
|
||||
else
|
||||
log "WARNING: ${DOCKER_DIR}/pihole not found — skipping"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole —"
|
||||
fi
|
||||
|
||||
# Plex (Cache, Logs, Crash Reports excluded)
|
||||
if [ -d "${DOCKER_DIR}/plex" ]; then
|
||||
if rsync -a --quiet \
|
||||
--exclude='Plex Media Server/Cache/' \
|
||||
--exclude='Plex Media Server/Logs/' \
|
||||
--exclude='Plex Media Server/Crash Reports/' \
|
||||
"${DOCKER_DIR}/plex/" "${APPDATA_DIR}/plex/" 2>/dev/null; then
|
||||
log "Plex data backed up (Cache, Logs, Crash Reports excluded)"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} · Plex ✓"
|
||||
else
|
||||
log "WARNING: Plex rsync encountered errors"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} · Plex ⚠"
|
||||
APPDATA_WARN=$((APPDATA_WARN + 1))
|
||||
fi
|
||||
else
|
||||
log "WARNING: ${DOCKER_DIR}/plex not found — skipping"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} · Plex —"
|
||||
fi
|
||||
|
||||
# Shared folder
|
||||
if [ -d "${DOCKER_DIR}/Shared" ]; then
|
||||
if rsync -a --quiet "${DOCKER_DIR}/Shared/" "${APPDATA_DIR}/shared/" 2>/dev/null; then
|
||||
log "Shared folder backed up"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} · Shared ✓"
|
||||
else
|
||||
log "WARNING: Shared rsync encountered errors"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} · Shared ⚠"
|
||||
APPDATA_WARN=$((APPDATA_WARN + 1))
|
||||
fi
|
||||
else
|
||||
log "WARNING: ${DOCKER_DIR}/Shared not found — skipping"
|
||||
APPDATA_DETAIL="${APPDATA_DETAIL} · Shared —"
|
||||
fi
|
||||
|
||||
if [ "$APPDATA_WARN" -eq 0 ]; then
|
||||
add_section_row "App Data (Pi-hole / Plex / Shared)" "OK" "${APPDATA_DETAIL# }"
|
||||
else
|
||||
add_section_row "App Data (Pi-hole / Plex / Shared)" "WARNING" "$APPDATA_WARN item(s) missing or failed ·${APPDATA_DETAIL}"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 5: CRONTAB & SCHEDULED JOBS
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Backing up crontab and scheduled jobs"
|
||||
|
||||
CRON_DIR="${TMP_DIR}/cron"
|
||||
mkdir -p "$CRON_DIR"
|
||||
CRON_WARN=0
|
||||
|
||||
crontab -l > "${CRON_DIR}/root-crontab.txt" 2>/dev/null \
|
||||
&& log "Root crontab saved" \
|
||||
|| { log "No root crontab found"; CRON_WARN=$((CRON_WARN + 1)); }
|
||||
|
||||
if [ -d /var/spool/cron/crontabs ]; then
|
||||
cp -r /var/spool/cron/crontabs/ "${CRON_DIR}/crontabs/" \
|
||||
&& log "All crontabs copied" \
|
||||
|| { log "WARNING: crontabs copy failed"; CRON_WARN=$((CRON_WARN + 1)); }
|
||||
fi
|
||||
|
||||
if [ -d /etc/cron.d ]; then
|
||||
cp -r /etc/cron.d/ "${CRON_DIR}/cron.d/" \
|
||||
&& log "cron.d copied" \
|
||||
|| { log "WARNING: cron.d copy failed"; CRON_WARN=$((CRON_WARN + 1)); }
|
||||
fi
|
||||
|
||||
if [ "$CRON_WARN" -eq 0 ]; then
|
||||
add_section_row "Crontab / Scheduled Jobs" "OK" "root crontab, crontabs/, cron.d/ saved"
|
||||
else
|
||||
add_section_row "Crontab / Scheduled Jobs" "WARNING" "$CRON_WARN item(s) missing or failed to copy"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 6: BACKUP MANIFEST
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Generating backup manifest"
|
||||
|
||||
OMV_VER=$(dpkg -l openmediavault 2>/dev/null | awk '/^ii/{print $3}' || echo "Unknown")
|
||||
KERNEL_VER=$(uname -r)
|
||||
HOST_IP=$(hostname -I | awk '{print $1}')
|
||||
DOCKER_VER=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || echo "Unknown")
|
||||
|
||||
MANIFEST="${TMP_DIR}/MANIFEST.txt"
|
||||
cat > "$MANIFEST" <<MANIFEST_EOF
|
||||
=============================================================
|
||||
NAS08 BACKUP MANIFEST
|
||||
=============================================================
|
||||
Hostname: $(hostname)
|
||||
Date: $(date)
|
||||
Kernel: ${KERNEL_VER}
|
||||
OMV Version: ${OMV_VER}
|
||||
Docker: ${DOCKER_VER}
|
||||
IP Address: ${HOST_IP}
|
||||
|
||||
CONTENTS:
|
||||
omv-config/ - OMV exported config + raw config.xml
|
||||
etc/ - Full /etc system configuration
|
||||
docker-compose/ - All Docker Compose files and global.env
|
||||
app-data/ - Pi-hole, Plex, Shared app data
|
||||
cron/ - Root crontab and scheduled jobs
|
||||
MANIFEST.txt - This file
|
||||
|
||||
RESTORE NOTES:
|
||||
1. Flash fresh OMV to SD card
|
||||
2. SSH in and restore /etc from etc/ folder
|
||||
3. Import OMV config via omv-confbak or config.xml.raw
|
||||
4. Restore Docker Compose files to /export/kingdezigns-all/Docker/Compose/
|
||||
5. Restore app data to /export/kingdezigns-all/Docker/
|
||||
6. Recreate containers: docker compose up -d (in each compose folder)
|
||||
7. Restore crontab: crontab cron/root-crontab.txt
|
||||
=============================================================
|
||||
MANIFEST_EOF
|
||||
|
||||
log "Manifest written"
|
||||
add_section_row "Backup Manifest" "OK" "MANIFEST.txt written to archive"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 7: CREATE ARCHIVE
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Creating compressed archive"
|
||||
|
||||
cd "$TMP_BASE"
|
||||
if tar -czf "${BACKUP_ROOT}/${ARCHIVE_NAME}" -C "$TMP_BASE" "$(basename "$TMP_DIR")" 2>/dev/null; then
|
||||
log "Archive created: ${BACKUP_ROOT}/${ARCHIVE_NAME}"
|
||||
ARCHIVE_SIZE=$(du -sh "${BACKUP_ROOT}/${ARCHIVE_NAME}" | cut -f1)
|
||||
log "Archive size: ${ARCHIVE_SIZE}"
|
||||
add_section_row "Archive Creation" "OK" "${ARCHIVE_NAME} · ${ARCHIVE_SIZE}"
|
||||
else
|
||||
log "ERROR: Archive creation failed"
|
||||
ARCHIVE_SIZE="N/A"
|
||||
add_section_row "Archive Creation" "ERROR" "tar failed — archive not created"
|
||||
rm -rf "$TMP_DIR"
|
||||
OVERALL_STATUS="FAILED"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 8: CLEANUP TEMP FILES
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Cleaning up temporary files"
|
||||
|
||||
if rm -rf "$TMP_DIR" 2>/dev/null; then
|
||||
log "Temporary directory removed"
|
||||
else
|
||||
log "WARNING: Failed to remove temp directory: $TMP_DIR"
|
||||
fi
|
||||
rmdir "$TMP_BASE" 2>/dev/null && log ".tmp directory removed (empty)" || true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 9: ROTATE OLD BACKUPS
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Rotating old backups (keeping last ${RETAIN_COUNT})"
|
||||
|
||||
BACKUP_LIST=$(ls -1t "${BACKUP_ROOT}"/nas08-backup-*.tar.gz 2>/dev/null || true)
|
||||
BACKUP_COUNT=$(echo "$BACKUP_LIST" | grep -c . || true)
|
||||
DELETED_COUNT=0
|
||||
|
||||
if [ "$BACKUP_COUNT" -gt "$RETAIN_COUNT" ]; then
|
||||
DELETE_LIST=$(echo "$BACKUP_LIST" | tail -n +$((RETAIN_COUNT + 1)))
|
||||
while IFS= read -r OLD_BACKUP; do
|
||||
rm -f "$OLD_BACKUP"
|
||||
log "Removed old backup: $(basename "$OLD_BACKUP")"
|
||||
DELETED_COUNT=$((DELETED_COUNT + 1))
|
||||
done <<< "$DELETE_LIST"
|
||||
add_section_row "Backup Rotation" "OK" "Kept $RETAIN_COUNT most recent · removed $DELETED_COUNT old archive(s)"
|
||||
else
|
||||
log "Backup count (${BACKUP_COUNT}) within retention limit — no rotation needed"
|
||||
add_section_row "Backup Rotation" "OK" "$BACKUP_COUNT / $RETAIN_COUNT slots used — no rotation needed"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# DONE — log summary
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Backup Complete"
|
||||
log "Archive: ${BACKUP_ROOT}/${ARCHIVE_NAME}"
|
||||
log "Size: ${ARCHIVE_SIZE:-N/A}"
|
||||
log "Status: ${OVERALL_STATUS}"
|
||||
log ""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# BUILD & SEND HTML EMAIL (pattern: nas08_zfs_scrub.sh)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Overall banner & badge
|
||||
case "$OVERALL_STATUS" in
|
||||
SUCCESS)
|
||||
BADGE_HTML="<div style=\"background:#1a7f4b;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">✅ SUCCESS</div>"
|
||||
BANNER_HTML="<tr><td style=\"background:#1a7f4b;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">✅ Backup completed successfully — no action required.</span></td></tr>"
|
||||
;;
|
||||
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;\">⚠️ Backup completed with warnings — review recommended.</span></td></tr>"
|
||||
;;
|
||||
FAILED)
|
||||
BADGE_HTML="<div style=\"background:#b91c1c;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">🚨 FAILED</div>"
|
||||
BANNER_HTML="<tr><td style=\"background:#b91c1c;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">🚨 Backup failed — immediate attention required!</span></td></tr>"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Archive info card — only shown when archive was created
|
||||
if [[ "${ARCHIVE_SIZE:-N/A}" != "N/A" ]]; then
|
||||
ARCHIVE_CARD="
|
||||
<tr><td style=\"padding:0 24px 8px;\">
|
||||
<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:12px;text-transform:uppercase;letter-spacing:.5px;\">🗜️ Archive</div>
|
||||
<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">
|
||||
<tr>
|
||||
<td style=\"width:50%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
|
||||
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Filename</div>
|
||||
<div style=\"font-family:monospace;font-size:13px;font-weight:600;color:#111827;margin-top:4px;\">$ARCHIVE_NAME</div>
|
||||
</td>
|
||||
<td style=\"width:4%;\"></td>
|
||||
<td style=\"width:20%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
|
||||
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Size</div>
|
||||
<div style=\"font-size:20px;font-weight:700;color:#111827;margin-top:2px;\">$ARCHIVE_SIZE</div>
|
||||
</td>
|
||||
<td style=\"width:4%;\"></td>
|
||||
<td style=\"width:20%;padding:8px 12px;background:#f9fafb;border-radius:6px;text-align:center;\">
|
||||
<div style=\"font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;\">Retained</div>
|
||||
<div style=\"font-size:20px;font-weight:700;color:#111827;margin-top:2px;\">$BACKUP_COUNT / $RETAIN_COUNT</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>"
|
||||
else
|
||||
ARCHIVE_CARD=""
|
||||
fi
|
||||
|
||||
# System versions card
|
||||
VERSIONS_CARD="
|
||||
<tr><td style=\"padding:0 24px 8px;\">
|
||||
<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:12px;text-transform:uppercase;letter-spacing:.5px;\">🖥️ System Versions</div>
|
||||
<table width=\"100%\" cellspacing=\"0\" cellpadding=\"0\">
|
||||
<tr>
|
||||
<td style=\"font-size:13px;color:#6b7280;padding:4px 0;\">OMV</td>
|
||||
<td style=\"font-size:13px;color:#111827;font-weight:600;\">${OMV_VER}</td>
|
||||
<td style=\"font-size:13px;color:#6b7280;padding:4px 0;\">Kernel</td>
|
||||
<td style=\"font-size:13px;color:#111827;font-weight:600;\">${KERNEL_VER}</td>
|
||||
</tr>
|
||||
<tr><td colspan=\"4\" style=\"height:4px;\"></td></tr>
|
||||
<tr>
|
||||
<td style=\"font-size:13px;color:#6b7280;padding:4px 0;\">Docker</td>
|
||||
<td style=\"font-size:13px;color:#111827;font-weight:600;\">${DOCKER_VER}</td>
|
||||
<td style=\"font-size:13px;color:#6b7280;padding:4px 0;\">IP Address</td>
|
||||
<td style=\"font-size:13px;color:#111827;font-weight:600;font-family:monospace;\">${HOST_IP}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>"
|
||||
|
||||
# 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;">System Backup Monitor</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;">$REPORT_TIME | 7 sections · $WARNING_COUNT warning(s) · $ERROR_COUNT error(s)</div>
|
||||
</td>
|
||||
<td align="right">$BADGE_HTML</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<!-- Status banner -->
|
||||
$BANNER_HTML
|
||||
|
||||
<!-- Section results table -->
|
||||
<tr><td style="padding:28px 24px 8px;">
|
||||
<div style="background:white;border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.10);overflow:hidden;border:1px solid #e5e7eb;">
|
||||
<div style="background:#f8fafc;padding:14px 22px;border-bottom:1px solid #e5e7eb;">
|
||||
<span style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;">📋 Backup Section Results</span>
|
||||
</div>
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr style="background:#f9fafb;">
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;width:30%;">Section</th>
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;width:15%;">Status</th>
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;">Detail</th>
|
||||
</tr>
|
||||
$SECTION_ROWS_HTML
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<!-- Archive info card -->
|
||||
$ARCHIVE_CARD
|
||||
|
||||
<!-- System versions card -->
|
||||
$VERSIONS_CARD
|
||||
|
||||
<!-- Run 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;">📊 Run 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_LABEL</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;">Run 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;">Archive</td>
|
||||
<td style="font-family:monospace;font-size:13px;color:#111827;font-weight:600;">${ARCHIVE_NAME}</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;">Archive Size</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">${ARCHIVE_SIZE:-N/A}</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;">Backups on Disk</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">$BACKUP_COUNT of $RETAIN_COUNT max retained</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;">Warnings</td>
|
||||
<td style="font-size:13px;font-weight:700;color:$([ "$WARNING_COUNT" -gt 0 ] && echo "#b45309" || echo "#1a7f4b");">$WARNING_COUNT</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;">Errors</td>
|
||||
<td style="font-size:13px;font-weight:700;color:$([ "$ERROR_COUNT" -gt 0 ] && echo "#b91c1c" || echo "#1a7f4b");">$ERROR_COUNT</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:$(
|
||||
case "$OVERALL_STATUS" in
|
||||
SUCCESS) echo "#1a7f4b" ;;
|
||||
WARNING) echo "#b45309" ;;
|
||||
*) echo "#b91c1c" ;;
|
||||
esac
|
||||
);">$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 Backup Monitor | $HOSTNAME_LABEL | KingDezigns Infrastructure</span>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
)
|
||||
|
||||
# Send email via OMV Postfix (Gmail relay)
|
||||
SUBJECT="[Backup] $HOSTNAME_LABEL — $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 "===== NAS08 Backup script finished ====="
|
||||
682
NAS08-Kernel-Recovery-Summary.html
Normal file
682
NAS08-Kernel-Recovery-Summary.html
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KingDezigns — NAS08 Kernel Recovery Summary</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); }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.script-box { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.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 Kernel Recovery Summary</h1>
|
||||
<div class="meta">
|
||||
<span>📅 May 15–16, 2026</span>
|
||||
<span>🖥 NAS08 — Raspberry Pi 5 Model B Rev 1.1</span>
|
||||
<span>⚙️ OpenMediaVault 8.2.13 / Debian Trixie</span>
|
||||
<span>🔌 Radxa Penta SATA HAT (JMB585)</span>
|
||||
<span>🐧 Kernel 6.12.75 → 6.18.29 → rolled back → 6.18.29 (fixed)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- SECTION 1 — THE PROBLEM -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="1">The problem</div>
|
||||
|
||||
<p>After a routine apt upgrade that included a kernel jump from 6.12 to 6.18 and a new <code>raspi-firmware</code> package, NAS08 lost all four SATA data drives connected via the Radxa Penta SATA HAT. ZFS reported no pools available and OMV could not see the drives.</p>
|
||||
|
||||
<div class="badge-row">
|
||||
<span class="badge red">zpool status: no pools available</span>
|
||||
<span class="badge red">ahci: failed to start port 0 (errno=-12)</span>
|
||||
<span class="badge red">lsblk: sdb/sdc/sdd/sde missing</span>
|
||||
<span class="badge red">BAR 0 [io size 0x0080]: can't assign; no space</span>
|
||||
<span class="badge amber">DMA32: empty</span>
|
||||
</div>
|
||||
|
||||
<p>The upgrade included <code>linux-image-6.18.29+rpt-rpi-2712</code>, <code>raspi-firmware 1:1.20260513-1</code>, and <code>openmediavault 8.2.13-1</code> among 20+ packages. The Radxa Penta SATA HAT uses a JMicron JMB585 PCIe SATA controller. An upstream kernel commit (ee95f3c) affecting the JMB585 chipset means kernel 6.18 now requires the <code>pcie-32bit-dma-pi5</code> device tree overlay for the controller to allocate DMA memory successfully. This overlay was present in <code>config.txt</code> but placed under the <code>[cm5]</code> conditional section, which is ignored on a Raspberry Pi 5 Model B. As a result, the overlay never loaded and the JMB585 had no DMA32 address space.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 2 — DIAGNOSTIC COMMANDS -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="2">Diagnostic commands run & why</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Confirm ZFS module is loaded</div>
|
||||
<pre>lsmod | grep zfs</pre>
|
||||
<p class="why">Rule out ZFS module as the cause — if it's not loaded, drives won't be visible to any pool.</p>
|
||||
<p class="result pass">ZFS module loaded correctly — zfs and spl both present. Not the cause.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Check running kernel vs installed kernels</div>
|
||||
<pre>uname -r
|
||||
dpkg -l | grep linux-image</pre>
|
||||
<p class="why">Confirm which kernel booted and whether a version mismatch caused the issue.</p>
|
||||
<p class="result neutral">Booted into 6.18.29 — confirmed the kernel jump from 6.12 happened. Both versions still installed.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Check if OS sees the drives at all</div>
|
||||
<pre>lsblk
|
||||
ls /dev/disk/by-id/</pre>
|
||||
<p class="why">Determine if the problem is at the ZFS layer or the OS/hardware layer.</p>
|
||||
<p class="result fail">Only boot SSD visible. Data drives completely absent — hardware/driver level problem confirmed.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">4</span> Check dmesg for USB/SATA/PCIe errors</div>
|
||||
<pre>dmesg | grep -iE "usb|sd|storage|attach|disconnect|reset" | grep -v "loop\|zram\|firmware" | tail -60</pre>
|
||||
<p class="why">Identify whether drives were seen at boot and then lost, or never detected at all.</p>
|
||||
<p class="result fail">Only one USB device detected at boot (boot SSD). No SATA drives enumerated. Pointed to PCIe HAT issue.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">5</span> Check PCIe controller visibility</div>
|
||||
<pre>lspci</pre>
|
||||
<p class="why">Confirm whether the JMicron JMB585 SATA controller on the Radxa HAT is being detected by the PCIe bus.</p>
|
||||
<p class="result neutral">JMicron JMB585 visible at 0001:01:00.0 — controller detected but not functional.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">6</span> Check PCIe BAR assignment errors</div>
|
||||
<pre>dmesg | grep -iE "pcie|pci|ahci|sata|asm|jmb|asmedia" | head -40</pre>
|
||||
<p class="why">Identify why the detected controller isn't working — look for resource allocation failures.</p>
|
||||
<p class="result fail">BAR 0–4 all failed to assign I/O space. PCI bridge showed zero-length address range. AHCI errno=-12 (ENOMEM).</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">7</span> Check DMA memory layout</div>
|
||||
<pre>cat /proc/meminfo | grep -i cma
|
||||
dmesg | grep -iE "cma|dma|coherent" | head -20</pre>
|
||||
<p class="why">Determine why AHCI port start was failing with ENOMEM — check DMA zone availability.</p>
|
||||
<p class="result fail">DMA32 zone empty. CmaFree: 0 kB. JMB585 forced into 32-bit DMA mode with no available DMA32 address space.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">8</span> Check hardware identity</div>
|
||||
<pre>cat /proc/device-tree/model</pre>
|
||||
<p class="why">Confirm exact hardware model — critical for applying the correct config.txt overlay section.</p>
|
||||
<p class="result neutral">Raspberry Pi 5 Model B Rev 1.1 — confirmed Pi 5, not CM5. This meant the [cm5] section in config.txt was being ignored.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">9</span> Check config.txt overlay placement</div>
|
||||
<pre>cat /boot/firmware/config.txt</pre>
|
||||
<p class="why">Identify whether the pcie-32bit-dma-pi5 overlay was present and in the correct conditional section.</p>
|
||||
<p class="result fail">Overlay was present but under [cm5] section — ignored on Pi 5. Moving it to [all] section was the fix.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">10</span> Check EEPROM version and config</div>
|
||||
<pre>sudo rpi-eeprom-update
|
||||
sudo rpi-eeprom-config</pre>
|
||||
<p class="why">Determine if pci=pcie_bus_safe was coming from the EEPROM bootloader config.</p>
|
||||
<p class="result neutral">EEPROM was 5 months out of date. Updated during recovery session.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 3 — ROOT CAUSE -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="3">Root cause</div>
|
||||
|
||||
<div class="callout coral">
|
||||
<p>An upstream Linux kernel commit (ee95f3c) changed how the JMB585 PCIe SATA controller allocates DMA memory on kernel 6.18. The JMB585 now requires the <code>pcie-32bit-dma-pi5</code> device tree overlay to function correctly. This overlay was present in <code>/boot/firmware/config.txt</code> but placed under the <code>[cm5]</code> conditional section, which is silently ignored on a Raspberry Pi 5 Model B. As a result the overlay never loaded, the JMB585 had no DMA32 address space, and AHCI failed with ENOMEM on every port start attempt.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout amber">
|
||||
<div class="clue-label">⚠ Key clue</div>
|
||||
<p style="margin-bottom:6px;">The config.txt overlay section mismatch was the root cause. The overlay fix was correct — it was simply applied to the wrong conditional block during the initial recovery attempt.</p>
|
||||
<pre># Wrong — ignored on Pi 5 Model B:
|
||||
[cm5]
|
||||
dtoverlay=pcie-32bit-dma-pi5
|
||||
|
||||
# Correct — applies to all Pi hardware:
|
||||
[all]
|
||||
dtoverlay=pcie-32bit-dma-pi5</pre>
|
||||
<p style="margin-top:8px;">The kernel 6.18 requirement for <code>pcie-32bit-dma-pi5</code> on JMB585 hardware is documented upstream. Any Pi 5 running kernel 6.18 with this HAT requires this overlay in the <code>[all]</code> or <code>[pi5]</code> section of config.txt.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout green" style="margin-top:0.75rem;">
|
||||
<p><strong>Resolution:</strong> Moved <code>dtoverlay=pcie-32bit-dma-pi5</code> from the <code>[cm5]</code> section to the <code>[all]</code> section in <code>/boot/firmware/config.txt</code>, then copied the 6.18.29 kernel binary back to <code>/boot/firmware/kernel_2712.img</code> and rebooted. All four SATA drives appeared immediately on kernel 6.18.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 4 — RECOVERY PROCEDURE -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="4">Recovery procedure applied</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Roll back to kernel 6.12 (initial emergency recovery)</div>
|
||||
<pre>sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
|
||||
sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-2712 /boot/firmware/kernel_2712.img
|
||||
sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-v8 /boot/firmware/kernel8.img
|
||||
sudo reboot</pre>
|
||||
<p class="why">Immediately restored drive visibility on 6.12 while the root cause was investigated. Holds prevented the firmware hook from re-upgrading kernel_2712.img on reboot.</p>
|
||||
<p class="result pass">All four drives (sdb/sdc/sdd/sde) returned on 6.12.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Fix the overlay placement in config.txt</div>
|
||||
<pre>sudo nano /boot/firmware/config.txt
|
||||
|
||||
# Remove dtoverlay=pcie-32bit-dma-pi5 from the [cm5] section
|
||||
# Add it to the [all] section instead:
|
||||
|
||||
[cm5]
|
||||
dtoverlay=dwc2,dr_mode=host
|
||||
|
||||
[all]
|
||||
dtoverlay=pcie-32bit-dma-pi5</pre>
|
||||
<p class="why">The overlay was present but in the wrong conditional section. Moving it to [all] ensures it applies to the Pi 5 Model B hardware.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Unhold packages and copy 6.18 kernel back</div>
|
||||
<pre>sudo apt-mark unhold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
|
||||
sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-2712 /boot/firmware/kernel_2712.img
|
||||
sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-v8 /boot/firmware/kernel8.img
|
||||
sudo reboot</pre>
|
||||
<p class="why">6.18 was already installed — only the kernel_2712.img binary needed updating. The overlay fix was now in place before boot.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">4</span> Verify and re-apply holds</div>
|
||||
<pre>uname -r # should show 6.18.29+rpt-rpi-2712
|
||||
lsblk # should show sdb sdc sdd sde
|
||||
sudo zpool status
|
||||
|
||||
sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8
|
||||
sudo apt-mark showhold</pre>
|
||||
<p class="result pass">All four drives present on 6.18.29. ZFS pool ONLINE. Holds re-applied to prevent uncontrolled kernel upgrades.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 5 — EMERGENCY ROLLBACK -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="5">Emergency rollback — if a future upgrade breaks drives again</div>
|
||||
|
||||
<p>If a future upgrade causes drives to disappear again, this is the fast rollback procedure. Takes less than 5 minutes.</p>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Hold packages immediately</div>
|
||||
<pre>sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8</pre>
|
||||
<p class="why">The z50-raspi-firmware hook overwrites kernel_2712.img on every apt operation. Hold first to stop this.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Copy 6.12 kernel directly over the firmware kernel image</div>
|
||||
<pre>sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-2712 /boot/firmware/kernel_2712.img
|
||||
sudo cp /boot/vmlinuz-6.12.75+rpt-rpi-v8 /boot/firmware/kernel8.img
|
||||
|
||||
# Verify sizes — should be 9698043 and 9695883
|
||||
ls -la /boot/firmware/kernel_2712.img /boot/firmware/kernel8.img</pre>
|
||||
<p class="why">raspi-firmware boots whichever kernel is in kernel_2712.img directly. Replacing it with 6.12 forces the rollback.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Reboot and confirm</div>
|
||||
<pre>sudo reboot
|
||||
|
||||
# After reboot:
|
||||
uname -r # should show 6.12.75+rpt-rpi-2712
|
||||
lsblk # should show sdb sdc sdd sde
|
||||
sudo zpool status</pre>
|
||||
<p class="result pass">Drives and ZFS pools return immediately on 6.12.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 6 — SCRIPTS CREATED -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="6">Scripts created this session</div>
|
||||
<div class="script-box">
|
||||
<div class="script-row">
|
||||
<div class="script-label">NAS08 ZFS Health Report</div>
|
||||
<pre>zfs_report_nas08.sh</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">NAS16 ZFS Health Report</div>
|
||||
<pre>zfs_report_nas16.sh</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Commands executed per run</div>
|
||||
<pre>zpool status -v <pool>
|
||||
zpool list -H -o name,size,alloc,free,frag,cap,health <pool>
|
||||
zfs list -r -H -o name,used,avail,refer,mountpoint <pool></pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Deploy & schedule (cron)</div>
|
||||
<pre>chmod +x zfs_report_nas08.sh
|
||||
sudo mv zfs_report_nas08.sh /opt/scripts/
|
||||
# Add to crontab — runs daily at 7:00 AM:
|
||||
0 7 * * * /opt/scripts/zfs_report_nas08.sh >> /var/log/zfs_report.log 2>&1</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Email recipient</div>
|
||||
<pre>rufus.king@kingdezigns.com</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 7 — NAS16 PROTECTION -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="7">NAS16 — apply same fix</div>
|
||||
|
||||
<p>NAS16 runs the identical Radxa Penta SATA HAT on a Raspberry Pi 5. It will hit the exact same kernel 6.18 / JMB585 DMA issue if updated without the overlay fix in place. SSH into NAS16 and follow these steps.</p>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Hold kernel and firmware packages on NAS16</div>
|
||||
<pre>sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8
|
||||
sudo apt-mark showhold</pre>
|
||||
<p class="why">Prevents kernel 6.18 and new raspi-firmware from installing automatically while the config.txt fix is applied.</p>
|
||||
<p class="result pass">Expected: all packages listed as held.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Confirm hardware identity</div>
|
||||
<pre>cat /proc/device-tree/model</pre>
|
||||
<p class="why">Verify NAS16 is also a Pi 5 Model B so the [all] section placement is correct.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Fix the overlay placement in config.txt</div>
|
||||
<pre>sudo nano /boot/firmware/config.txt
|
||||
|
||||
# Ensure pcie-32bit-dma-pi5 is in the [all] section:
|
||||
[all]
|
||||
dtoverlay=pcie-32bit-dma-pi5</pre>
|
||||
<p class="why">Same fix as NAS08 — overlay must be in [all], not [cm5], to apply on Pi 5 hardware.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">4</span> Unhold, upgrade, and reboot</div>
|
||||
<pre>sudo apt-mark unhold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8
|
||||
sudo apt upgrade
|
||||
sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-2712 /boot/firmware/kernel_2712.img
|
||||
sudo cp /boot/vmlinuz-6.18.29+rpt-rpi-v8 /boot/firmware/kernel8.img
|
||||
sudo reboot</pre>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">5</span> Verify and re-hold</div>
|
||||
<pre>uname -r
|
||||
lsblk
|
||||
sudo zpool status
|
||||
sudo apt-mark hold raspi-firmware linux-image-rpi-2712 linux-image-rpi-v8 linux-image-6.18.29+rpt-rpi-2712 linux-image-6.18.29+rpt-rpi-v8</pre>
|
||||
<p class="result pass">Expected: sdb sdc sdd sde all present, uname shows 6.18.x, ZFS ONLINE.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 8 — LESSONS LEARNED -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="8">Lessons learned</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Lesson</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Always identify exact hardware before troubleshooting</td>
|
||||
<td>Run <code>cat /proc/device-tree/model</code> at the start of every session. The initial recovery misidentified NAS08 as a CM5, which placed the overlay fix under [cm5] where it was silently ignored on the Pi 5 Model B.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Never update both NAS systems at the same time</td>
|
||||
<td>Update NAS08 first, confirm stable for 48hrs, then update NAS16. This approach would have prevented both systems from failing simultaneously.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>config.txt conditional sections are silently ignored</td>
|
||||
<td>Overlays placed under [cm4] or [cm5] are completely ignored on Pi 5 Model B hardware. There is no warning or error. Always verify placement with <code>cat /proc/device-tree/model</code> first.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kernel 6.18 requires pcie-32bit-dma-pi5 for JMB585</td>
|
||||
<td>Upstream commit ee95f3c changed DMA behaviour for the JMB585 chipset. Any Pi 5 running kernel 6.18 with the Radxa Penta SATA HAT must have <code>dtoverlay=pcie-32bit-dma-pi5</code> in the <code>[all]</code> section of config.txt or drives will not appear.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Hold kernel packages on all Pi-based NAS systems</td>
|
||||
<td>Kernel updates can silently break HAT compatibility. Always hold and update deliberately, never via unattended-upgrades.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>raspi-firmware manages kernel_2712.img directly</td>
|
||||
<td>The post-install hook z50-raspi-firmware copies the kernel directly to /boot/firmware/kernel_2712.img. Copying an older kernel binary over this file is the correct rollback mechanism. Hold raspi-firmware before doing so or it will be overwritten on next boot.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Always simulate before upgrading</td>
|
||||
<td>Run <code>sudo apt upgrade --simulate 2>&1 | grep -E "raspi-firmware|linux-image|linux-headers|Inst|Remv"</code> before every upgrade. If any kernel or firmware packages appear as Inst lines, investigate before proceeding.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 9 — CONFIG FILE REFERENCE -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="9">Current working config — NAS08</div>
|
||||
|
||||
<p>This is the known-good configuration for NAS08 on kernel 6.18.29 with all four SATA drives visible and ZFS healthy.</p>
|
||||
|
||||
<div class="script-box">
|
||||
<div class="script-row">
|
||||
<div class="script-label">/boot/firmware/config.txt — bottom section</div>
|
||||
<pre># Enable PCIe
|
||||
dtparam=pciex1
|
||||
dtparam=pciex1_gen=3
|
||||
|
||||
[cm4]
|
||||
otg_mode=1
|
||||
|
||||
[cm5]
|
||||
dtoverlay=dwc2,dr_mode=host
|
||||
|
||||
[all]
|
||||
dtoverlay=pcie-32bit-dma-pi5</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">/boot/firmware/cmdline.txt</div>
|
||||
<pre>console=serial0,115200 console=tty1 root=PARTUUID=57cb028e-02 rootfstype=ext4 fsck.repair=yes rootwait cfg80211.ieee80211_regdom=US</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Active kernel</div>
|
||||
<pre>6.18.29+rpt-rpi-2712</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Held packages</div>
|
||||
<pre>raspi-firmware
|
||||
linux-image-rpi-2712
|
||||
linux-image-rpi-v8
|
||||
linux-image-6.18.29+rpt-rpi-2712
|
||||
linux-image-6.18.29+rpt-rpi-v8</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 10 — NETWORK MAP -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="10">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>NAS08 kernel version</td>
|
||||
<td><code>6.12.75+rpt-rpi-2712</code></td>
|
||||
<td><code>6.18.29+rpt-rpi-2712 (held)</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 config.txt overlay</td>
|
||||
<td><code>pcie-32bit-dma-pi5 under [cm5] — ignored</code></td>
|
||||
<td><code>pcie-32bit-dma-pi5 under [all] — active</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS08 raspi-firmware</td>
|
||||
<td><code>auto-update enabled</code></td>
|
||||
<td><code>held at 1:1.20260513-1</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NAS16 kernel + overlay fix</td>
|
||||
<td><code>6.12.75 — pending upgrade</code></td>
|
||||
<td><code>apply config.txt fix first, then upgrade to 6.18.29</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ZFS report scripts</td>
|
||||
<td><code>not deployed</code></td>
|
||||
<td><code>zfs_report_nas08.sh + zfs_report_nas16.sh — deploy to /opt/scripts/</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="footer">
|
||||
<span>KingDezigns Home Network</span>
|
||||
<span>Updated May 16, 2026</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
235
Stocks/StockFunctions.bas
Normal file
235
Stocks/StockFunctions.bas
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
' =========================================================================
|
||||
' StockFunctions.bas
|
||||
' Collabora / LibreOffice Basic port of the ONLYOFFICE custom functions.
|
||||
'
|
||||
' UPDATED 2026-07-23: STOCKPRICE / STOCKTIME (Finnhub) removed.
|
||||
' Finnhub was dropped because it has no historical price coverage, which is
|
||||
' why stockproxy (NAS08) was built in the first place. FUNDPRICE already
|
||||
' works for any ticker stockproxy knows about, fund or stock, so it now
|
||||
' doubles as the equity price function. FUNDTIME replaces STOCKTIME and
|
||||
' returns stockproxy's "resolved_date" field.
|
||||
'
|
||||
' NOTE: resolved_date is a DATE (YYYY-MM-DD), not a time-of-day timestamp.
|
||||
' Finnhub's old STOCKTIME gave true time-of-day resolution; this does not.
|
||||
' If intraday precision is ever needed, stockproxy's /current endpoint
|
||||
' would need a real timestamp field added on the backend side.
|
||||
'
|
||||
' HOW TO INSTALL IN COLLABORA:
|
||||
' 1. Open the sheet in Collabora (collabora.kingdezigns.com).
|
||||
' 2. Tools > Macros > Edit Macros (opens the Basic IDE).
|
||||
' 3. In the tree, find "<Your Document Name>" (NOT "My Macros" -
|
||||
' you want this stored IN the document so it travels with the file).
|
||||
' 4. Right-click "Standard" under your document > Insert > Module.
|
||||
' 5. Paste this entire file's contents into the new module.
|
||||
' (Or, if StockFunctions already exists, replace its contents.)
|
||||
' 6. Fill in PROXY_SECRET() below with the real value if not already set.
|
||||
' 7. Save the document (Ctrl+S). Macro security must allow this
|
||||
' document's macros to run - if prompted, allow them.
|
||||
'
|
||||
' USAGE IN CELLS:
|
||||
' =FUNDPRICE(D4)
|
||||
' =FUNDPRICE_HIST(D4, TEXT($B$3,"yyyy-mm-dd"))
|
||||
' =FUNDPRICE(A5) <- replaces old =STOCKPRICE(A5)
|
||||
' =FUNDTIME(A5) <- replaces old =STOCKTIME(A5)
|
||||
' =========================================================================
|
||||
|
||||
Option Explicit
|
||||
|
||||
' ---- Config -------------------------------------------------------------
|
||||
|
||||
Function PROXY_BASE() As String
|
||||
PROXY_BASE = "https://stocks.kingdezigns.com"
|
||||
End Function
|
||||
|
||||
Function PROXY_SECRET() As String
|
||||
PROXY_SECRET = "90c2528e9b5221c110f7c2c9cd6c65dc" ' <-- from Vaultwarden
|
||||
End Function
|
||||
|
||||
' ---- Public spreadsheet functions ---------------------------------------
|
||||
|
||||
Function FUNDPRICE(ticker As String) As Variant
|
||||
Dim sUrl As String, sJson As String, vPrice As Variant
|
||||
sUrl = PROXY_BASE() & "/current?ticker=" & EncodeUrl(ticker) & "&key=" & PROXY_SECRET()
|
||||
sJson = HttpGetText(sUrl)
|
||||
If sJson = "" Then
|
||||
FUNDPRICE = "#ERROR"
|
||||
Exit Function
|
||||
End If
|
||||
vPrice = JsonNumber(sJson, "price")
|
||||
If IsNull(vPrice) Then
|
||||
FUNDPRICE = "#N/A"
|
||||
Else
|
||||
FUNDPRICE = vPrice
|
||||
End If
|
||||
End Function
|
||||
|
||||
Function FUNDPRICE_HIST(ticker As String, dateStr As String) As Variant
|
||||
Dim sUrl As String, sJson As String, vPrice As Variant
|
||||
sUrl = PROXY_BASE() & "/historical?ticker=" & EncodeUrl(ticker) & _
|
||||
"&date=" & EncodeUrl(dateStr) & "&key=" & PROXY_SECRET()
|
||||
sJson = HttpGetText(sUrl)
|
||||
If sJson = "" Then
|
||||
FUNDPRICE_HIST = "#ERROR"
|
||||
Exit Function
|
||||
End If
|
||||
vPrice = JsonNumber(sJson, "price")
|
||||
If IsNull(vPrice) Then
|
||||
FUNDPRICE_HIST = "#N/A"
|
||||
Else
|
||||
FUNDPRICE_HIST = vPrice
|
||||
End If
|
||||
End Function
|
||||
|
||||
' Replaces old STOCKTIME(ticker). Returns stockproxy's resolved_date
|
||||
' (YYYY-MM-DD) — a date, not a time-of-day timestamp. See note at top.
|
||||
Function FUNDTIME(ticker As String) As Variant
|
||||
Dim sUrl As String, sJson As String, vDate As Variant
|
||||
sUrl = PROXY_BASE() & "/current?ticker=" & EncodeUrl(ticker) & "&key=" & PROXY_SECRET()
|
||||
sJson = HttpGetText(sUrl)
|
||||
If sJson = "" Then
|
||||
FUNDTIME = "#ERROR"
|
||||
Exit Function
|
||||
End If
|
||||
vDate = JsonString(sJson, "resolved_date")
|
||||
If IsNull(vDate) Then
|
||||
FUNDTIME = "#N/A"
|
||||
Else
|
||||
FUNDTIME = vDate
|
||||
End If
|
||||
End Function
|
||||
|
||||
' ---- Internal helpers ----------------------------------------------------
|
||||
|
||||
' Synchronous HTTP GET, returns response body as text, "" on failure.
|
||||
Function HttpGetText(sUrl As String) As String
|
||||
Dim oSFA As Object, oStream As Object, oTextStream As Object
|
||||
Dim sResult As String
|
||||
|
||||
On Error GoTo ErrHandler
|
||||
oSFA = createUnoService("com.sun.star.ucb.SimpleFileAccess")
|
||||
oStream = oSFA.openFileRead(sUrl)
|
||||
oTextStream = createUnoService("com.sun.star.io.TextInputStream")
|
||||
oTextStream.setInputStream(oStream)
|
||||
oTextStream.setEncoding("UTF-8")
|
||||
|
||||
sResult = ""
|
||||
Do While Not oTextStream.isEOF()
|
||||
sResult = sResult & oTextStream.readLine() & Chr(10)
|
||||
Loop
|
||||
oTextStream.closeInput()
|
||||
HttpGetText = sResult
|
||||
Exit Function
|
||||
|
||||
ErrHandler:
|
||||
HttpGetText = ""
|
||||
End Function
|
||||
|
||||
' Pulls a numeric value out of a flat JSON string for a given key.
|
||||
' e.g. JsonNumber("{""price"":18.37}", "price") -> 18.37
|
||||
' Returns Null if the key isn't found or has no numeric value.
|
||||
Function JsonNumber(sJson As String, sKey As String) As Variant
|
||||
Dim iPos As Integer, iStart As Integer, iEnd As Integer
|
||||
Dim sNum As String, cChar As String
|
||||
|
||||
iPos = InStr(sJson, Chr(34) & sKey & Chr(34))
|
||||
If iPos = 0 Then
|
||||
JsonNumber = Null
|
||||
Exit Function
|
||||
End If
|
||||
|
||||
iPos = InStr(iPos, sJson, ":")
|
||||
If iPos = 0 Then
|
||||
JsonNumber = Null
|
||||
Exit Function
|
||||
End If
|
||||
|
||||
iStart = iPos + 1
|
||||
Do While iStart <= Len(sJson) And Mid(sJson, iStart, 1) = " "
|
||||
iStart = iStart + 1
|
||||
Loop
|
||||
|
||||
iEnd = iStart
|
||||
Do While iEnd <= Len(sJson)
|
||||
cChar = Mid(sJson, iEnd, 1)
|
||||
If (cChar >= "0" And cChar <= "9") Or cChar = "." Or cChar = "-" Then
|
||||
iEnd = iEnd + 1
|
||||
Else
|
||||
Exit Do
|
||||
End If
|
||||
Loop
|
||||
|
||||
sNum = Mid(sJson, iStart, iEnd - iStart)
|
||||
If Len(sNum) = 0 Then
|
||||
JsonNumber = Null
|
||||
Else
|
||||
JsonNumber = CDbl(sNum)
|
||||
End If
|
||||
End Function
|
||||
|
||||
' Pulls a quoted STRING value out of a flat JSON string for a given key.
|
||||
' e.g. JsonString("{""resolved_date"":""2026-07-23""}", "resolved_date")
|
||||
' -> "2026-07-23"
|
||||
' Returns Null if the key isn't found or has no quoted value.
|
||||
Function JsonString(sJson As String, sKey As String) As Variant
|
||||
Dim iPos As Integer, iColon As Integer, iQuoteStart As Integer, iQuoteEnd As Integer
|
||||
|
||||
iPos = InStr(sJson, Chr(34) & sKey & Chr(34))
|
||||
If iPos = 0 Then
|
||||
JsonString = Null
|
||||
Exit Function
|
||||
End If
|
||||
|
||||
iColon = InStr(iPos, sJson, ":")
|
||||
If iColon = 0 Then
|
||||
JsonString = Null
|
||||
Exit Function
|
||||
End If
|
||||
|
||||
iQuoteStart = InStr(iColon, sJson, Chr(34))
|
||||
If iQuoteStart = 0 Then
|
||||
JsonString = Null
|
||||
Exit Function
|
||||
End If
|
||||
|
||||
iQuoteEnd = InStr(iQuoteStart + 1, sJson, Chr(34))
|
||||
If iQuoteEnd = 0 Then
|
||||
JsonString = Null
|
||||
Exit Function
|
||||
End If
|
||||
|
||||
JsonString = Mid(sJson, iQuoteStart + 1, iQuoteEnd - iQuoteStart - 1)
|
||||
End Function
|
||||
|
||||
' Minimal percent-encoder - sufficient for tickers and yyyy-mm-dd strings.
|
||||
Function EncodeUrl(s As String) As String
|
||||
Dim i As Integer, c As String, sOut As String
|
||||
sOut = ""
|
||||
For i = 1 To Len(s)
|
||||
c = Mid(s, i, 1)
|
||||
If (c >= "A" And c <= "Z") Or (c >= "a" And c <= "z") Or _
|
||||
(c >= "0" And c <= "9") Or c = "-" Or c = "_" Or c = "." Or c = "~" Then
|
||||
sOut = sOut & c
|
||||
Else
|
||||
sOut = sOut & "%" & Right("0" & Hex(Asc(c)), 2)
|
||||
End If
|
||||
Next i
|
||||
EncodeUrl = sOut
|
||||
End Function
|
||||
|
||||
Sub RefreshPriceSnapshot
|
||||
Dim oDoc As Object, oSheet As Object
|
||||
Dim oSrcRange As Object, oDestRange As Object
|
||||
Dim oCell As Object
|
||||
Dim i As Integer
|
||||
|
||||
oDoc = ThisComponent
|
||||
oSheet = oDoc.Sheets.getByIndex(0) ' adjust index/name if needed
|
||||
|
||||
' Copy P3:Q53 -> R3:S53 as values only
|
||||
For i = 2 To 4 ' rows 3 to 53 (0-indexed: row 3 = index 2)
|
||||
oSheet.getCellByPosition(17, i).setValue(oSheet.getCellByPosition(15, i).getValue()) ' P->R (col P=15, R=17)
|
||||
oSheet.getCellByPosition(18, i).setValue(oSheet.getCellByPosition(16, i).getValue()) ' Q->S (col Q=16, S=18)
|
||||
Next i
|
||||
|
||||
MsgBox "Price snapshot refreshed."
|
||||
End Sub
|
||||
19
Stocks/onlyoffice_macro_autostart_recalc.js
Normal file
19
Stocks/onlyoffice_macro_autostart_recalc.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// ONLYOFFICE Macro (NOT a Custom Function -- add under View -> Macros -> Macros, the "+" next
|
||||
// to "Macros", not "Custom functions")
|
||||
//
|
||||
// Purpose: attempt to fix #NAME? / #BUSY errors that appear on file open for custom-function
|
||||
// cells (FUNDPRICE, FUNDPRICE_HIST, STOCKPRICE, STOCKTIME) until manually clicked + Enter.
|
||||
//
|
||||
// IMPORTANT -- STATUS: PARTIAL FIX ONLY, ISSUE NOT FULLY RESOLVED.
|
||||
// A screenshot after applying this showed only 2 of 34 "Price" cells resolved correctly;
|
||||
// the rest stayed #NAME?, while a parallel "Price History" column resolved for all rows.
|
||||
// The asymmetry was not explained before the session moved on. See
|
||||
// onlyoffice_stock_tracking.md "Known Issues" section for next debugging steps if resuming.
|
||||
//
|
||||
// Setup: paste this in as a new Macro, check the "Autostart" checkbox, Save.
|
||||
|
||||
(function () {
|
||||
setTimeout(function () {
|
||||
Api.RecalculateAllFormulas();
|
||||
}, 3000);
|
||||
})();
|
||||
61
Stocks/onlyoffice_macro_fundprice_proxy.js
Normal file
61
Stocks/onlyoffice_macro_fundprice_proxy.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// ONLYOFFICE Custom Function
|
||||
// Location: View -> Macros -> Custom Functions
|
||||
// Used on: "HSA Investiments Choices" tab (mutual funds -- Finnhub has no NAV coverage)
|
||||
// Data source: self-hosted STOCKPROXY service (see server_stockproxy.md / stockproxy_app.py)
|
||||
// https://stocks.kingdezigns.com
|
||||
//
|
||||
// Real shared secret lives in Vaultwarden -- never commit the real secret to this file.
|
||||
//
|
||||
// Usage in a cell:
|
||||
// =FUNDPRICE("MKDVX")
|
||||
// =FUNDPRICE_HIST("MKDVX", TEXT($B$3,"yyyy-mm-dd")) <- date MUST be passed as text
|
||||
|
||||
(function () {
|
||||
var PROXY_BASE = "https://stocks.kingdezigns.com";
|
||||
var PROXY_SECRET = "YOUR_PROXY_SECRET_HERE";
|
||||
|
||||
/**
|
||||
* Current mutual fund price via your own proxy.
|
||||
* @customfunction
|
||||
* @param {string} ticker Ticker symbol, e.g. "MKDVX"
|
||||
* @returns {any} Latest price, or an error string
|
||||
*/
|
||||
async function FUNDPRICE(ticker) {
|
||||
var url = PROXY_BASE + "/current?ticker=" + encodeURIComponent(ticker) + "&key=" + PROXY_SECRET;
|
||||
try {
|
||||
var r = await fetch(url);
|
||||
var data = await r.json();
|
||||
if (data && typeof data.price === "number") {
|
||||
return data.price;
|
||||
}
|
||||
return "#N/A";
|
||||
} catch (e) {
|
||||
return "#ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical fund price on/before a given date via your own proxy.
|
||||
* @customfunction
|
||||
* @param {string} ticker Ticker symbol, e.g. "MKDVX"
|
||||
* @param {string} dateStr Date as YYYY-MM-DD (wrap the date cell with TEXT(cell,"yyyy-mm-dd"))
|
||||
* @returns {any} Historical closing price, or an error string
|
||||
*/
|
||||
async function FUNDPRICE_HIST(ticker, dateStr) {
|
||||
var url = PROXY_BASE + "/historical?ticker=" + encodeURIComponent(ticker) +
|
||||
"&date=" + encodeURIComponent(dateStr) + "&key=" + PROXY_SECRET;
|
||||
try {
|
||||
var r = await fetch(url);
|
||||
var data = await r.json();
|
||||
if (data && typeof data.price === "number") {
|
||||
return data.price;
|
||||
}
|
||||
return "#N/A";
|
||||
} catch (e) {
|
||||
return "#ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
Api.AddCustomFunction(FUNDPRICE);
|
||||
Api.AddCustomFunction(FUNDPRICE_HIST);
|
||||
})();
|
||||
55
Stocks/onlyoffice_macro_stockprice_finnhub (1).js
Normal file
55
Stocks/onlyoffice_macro_stockprice_finnhub (1).js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// ONLYOFFICE Custom Function
|
||||
// Location: View -> Macros -> Custom Functions
|
||||
// Used on: "Dashboard" tab (regular equities only -- Finnhub does NOT support mutual funds)
|
||||
// Data source: Finnhub (https://finnhub.io) -- free tier, real-time US equity quotes
|
||||
//
|
||||
// Real API key lives in Vaultwarden -- never commit the real key to this file.
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Latest stock price for a ticker, via Finnhub.
|
||||
* @customfunction
|
||||
* @param {string} ticker Stock ticker symbol, e.g. "AAPL"
|
||||
* @returns {any} Latest price, or an error string
|
||||
*/
|
||||
async function STOCKPRICE(ticker) {
|
||||
var apiKey = "YOUR_FINNHUB_API_KEY";
|
||||
var url = "https://finnhub.io/api/v1/quote?symbol=" +
|
||||
encodeURIComponent(ticker) + "&token=" + apiKey;
|
||||
try {
|
||||
var r = await fetch(url);
|
||||
var data = await r.json();
|
||||
if (data && typeof data.c === "number" && data.c !== 0) {
|
||||
return data.c;
|
||||
}
|
||||
return "#N/A";
|
||||
} catch (e) {
|
||||
return "#ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange-reported timestamp of the last quote for a ticker, via Finnhub.
|
||||
* @customfunction
|
||||
* @param {string} ticker Stock ticker symbol, e.g. "AAPL"
|
||||
* @returns {any} Last-updated time, or an error string
|
||||
*/
|
||||
async function STOCKTIME(ticker) {
|
||||
var apiKey = "YOUR_FINNHUB_API_KEY";
|
||||
var url = "https://finnhub.io/api/v1/quote?symbol=" +
|
||||
encodeURIComponent(ticker) + "&token=" + apiKey;
|
||||
try {
|
||||
var r = await fetch(url);
|
||||
var data = await r.json();
|
||||
if (data && data.t) {
|
||||
return new Date(data.t * 1000).toLocaleString();
|
||||
}
|
||||
return "#N/A";
|
||||
} catch (e) {
|
||||
return "#ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
Api.AddCustomFunction(STOCKPRICE);
|
||||
Api.AddCustomFunction(STOCKTIME);
|
||||
})();
|
||||
55
Stocks/onlyoffice_macro_stockprice_finnhub.js
Normal file
55
Stocks/onlyoffice_macro_stockprice_finnhub.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// ONLYOFFICE Custom Function
|
||||
// Location: View -> Macros -> Custom Functions
|
||||
// Used on: "Dashboard" tab (regular equities only -- Finnhub does NOT support mutual funds)
|
||||
// Data source: Finnhub (https://finnhub.io) -- free tier, real-time US equity quotes
|
||||
//
|
||||
// Real API key lives in Vaultwarden -- never commit the real key to this file.
|
||||
|
||||
(function () {
|
||||
/**
|
||||
* Latest stock price for a ticker, via Finnhub.
|
||||
* @customfunction
|
||||
* @param {string} ticker Stock ticker symbol, e.g. "AAPL"
|
||||
* @returns {any} Latest price, or an error string
|
||||
*/
|
||||
async function STOCKPRICE(ticker) {
|
||||
var apiKey = "YOUR_FINNHUB_API_KEY";
|
||||
var url = "https://finnhub.io/api/v1/quote?symbol=" +
|
||||
encodeURIComponent(ticker) + "&token=" + apiKey;
|
||||
try {
|
||||
var r = await fetch(url);
|
||||
var data = await r.json();
|
||||
if (data && typeof data.c === "number" && data.c !== 0) {
|
||||
return data.c;
|
||||
}
|
||||
return "#N/A";
|
||||
} catch (e) {
|
||||
return "#ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange-reported timestamp of the last quote for a ticker, via Finnhub.
|
||||
* @customfunction
|
||||
* @param {string} ticker Stock ticker symbol, e.g. "AAPL"
|
||||
* @returns {any} Last-updated time, or an error string
|
||||
*/
|
||||
async function STOCKTIME(ticker) {
|
||||
var apiKey = "YOUR_FINNHUB_API_KEY";
|
||||
var url = "https://finnhub.io/api/v1/quote?symbol=" +
|
||||
encodeURIComponent(ticker) + "&token=" + apiKey;
|
||||
try {
|
||||
var r = await fetch(url);
|
||||
var data = await r.json();
|
||||
if (data && data.t) {
|
||||
return new Date(data.t * 1000).toLocaleString();
|
||||
}
|
||||
return "#N/A";
|
||||
} catch (e) {
|
||||
return "#ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
Api.AddCustomFunction(STOCKPRICE);
|
||||
Api.AddCustomFunction(STOCKTIME);
|
||||
})();
|
||||
14
Stocks/stockproxy_Dockerfile
Normal file
14
Stocks/stockproxy_Dockerfile
Normal 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", "app:app"]
|
||||
161
Stocks/stockproxy_app.py
Normal file
161
Stocks/stockproxy_app.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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 ----
|
||||
SHARED_SECRET = os.environ.get("PROXY_SECRET", "")
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/cache.db")
|
||||
CURRENT_TTL_SECONDS = 15 * 60 # re-check "today" every 15 min in case NAV just posted
|
||||
|
||||
_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 = {} # ticker -> (price, resolved_date, fetched_at)
|
||||
_current_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_cors_headers(response):
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
return response
|
||||
|
||||
|
||||
def check_auth():
|
||||
key = request.args.get("key", "")
|
||||
return bool(SHARED_SECRET) and key == SHARED_SECRET
|
||||
|
||||
|
||||
def fetch_yahoo_price(ticker, target_date):
|
||||
"""Ask Yahoo for the last trading-day close on or before target_date."""
|
||||
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 None, f"yahoo http {r.status_code}"
|
||||
|
||||
data = r.json()
|
||||
try:
|
||||
result = data["chart"]["result"][0]
|
||||
timestamps = result["timestamp"]
|
||||
closes = result["indicators"]["quote"][0]["close"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None, "no historical data"
|
||||
|
||||
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 None, "no trading day found in window"
|
||||
|
||||
return {"resolved_date": best[0].strftime("%Y-%m-%d"), "price": round(best[1], 4)}, None
|
||||
|
||||
|
||||
@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[2]) < CURRENT_TTL_SECONDS:
|
||||
return jsonify({"ticker": ticker, "price": cached[0], "resolved_date": cached[1], "cached": True})
|
||||
|
||||
result, err = fetch_yahoo_price(ticker, datetime.utcnow())
|
||||
if err:
|
||||
return jsonify({"ticker": ticker, "error": err}), 502
|
||||
|
||||
with _current_cache_lock:
|
||||
_current_cache[ticker] = (result["price"], result["resolved_date"], now)
|
||||
|
||||
return jsonify({"ticker": ticker, "price": result["price"], "resolved_date": result["resolved_date"], "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
|
||||
|
||||
# If someone asks "historical" for today/future, treat it like /current instead
|
||||
if target_date.date() >= datetime.utcnow().date():
|
||||
return current_price()
|
||||
|
||||
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})
|
||||
|
||||
result, err = fetch_yahoo_price(ticker, target_date)
|
||||
if err:
|
||||
return jsonify({"ticker": ticker, "error": err}), 502
|
||||
|
||||
with _db_lock:
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO historical_cache (ticker, requested_date, resolved_date, price) VALUES (?, ?, ?, ?)",
|
||||
(ticker, date_str, result["resolved_date"], result["price"]),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({"ticker": ticker, "requested_date": date_str, "resolved_date": result["resolved_date"], "price": result["price"], "cached": False})
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5005)
|
||||
12
Stocks/stockproxy_docker-compose.yml
Normal file
12
Stocks/stockproxy_docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
stockproxy:
|
||||
build: .
|
||||
container_name: stockproxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5005:5005"
|
||||
environment:
|
||||
- PROXY_SECRET=PASTE_YOUR_RANDOM_SECRET_HERE
|
||||
- DB_PATH=/data/cache.db
|
||||
volumes:
|
||||
- /opt/stockproxy/data:/data
|
||||
3
Stocks/stockproxy_requirements.txt
Normal file
3
Stocks/stockproxy_requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask
|
||||
requests
|
||||
gunicorn
|
||||
371
nextcloud/nextcloud_update_check (1).sh
Normal file
371
nextcloud/nextcloud_update_check (1).sh
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
#!/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="⛔ 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="⛔ Nextcloud requires immediate attention"
|
||||
elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]] && [[ $APP_COUNT -gt 0 ]]; then
|
||||
STATUS_COLOR="#b45309"
|
||||
STATUS_LABEL="UPDATES AVAILABLE"
|
||||
STATUS_MSG="🔄 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="🔄 Nextcloud core update available (${NC_VERSION} → ${CORE_NEW_VERSION})"
|
||||
elif [[ $APP_COUNT -gt 0 ]]; then
|
||||
STATUS_COLOR="#b45309"
|
||||
STATUS_LABEL="UPDATES AVAILABLE"
|
||||
STATUS_MSG="🔄 ${APP_COUNT} app update(s) available"
|
||||
else
|
||||
STATUS_COLOR="#1a7f4b"
|
||||
STATUS_LABEL="UP TO DATE"
|
||||
STATUS_MSG="✅ 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 | ${HOSTNAME_LABEL} | 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."
|
||||
352
nextcloud/nextcloud_update_check.sh
Normal file
352
nextcloud/nextcloud_update_check.sh
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
#!/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"
|
||||
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="⛔ 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 | 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="⛔ Nextcloud requires immediate attention"
|
||||
elif [[ "$CORE_UPDATE_AVAILABLE" == "true" ]] && [[ $APP_COUNT -gt 0 ]]; then
|
||||
STATUS_COLOR="#b45309"
|
||||
STATUS_LABEL="UPDATES AVAILABLE"
|
||||
STATUS_MSG="🔄 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="🔄 Nextcloud core update available (${NC_VERSION} → ${CORE_NEW_VERSION})"
|
||||
elif [[ $APP_COUNT -gt 0 ]]; then
|
||||
STATUS_COLOR="#b45309"
|
||||
STATUS_LABEL="UPDATES AVAILABLE"
|
||||
STATUS_MSG="🔄 ${APP_COUNT} app update(s) available"
|
||||
else
|
||||
STATUS_COLOR="#1a7f4b"
|
||||
STATUS_LABEL="UP TO DATE"
|
||||
STATUS_MSG="✅ 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
|
||||
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'
|
||||
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 | ${HOSTNAME_LABEL} | 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."
|
||||
521
omv/SSD-fstrim-Summary.html
Normal file
521
omv/SSD-fstrim-Summary.html
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KingDezigns — SSD Hardening Summary</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); }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.script-box { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.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 — SSD Hardening (TRIM + noatime) summary</h1>
|
||||
<div class="meta">
|
||||
<span>📅 2026-05-23</span>
|
||||
<span>🖥 NAS08 & NAS16</span>
|
||||
<span>⚙️ OpenMediaVault</span>
|
||||
<span>🔧 Raspberry Pi 5 / Raspberry Pi</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- SECTION 1 — OBJECTIVE -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="1">Objective</div>
|
||||
<p>Harden both NAS08 and NAS16 to extend SSD lifespan by ensuring TRIM is running correctly, noatime is active, and SMART health is being monitored — with all scheduled jobs visible in OMV's scheduler for easy management.</p>
|
||||
<div class="badge-row">
|
||||
<span class="badge green">NAS08 — 5 drives (1x PNY CS900 + 4x Crucial BX500)</span>
|
||||
<span class="badge green">NAS16 — 1 drive (unknown model, standard SMART attrs)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 2 — INVESTIGATION STEPS -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="2">Investigation steps — both units</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Verify TRIM support on all drives</div>
|
||||
<pre>lsblk --discard /dev/sda</pre>
|
||||
<p class="why">Confirm DISC-GRAN and DISC-MAX are non-zero before enabling TRIM.</p>
|
||||
<p class="result pass">Both units: DISC-GRAN 4K / DISC-MAX 4G — TRIM fully supported on all drives.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Check if systemd fstrim.timer was already active</div>
|
||||
<pre>systemctl status fstrim.timer</pre>
|
||||
<p class="why">Determine whether a TRIM schedule already existed before creating a new one.</p>
|
||||
<p class="result neutral">Both units: timer active and enabled, running weekly. NAS08 next run Monday 12:33 AM — NAS16 next run Monday 1:37 AM.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Confirm which volumes were being trimmed</div>
|
||||
<pre>journalctl -u fstrim.service --no-pager</pre>
|
||||
<p class="why">Verify the timer was actually running and catching all mounted volumes.</p>
|
||||
<p class="result pass">Both units: / and /boot/firmware trimmed successfully on May 11 and May 18. NAS08 ~400 GiB freed per run. NAS16 ~228 GiB on first run, 3.3 GiB on second (less free space).</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">4</span> Check noatime mount status</div>
|
||||
<pre>findmnt -t ext4,xfs,btrfs -o TARGET,SOURCE,OPTIONS</pre>
|
||||
<p class="why">Confirm noatime was already set on all filesystems to prevent reads generating writes.</p>
|
||||
<p class="result pass">Both units: every mount point already showing noatime — handled automatically by the openmediavault-flashmemory plugin. No action required.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">5</span> Inspect SMART attributes per drive</div>
|
||||
<pre>sudo smartctl -A /dev/sda
|
||||
sudo smartctl -A /dev/sdb # NAS08 data drives</pre>
|
||||
<p class="why">Identify vendor-specific SMART attribute names before writing the monitoring script — attribute names differ significantly between PNY and Crucial.</p>
|
||||
<p class="result neutral">NAS08 PNY CS900: uses SSD_Life_Left (attr 231), Bad_Blk_Ct_Lat/Erl (attr 170), no pending/uncorrectable attrs. NAS08 Crucial BX500: uses Percent_Lifetime_Remain (attr 202), Reallocate_NAND_Blk_Cnt (attr 5), Reported_Uncorrect. NAS16 drive: uses standard attribute names — no vendor mapping required.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 3 — WHAT WAS ALREADY DONE -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="3">What was already handled by OMV</div>
|
||||
|
||||
<div class="callout green">
|
||||
<p><strong>noatime:</strong> Already active on all filesystems on both units via the <code>openmediavault-flashmemory</code> plugin. No manual configuration required.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout green">
|
||||
<p><strong>Swap disabled, logs in tmpfs:</strong> Also handled automatically by the flashmemory plugin — swap off, /var/log and /tmp in RAM, reducing write amplification significantly.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout amber">
|
||||
<div class="clue-label">⚠ Note</div>
|
||||
<p>The systemd <code>fstrim.timer</code> was already running weekly on both units but was invisible in OMV's scheduler. It was replaced with an OMV Scheduled Job so all jobs are visible and manageable in one place.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 4 — TRIM REPORT SCRIPT -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="4">TRIM report script — created and deployed</div>
|
||||
|
||||
<p>A custom script was written to replace the bare <code>fstrim</code> call with a full monitoring run that emails an HTML report matching the KingDezigns backup monitor style (dark header, badge/banner, section rows, cards, footer).</p>
|
||||
|
||||
<p>The email report includes: TRIM results per volume with total bytes freed, SSD SMART health per drive (health, temperature, wear, power-on hours, sector counts), filesystem usage with colour-coded warnings at 75% and 90%, and system info (OMV version, kernel, IP, uptime, load, memory, next run time).</p>
|
||||
|
||||
<div class="script-box">
|
||||
<div class="script-row">
|
||||
<div class="script-label">Script location</div>
|
||||
<pre>/usr/scripts/omv/fstrim-report.sh</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Log location</div>
|
||||
<pre>/var/log/fstrim/fstrim-YYYY-MM-DD.log (90-day retention)</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Deploy (preferred method — sudo nano)</div>
|
||||
<pre>sudo nano /usr/scripts/omv/fstrim-report.sh
|
||||
# paste script content, save with Ctrl+O, exit with Ctrl+X
|
||||
sudo chmod +x /usr/scripts/omv/fstrim-report.sh</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Test run</div>
|
||||
<pre>sudo systemctl start fstrim.service
|
||||
sudo journalctl -u fstrim.service -f</pre>
|
||||
</div>
|
||||
<div class="script-row">
|
||||
<div class="script-label">Email delivery</div>
|
||||
<pre>sendmail -t (via OMV's configured SMTP relay)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout amber" style="margin-top:0.75rem;">
|
||||
<div class="clue-label">⚠ SMART attribute fixes required</div>
|
||||
<p style="margin-bottom:6px;">Initial script used generic attribute names. Two rounds of fixes were needed after testing:</p>
|
||||
<pre>Round 1 — Crucial BX500 fix:
|
||||
Reallocated: Reallocated_Sector → Reallocate_NAND_Blk_Cnt
|
||||
Pending: Current_Pending_Sector → Current_Pending_ECC_Cnt
|
||||
Uncorrect: Offline_Uncorrectable → Reported_Uncorrect
|
||||
Temperature: $10 → $10+0 (strips "32 (Min/Max 25/56)" format)
|
||||
|
||||
Round 2 — PNY CS900 fix:
|
||||
Wear: Wear_Leveling_Count → SSD_Life_Left
|
||||
Reallocated: Reallocate_NAND_Blk_Cnt → Bad_Blk_Ct_Lat/Erl (first value before /)</pre>
|
||||
<p style="margin-top:8px;">Each pattern now tries vendor-specific names first, then falls back to generic names — script works correctly on any brand.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 5 — SYSTEMD → OMV MIGRATION -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="5">Migrating from systemd timer to OMV scheduler</div>
|
||||
|
||||
<p>The systemd <code>fstrim.timer</code> was disabled on both units and replaced with OMV Scheduled Jobs so all scheduled tasks are visible in one place.</p>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">1</span> Disable systemd timer (both units)</div>
|
||||
<pre>sudo systemctl disable fstrim.timer
|
||||
sudo systemctl stop fstrim.timer</pre>
|
||||
<p class="why">Prevent the systemd timer from running alongside the new OMV job — avoids double execution.</p>
|
||||
<p class="result pass">Timer stopped and disabled on both NAS08 and NAS16.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">2</span> Remove systemd override (both units)</div>
|
||||
<pre>sudo rm /etc/systemd/system/fstrim.service.d/override.conf
|
||||
sudo systemctl daemon-reload</pre>
|
||||
<p class="why">Clean up the override that pointed fstrim.service to the script — no longer needed since OMV cron calls the script directly.</p>
|
||||
<p class="result pass">Override removed, systemd reloaded cleanly on both units.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-header"><span class="step-num">3</span> Add OMV Scheduled Job (each unit)</div>
|
||||
<pre>System → Scheduled Jobs → Add
|
||||
Command: /usr/scripts/omv/fstrim-report.sh
|
||||
User: root
|
||||
NAS08: Wednesday at 1:00 AM
|
||||
NAS16: Wednesday at 1:30 AM</pre>
|
||||
<p class="why">Wednesday was chosen as it sits midweek, away from Sunday ZFS scrubs and the rolling every-3-day backup jobs.</p>
|
||||
<p class="result pass">Both units now show TRIM Report in OMV scheduler alongside all other scheduled jobs.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 6 — SCHEDULED JOB REFERENCE -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="6">Final OMV scheduled jobs — both units</div>
|
||||
|
||||
<p style="margin-bottom:0.75rem;"><strong>NAS08</strong></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Schedule</th><th>Script</th><th>Tag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Every 3 days at 2:00 AM</td><td><code>/usr/scripts/omv/nas08-backup.sh</code></td><td>NAS08 Backup</td></tr>
|
||||
<tr><td>Daily at 12:00 AM</td><td><code>/usr/scripts/zfs/nas08_zfs_report.sh</code></td><td>ZFS Status Report</td></tr>
|
||||
<tr><td>Sunday at 3:00 AM</td><td><code>/usr/scripts/zfs/nas08_zfs_scrub.sh</code></td><td>ZFS Scrub</td></tr>
|
||||
<tr><td>Wednesday at 1:00 AM</td><td><code>/usr/scripts/omv/fstrim-report.sh</code></td><td>TRIM Report</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin:1.25rem 0 0.75rem;"><strong>NAS16</strong></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Schedule</th><th>Script</th><th>Tag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Every 3 days at 2:00 AM</td><td><code>/usr/scripts/omv/nas16-backup.sh</code></td><td>NAS16 Backup</td></tr>
|
||||
<tr><td>Every 2 days at 2:30 AM</td><td><code>bash /usr/scripts/nas/nas08_to_nas16_sync.sh</code></td><td>NAS Drive Backup</td></tr>
|
||||
<tr><td>Daily at 12:00 AM</td><td><code>/usr/scripts/zfs/nas16_zfs_report.sh</code></td><td>ZFS Status Report</td></tr>
|
||||
<tr><td>Sunday at 3:00 AM</td><td><code>/usr/scripts/zfs/nas16_zfs_scrub.sh</code></td><td>ZFS Scrub</td></tr>
|
||||
<tr><td>Every 15 minutes</td><td><code>/usr/scripts/ddns/ddns_update.sh</code></td><td>DDNS Update</td></tr>
|
||||
<tr><td>Wednesday at 1:30 AM</td><td><code>/usr/scripts/omv/fstrim-report.sh</code></td><td>TRIM Report</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 7 — LESSONS LEARNED -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="7">Lessons learned</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Lesson</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Always investigate before configuring</td>
|
||||
<td>Both noatime and fstrim were already handled by OMV's flashmemory plugin — checking first avoided duplicating work.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>SMART attribute names are vendor-specific</td>
|
||||
<td>PNY and Crucial use completely different attribute names for wear, reallocated sectors, and temperature format. Always run <code>smartctl -A /dev/sdX</code> first to map the real names.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>systemd timers are invisible in OMV scheduler</td>
|
||||
<td>The default fstrim.timer runs correctly but doesn't appear in OMV's job list — migrate to OMV Scheduled Jobs for full visibility alongside ZFS scrubs and backups.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Never run script without sudo for testing</td>
|
||||
<td>Running fstrim-report.sh without sudo causes permission denied on /var/log/fstrim/ and returns N/A for all SMART data — always test with sudo or via systemctl start.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Use sudo nano over cp for file edits</td>
|
||||
<td>Preferred method for placing and editing scripts on the NAS — faster navigation to the correct path, avoids confusion about source file location.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Inline TRIM (discard mount option) is not recommended</td>
|
||||
<td>Real-time per-delete TRIM adds latency under heavy load. Periodic batch TRIM via fstrim is the correct approach for NAS workloads.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PNY CS900 has no pending/uncorrectable attributes</td>
|
||||
<td>N/A for those fields is correct and expected — not a script bug. The drive simply does not expose those attributes.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- SECTION 8 — DOCS UPDATED -->
|
||||
<div class="section">
|
||||
<div class="section-title" data-num="8">Documentation updated</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>File</th><th>What changed</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>server_nas08.md</code></td>
|
||||
<td>Added drive inventory table, full SSD Hardening & Maintenance section (TRIM, noatime, flashmemory plugin, SMART attribute mapping), and complete OMV Scheduled Jobs table.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>server_nas16.md</code></td>
|
||||
<td>Added drive inventory table with Power_Cycle_Count monitoring note, full SSD Hardening & Maintenance section, and complete OMV Scheduled Jobs table including NAS drive backup sync job.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="footer">
|
||||
<span>KingDezigns Home Network</span>
|
||||
<span>Generated 2026-05-23</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
4
omv/fstrim-override.conf
Normal file
4
omv/fstrim-override.conf
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[Service]
|
||||
# Clear the default ExecStart then replace with our script
|
||||
ExecStart=
|
||||
ExecStart=/usr/scripts/omv/fstrim-report.sh
|
||||
471
omv/fstrim-report.sh
Normal file
471
omv/fstrim-report.sh
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# NAS08 Weekly TRIM Report Script
|
||||
# =============================================================================
|
||||
# Runs fstrim on all mounted filesystems, captures results, gathers SMART
|
||||
# health data for each SSD, and sends an HTML email report matching the
|
||||
# KingDezigns backup monitor style.
|
||||
#
|
||||
# Triggered by: systemd fstrim.service (via drop-in override)
|
||||
# Schedule: Weekly (Monday ~12:33 AM — managed by fstrim.timer)
|
||||
#
|
||||
# Script location: /usr/scripts/omv/fstrim-report.sh
|
||||
# Log location: /var/log/fstrim/
|
||||
# =============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CONFIGURATION
|
||||
# -----------------------------------------------------------------------------
|
||||
HOSTNAME_LABEL=$(hostname -s 2>/dev/null || echo "NAS08")
|
||||
REPORT_TO="rufus.king@kingdezigns.com"
|
||||
REPORT_FROM="trim-monitor@nas08.local" # Display only — OMV uses your configured SMTP relay
|
||||
|
||||
LOG_DIR="/var/log/fstrim"
|
||||
KEEP_LOGS=90 # days to retain log files
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
LOG_FILE="${LOG_DIR}/fstrim-${DATE}.log"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# STATUS TRACKING
|
||||
# OVERALL_STATUS: SUCCESS | WARNING | FAILED
|
||||
# -----------------------------------------------------------------------------
|
||||
OVERALL_STATUS="SUCCESS"
|
||||
SECTION_ROWS_HTML=""
|
||||
WARNING_COUNT=0
|
||||
ERROR_COUNT=0
|
||||
|
||||
# Append a result row to the section table
|
||||
# Usage: add_section_row "Section name" "STATUS" "Detail message"
|
||||
# STATUS: OK | WARNING | ERROR | SKIPPED
|
||||
add_section_row() {
|
||||
local section="$1" status="$2" detail="$3"
|
||||
|
||||
case "$status" in
|
||||
OK)
|
||||
BADGE="<span style=\"background:#d1fae5;color:#065f46;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">✓ OK</span>"
|
||||
;;
|
||||
WARNING)
|
||||
BADGE="<span style=\"background:#fef3c7;color:#92400e;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">⚠ WARNING</span>"
|
||||
WARNING_COUNT=$((WARNING_COUNT + 1))
|
||||
[[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING"
|
||||
;;
|
||||
ERROR)
|
||||
BADGE="<span style=\"background:#fee2e2;color:#991b1b;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">✗ ERROR</span>"
|
||||
ERROR_COUNT=$((ERROR_COUNT + 1))
|
||||
OVERALL_STATUS="FAILED"
|
||||
;;
|
||||
SKIPPED)
|
||||
BADGE="<span style=\"background:#f3f4f6;color:#6b7280;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;\">— SKIPPED</span>"
|
||||
;;
|
||||
esac
|
||||
|
||||
SECTION_ROWS_HTML+="
|
||||
<tr>
|
||||
<td style=\"padding:9px 14px;border-bottom:1px solid #f3f4f6;font-size:13px;color:#111827;font-weight:600;\">$section</td>
|
||||
<td style=\"padding:9px 14px;border-bottom:1px solid #f3f4f6;\">$BADGE</td>
|
||||
<td style=\"padding:9px 14px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#6b7280;\">$detail</td>
|
||||
</tr>"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# LOGGING
|
||||
# -----------------------------------------------------------------------------
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
log_section() {
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "------------------------------------------------------------" | tee -a "$LOG_FILE"
|
||||
log "$1"
|
||||
echo "------------------------------------------------------------" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 1: RUN FSTRIM
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Running fstrim on all mounted filesystems"
|
||||
|
||||
TRIM_RAW=$(fstrim -av 2>&1)
|
||||
TRIM_EXIT=$?
|
||||
|
||||
TRIM_ROWS_HTML=""
|
||||
TOTAL_BYTES=0
|
||||
VOLUME_COUNT=0
|
||||
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^(/.+):[[:space:]]+(.+)[[:space:]]+\(([0-9]+)[[:space:]]bytes\) ]]; then
|
||||
MOUNT="${BASH_REMATCH[1]}"
|
||||
HUMAN="${BASH_REMATCH[2]}"
|
||||
BYTES="${BASH_REMATCH[3]}"
|
||||
TOTAL_BYTES=$((TOTAL_BYTES + BYTES))
|
||||
VOLUME_COUNT=$((VOLUME_COUNT + 1))
|
||||
log "Trimmed ${MOUNT}: ${HUMAN}"
|
||||
TRIM_ROWS_HTML+="
|
||||
<tr>
|
||||
<td style=\"padding:8px 14px;border-bottom:1px solid #f3f4f6;font-size:13px;font-family:monospace;color:#111827;\">$MOUNT</td>
|
||||
<td style=\"padding:8px 14px;border-bottom:1px solid #f3f4f6;font-size:13px;color:#374151;font-weight:600;\">$HUMAN freed</td>
|
||||
</tr>"
|
||||
fi
|
||||
done <<< "$TRIM_RAW"
|
||||
|
||||
# Convert total bytes to human readable
|
||||
if [ $TOTAL_BYTES -ge 1073741824 ]; then
|
||||
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1f GiB\", $TOTAL_BYTES/1073741824}")
|
||||
elif [ $TOTAL_BYTES -ge 1048576 ]; then
|
||||
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1f MiB\", $TOTAL_BYTES/1048576}")
|
||||
else
|
||||
TOTAL_HUMAN="${TOTAL_BYTES} bytes"
|
||||
fi
|
||||
|
||||
if [ $TRIM_EXIT -eq 0 ]; then
|
||||
log "fstrim completed — $VOLUME_COUNT volume(s) · $TOTAL_HUMAN total freed"
|
||||
add_section_row "TRIM Execution" "OK" "$VOLUME_COUNT volume(s) processed · $TOTAL_HUMAN total freed"
|
||||
else
|
||||
log "ERROR: fstrim exited with code $TRIM_EXIT"
|
||||
add_section_row "TRIM Execution" "ERROR" "fstrim exited with code $TRIM_EXIT"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 2: SSD SMART HEALTH
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Gathering SSD SMART health data"
|
||||
|
||||
SMART_ROWS_HTML=""
|
||||
SMART_WARN=0
|
||||
SMART_ERR=0
|
||||
|
||||
for DISK in $(lsblk -dpno NAME | grep -E '^/dev/sd|^/dev/nvme'); do
|
||||
MODEL=$(smartctl -i "$DISK" 2>/dev/null | awk -F': ' '/Device Model|Model Number/{gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit}')
|
||||
SERIAL=$(smartctl -i "$DISK" 2>/dev/null | awk -F': ' '/Serial Number/{gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit}')
|
||||
CAPACITY=$(lsblk -dno SIZE "$DISK" 2>/dev/null)
|
||||
HEALTH=$(smartctl -H "$DISK" 2>/dev/null | awk '/overall-health|test result/{print $NF}')
|
||||
# Temperature: +0 forces numeric conversion, handling "32 (Min/Max 25/56)" Crucial format
|
||||
TEMP=$(smartctl -A "$DISK" 2>/dev/null | awk '/Temperature_Celsius|Temperature_Internal/{print $10+0; exit}')
|
||||
POWER_ON=$(smartctl -A "$DISK" 2>/dev/null | awk '/Power_On_Hours/{print $10; exit}')
|
||||
# Wear: PNY=SSD_Life_Left, Crucial=Percent_Lifetime_Remain, Samsung/WD=Wear_Leveling_Count, Intel=Media_Wearout_Indicator
|
||||
WEAR=$(smartctl -A "$DISK" 2>/dev/null | awk '/SSD_Life_Left|Percent_Lifetime_Remain|Wear_Leveling_Count|Media_Wearout_Indicator/{print $10; exit}')
|
||||
# Reallocated: PNY=Bad_Blk_Ct_Lat/Erl (RAW_VALUE is "0/88" — take first number), Crucial=Reallocate_NAND_Blk_Cnt, others=Reallocated_Sector_Ct
|
||||
REALLOCATED=$(smartctl -A "$DISK" 2>/dev/null | awk '/Bad_Blk_Ct_Lat\/Erl/{split($10,a,"/"); print a[1]; exit} /Reallocate_NAND_Blk_Cnt|Reallocated_Sector_Ct|Reallocated_Sector/{print $10; exit}')
|
||||
# Pending: Crucial=Current_Pending_ECC_Cnt, others=Current_Pending_Sector (PNY has no equivalent — will show N/A)
|
||||
PENDING=$(smartctl -A "$DISK" 2>/dev/null | awk '/Current_Pending_ECC_Cnt|Current_Pending_Sector/{print $10; exit}')
|
||||
# Uncorrectable: Crucial=Reported_Uncorrect, others=Offline_Uncorrectable (PNY has no equivalent — will show N/A)
|
||||
UNCORRECTABLE=$(smartctl -A "$DISK" 2>/dev/null | awk '/Reported_Uncorrect|Offline_Uncorrectable/{print $10; exit}')
|
||||
|
||||
[ -z "$MODEL" ] && MODEL="Unknown"
|
||||
[ -z "$SERIAL" ] && SERIAL="Unknown"
|
||||
[ -z "$HEALTH" ] && HEALTH="Unknown"
|
||||
[ -z "$TEMP" ] && TEMP="N/A"
|
||||
[ -z "$POWER_ON" ] && POWER_ON="N/A"
|
||||
[ -z "$WEAR" ] && WEAR="N/A"
|
||||
[ -z "$REALLOCATED" ] && REALLOCATED="N/A"
|
||||
[ -z "$PENDING" ] && PENDING="N/A"
|
||||
[ -z "$UNCORRECTABLE" ] && UNCORRECTABLE="N/A"
|
||||
|
||||
# Health colour
|
||||
if [ "$HEALTH" = "PASSED" ]; then
|
||||
HEALTH_COLOUR="#065f46"
|
||||
HEALTH_BG="#d1fae5"
|
||||
DISK_STATUS="OK"
|
||||
else
|
||||
HEALTH_COLOUR="#991b1b"
|
||||
HEALTH_BG="#fee2e2"
|
||||
DISK_STATUS="WARNING"
|
||||
SMART_WARN=$((SMART_WARN + 1))
|
||||
fi
|
||||
|
||||
# Flag non-zero reallocated/pending/uncorrectable sectors
|
||||
SECTOR_NOTE=""
|
||||
if [ "$REALLOCATED" != "N/A" ] && [ "$REALLOCATED" -gt 0 ] 2>/dev/null; then
|
||||
SECTOR_NOTE+=" ⚠ Reallocated: $REALLOCATED"
|
||||
SMART_WARN=$((SMART_WARN + 1))
|
||||
[[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING"
|
||||
fi
|
||||
if [ "$PENDING" != "N/A" ] && [ "$PENDING" -gt 0 ] 2>/dev/null; then
|
||||
SECTOR_NOTE+=" ⚠ Pending: $PENDING"
|
||||
SMART_WARN=$((SMART_WARN + 1))
|
||||
[[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING"
|
||||
fi
|
||||
if [ "$UNCORRECTABLE" != "N/A" ] && [ "$UNCORRECTABLE" -gt 0 ] 2>/dev/null; then
|
||||
SECTOR_NOTE+=" 🚨 Uncorrectable: $UNCORRECTABLE"
|
||||
SMART_ERR=$((SMART_ERR + 1))
|
||||
OVERALL_STATUS="FAILED"
|
||||
fi
|
||||
|
||||
log "Drive $DISK ($MODEL) — Health: $HEALTH | Temp: ${TEMP}°C | Wear: $WEAR | Power-on: ${POWER_ON}h${SECTOR_NOTE}"
|
||||
|
||||
SMART_ROWS_HTML+="
|
||||
<tr>
|
||||
<td colspan=\"2\" style=\"padding:10px 14px 4px;font-size:13px;font-weight:700;color:#111827;border-top:2px solid #e5e7eb;\">
|
||||
$DISK <span style=\"font-weight:400;color:#6b7280;\">$MODEL · $CAPACITY · S/N: $SERIAL</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=\"padding:4px 14px 4px 24px;font-size:12px;color:#6b7280;width:30%;\">Overall health</td>
|
||||
<td style=\"padding:4px 14px;\"><span style=\"background:${HEALTH_BG};color:${HEALTH_COLOUR};padding:2px 10px;border-radius:20px;font-size:11px;font-weight:700;\">$HEALTH</span>${SECTOR_NOTE:+<span style=\"font-size:11px;color:#b45309;margin-left:8px;\">$SECTOR_NOTE</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=\"padding:4px 14px 4px 24px;font-size:12px;color:#6b7280;\">Temperature</td>
|
||||
<td style=\"padding:4px 14px;font-size:12px;color:#374151;font-weight:600;\">${TEMP}°C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=\"padding:4px 14px 4px 24px;font-size:12px;color:#6b7280;\">Power-on hours</td>
|
||||
<td style=\"padding:4px 14px;font-size:12px;color:#374151;font-weight:600;\">${POWER_ON} hrs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=\"padding:4px 14px 4px 24px;font-size:12px;color:#6b7280;\">Wear leveling</td>
|
||||
<td style=\"padding:4px 14px;font-size:12px;color:#374151;font-weight:600;\">$WEAR</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=\"padding:4px 14px 8px 24px;font-size:12px;color:#6b7280;\">Sectors (realloc / pending / uncorrectable)</td>
|
||||
<td style=\"padding:4px 14px 8px;font-size:12px;color:#374151;font-weight:600;\">$REALLOCATED / $PENDING / $UNCORRECTABLE</td>
|
||||
</tr>"
|
||||
done
|
||||
|
||||
if [ $SMART_ERR -gt 0 ]; then
|
||||
add_section_row "SSD SMART Health" "ERROR" "$SMART_ERR drive(s) with uncorrectable sectors — immediate attention required"
|
||||
elif [ $SMART_WARN -gt 0 ]; then
|
||||
add_section_row "SSD SMART Health" "WARNING" "$SMART_WARN warning(s) — review drive details below"
|
||||
else
|
||||
add_section_row "SSD SMART Health" "OK" "All drives PASSED · no sector errors detected"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SECTION 3: FILESYSTEM USAGE
|
||||
# -----------------------------------------------------------------------------
|
||||
log_section "Filesystem usage"
|
||||
|
||||
FS_ROWS_HTML=""
|
||||
FS_WARN=0
|
||||
|
||||
while IFS= read -r line; do
|
||||
USE_PCT=$(echo "$line" | awk '{print $5}' | tr -d '%')
|
||||
MOUNT_PT=$(echo "$line" | awk '{print $6}')
|
||||
USED=$(echo "$line" | awk '{print $3}')
|
||||
SIZE=$(echo "$line" | awk '{print $2}')
|
||||
|
||||
if [ "$USE_PCT" -ge 90 ] 2>/dev/null; then
|
||||
ROW_COLOUR="#fee2e2"; PCT_COLOUR="#991b1b"
|
||||
FS_WARN=$((FS_WARN + 1))
|
||||
[[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING"
|
||||
elif [ "$USE_PCT" -ge 75 ] 2>/dev/null; then
|
||||
ROW_COLOUR="#fef3c7"; PCT_COLOUR="#92400e"
|
||||
FS_WARN=$((FS_WARN + 1))
|
||||
[[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING"
|
||||
else
|
||||
ROW_COLOUR="transparent"; PCT_COLOUR="#065f46"
|
||||
fi
|
||||
|
||||
log "Filesystem $MOUNT_PT: ${USED} used of ${SIZE} (${USE_PCT}%)"
|
||||
|
||||
FS_ROWS_HTML+="
|
||||
<tr style=\"background:${ROW_COLOUR};\">
|
||||
<td style=\"padding:8px 14px;font-size:13px;font-family:monospace;color:#111827;\">$MOUNT_PT</td>
|
||||
<td style=\"padding:8px 14px;font-size:13px;color:#374151;\">$USED used of $SIZE</td>
|
||||
<td style=\"padding:8px 14px;font-size:13px;font-weight:700;color:${PCT_COLOUR};\">${USE_PCT}%</td>
|
||||
</tr>"
|
||||
done < <(df -h | grep -E '^/dev/')
|
||||
|
||||
if [ $FS_WARN -gt 0 ]; then
|
||||
add_section_row "Filesystem Usage" "WARNING" "$FS_WARN volume(s) above 75% — review recommended"
|
||||
else
|
||||
add_section_row "Filesystem Usage" "OK" "All volumes within normal range"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ROTATE OLD LOGS
|
||||
# -----------------------------------------------------------------------------
|
||||
find "$LOG_DIR" -name "fstrim-*.log" -mtime +${KEEP_LOGS} -delete
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SYSTEM CONTEXT
|
||||
# -----------------------------------------------------------------------------
|
||||
OMV_VER=$(dpkg -l openmediavault 2>/dev/null | awk '/^ii/{print $3}' || echo "Unknown")
|
||||
KERNEL_VER=$(uname -r)
|
||||
HOST_IP=$(hostname -I | awk '{print $1}')
|
||||
UPTIME_STR=$(uptime -p)
|
||||
LOAD_STR=$(uptime | awk -F'load average:' '{print $2}' | xargs)
|
||||
MEM_USED=$(free -h | awk '/^Mem:/{print $3}')
|
||||
MEM_TOTAL=$(free -h | awk '/^Mem:/{print $2}')
|
||||
NEXT_RUN=$(systemctl show fstrim.timer --property=NextElapseUSecRealtime 2>/dev/null \
|
||||
| awk -F= '{print $2}' || echo "See: systemctl status fstrim.timer")
|
||||
|
||||
log_section "Report complete — Overall: $OVERALL_STATUS"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# BUILD HTML EMAIL (style: KingDezigns monitor)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Overall banner & badge
|
||||
case "$OVERALL_STATUS" in
|
||||
SUCCESS)
|
||||
BADGE_HTML="<div style=\"background:#1a7f4b;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">✅ SUCCESS</div>"
|
||||
BANNER_HTML="<tr><td style=\"background:#1a7f4b;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">✅ TRIM completed successfully — no action required.</span></td></tr>"
|
||||
;;
|
||||
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;\">⚠️ TRIM completed with warnings — review recommended.</span></td></tr>"
|
||||
;;
|
||||
FAILED)
|
||||
BADGE_HTML="<div style=\"background:#b91c1c;color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;\">🚨 FAILED</div>"
|
||||
BANNER_HTML="<tr><td style=\"background:#b91c1c;padding:12px 32px;\"><span style=\"color:white;font-size:14px;font-weight:600;\">🚨 TRIM run failed — immediate attention required!</span></td></tr>"
|
||||
;;
|
||||
esac
|
||||
|
||||
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;">SSD Maintenance Monitor</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;">$REPORT_TIME | Weekly TRIM · $WARNING_COUNT warning(s) · $ERROR_COUNT error(s)</div>
|
||||
</td>
|
||||
<td align="right">$BADGE_HTML</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<!-- Status banner -->
|
||||
$BANNER_HTML
|
||||
|
||||
<!-- Section results table -->
|
||||
<tr><td style="padding:28px 24px 8px;">
|
||||
<div style="background:white;border-radius:10px;box-shadow:0 1px 6px rgba(0,0,0,.10);overflow:hidden;border:1px solid #e5e7eb;">
|
||||
<div style="background:#f8fafc;padding:14px 22px;border-bottom:1px solid #e5e7eb;">
|
||||
<span style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;">📋 Run Summary</span>
|
||||
</div>
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr style="background:#f9fafb;">
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;width:30%;">Section</th>
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;width:15%;">Status</th>
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;">Detail</th>
|
||||
</tr>
|
||||
$SECTION_ROWS_HTML
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<!-- TRIM volumes card -->
|
||||
<tr><td style="padding:0 24px 8px;">
|
||||
<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:12px;text-transform:uppercase;letter-spacing:.5px;">✂️ TRIM Results — $TOTAL_HUMAN Total Freed</div>
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr style="background:#f9fafb;">
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;">Mount point</th>
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;">Space freed</th>
|
||||
</tr>
|
||||
$TRIM_ROWS_HTML
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<!-- SMART health card -->
|
||||
<tr><td style="padding:0 24px 8px;">
|
||||
<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:12px;text-transform:uppercase;letter-spacing:.5px;">🔬 SSD SMART Health</div>
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
$SMART_ROWS_HTML
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<!-- Filesystem usage card -->
|
||||
<tr><td style="padding:0 24px 8px;">
|
||||
<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:12px;text-transform:uppercase;letter-spacing:.5px;">💾 Filesystem Usage</div>
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr style="background:#f9fafb;">
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;width:35%;">Mount point</th>
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;">Usage</th>
|
||||
<th style="text-align:left;padding:8px 14px;font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;font-weight:600;border-bottom:1px solid #e5e7eb;width:15%;">Used %</th>
|
||||
</tr>
|
||||
$FS_ROWS_HTML
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<!-- System info 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;">🖥️ System Info</div>
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;width:30%;">OMV</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">${OMV_VER}</td>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;width:30%;">Kernel</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">${KERNEL_VER}</td>
|
||||
</tr>
|
||||
<tr><td colspan="4" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;">IP Address</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;font-family:monospace;">${HOST_IP}</td>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;">Uptime</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">${UPTIME_STR}</td>
|
||||
</tr>
|
||||
<tr><td colspan="4" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;">Load average</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">${LOAD_STR}</td>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;">Memory</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">${MEM_USED} / ${MEM_TOTAL}</td>
|
||||
</tr>
|
||||
<tr><td colspan="4" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;">Log file</td>
|
||||
<td colspan="3" style="font-size:13px;color:#111827;font-weight:600;font-family:monospace;">${LOG_FILE}</td>
|
||||
</tr>
|
||||
<tr><td colspan="4" style="height:6px;"></td></tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#6b7280;padding:4px 0;">Next TRIM run</td>
|
||||
<td colspan="3" style="font-size:13px;color:#111827;font-weight:600;">${NEXT_RUN}</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;">SSD Maintenance Monitor | $HOSTNAME_LABEL | KingDezigns Infrastructure</span>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SEND EMAIL
|
||||
# -----------------------------------------------------------------------------
|
||||
SUBJECT="[TRIM] $HOSTNAME_LABEL — $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 "===== fstrim-report.sh finished ====="
|
||||
288
scripts/nextcloud_update_check.sh
Normal file
288
scripts/nextcloud_update_check.sh
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
#!/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="⛔ 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="⛔ Nextcloud requires immediate attention"
|
||||
elif [[ $APP_COUNT -gt 0 ]]; then
|
||||
STATUS_COLOR="#b45309"
|
||||
STATUS_LABEL="UPDATES AVAILABLE"
|
||||
STATUS_MSG="🔄 ${APP_COUNT} app update(s) available"
|
||||
else
|
||||
STATUS_COLOR="#1a7f4b"
|
||||
STATUS_LABEL="UP TO DATE"
|
||||
STATUS_MSG="✅ 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 | ${HOSTNAME_LABEL} | 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."
|
||||
506
zfs/NAS08-zfs_pools.html
Normal file
506
zfs/NAS08-zfs_pools.html
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
<!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 & 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>
|
||||
506
zfs/NAS08-zfs_pools.html~
Normal file
506
zfs/NAS08-zfs_pools.html~
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
<!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 & 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>
|
||||
351
zfs/nas08_zfs_report.sh
Normal file
351
zfs/nas08_zfs_report.sh
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# ZFS Pool Health Report — NAS08
|
||||
# Sends an HTML status email to rufus.king@kingdezigns.com
|
||||
# Cron example: 0 7 * * * /opt/scripts/zfs_report_nas08.sh
|
||||
#
|
||||
# Commands executed per pool:
|
||||
# zpool status -v <pool> — full status + verbose per-file error listing
|
||||
# zpool list <pool> — pool-level size / capacity / health
|
||||
# zfs list -r <pool> — per-dataset breakdown (used, avail, refer, mount)
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
TARGET_HOST="NAS08"
|
||||
REPORT_TO="rufus.king@kingdezigns.com"
|
||||
REPORT_FROM="zfs-monitor@${TARGET_HOST,,}.local"
|
||||
SCRUB_MAX_AGE_DAYS=8 # Warn if last scrub is older than this
|
||||
CAP_WARN=80 # Capacity % that triggers WARNING
|
||||
CAP_CRIT=90 # Capacity % that triggers CRITICAL
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
NOW=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
HOSTNAME_REAL=$(hostname -s 2>/dev/null || echo "$TARGET_HOST")
|
||||
|
||||
# ── HTML escape helper ────────────────────────────────────────────────────────
|
||||
html_escape() { sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g'; }
|
||||
|
||||
# ── Collect pool list ─────────────────────────────────────────────────────────
|
||||
POOL_LIST=$(zpool list -H -o name 2>/dev/null) || { echo "ERROR: zpool not found"; exit 1; }
|
||||
|
||||
OVERALL_STATUS="HEALTHY"
|
||||
POOL_BLOCKS=""
|
||||
POOL_COUNT=0
|
||||
PROBLEM_COUNT=0
|
||||
|
||||
# ── Per-pool loop ─────────────────────────────────────────────────────────────
|
||||
while IFS= read -r POOL; do
|
||||
[[ -z "$POOL" ]] && continue
|
||||
POOL_COUNT=$((POOL_COUNT + 1))
|
||||
|
||||
# 1. zpool status -v ── verbose: includes per-file permanent error listing
|
||||
STATUS_RAW=$(zpool status -v "$POOL" 2>&1)
|
||||
|
||||
# 2. zpool list ── raw (-p) for math, human for display
|
||||
ZPOOL_RAW=$(zpool list -H -p -o name,size,alloc,free,frag,cap,health "$POOL" 2>&1)
|
||||
ZPOOL_HUM=$(zpool list -H -o name,size,alloc,free,frag,cap,health "$POOL" 2>&1)
|
||||
|
||||
HEALTH=$(echo "$ZPOOL_RAW" | awk '{print $7}')
|
||||
CAP_RAW=$(echo "$ZPOOL_RAW" | awk '{print $6}')
|
||||
|
||||
SIZE_H=$(echo "$ZPOOL_HUM" | awk '{print $2}')
|
||||
ALLOC_H=$(echo "$ZPOOL_HUM" | awk '{print $3}')
|
||||
FREE_H=$(echo "$ZPOOL_HUM" | awk '{print $4}')
|
||||
FRAG_H=$(echo "$ZPOOL_HUM" | awk '{print $5}')
|
||||
CAP_H=$(echo "$ZPOOL_HUM" | awk '{print $6}')
|
||||
|
||||
# 3. zfs list -r ── per-dataset: name, used, avail, refer, mountpoint
|
||||
ZFS_LIST_RAW=$(zfs list -r -H -o name,used,avail,refer,mountpoint "$POOL" 2>&1)
|
||||
|
||||
# Build dataset table rows
|
||||
DATASET_ROWS=""
|
||||
DATASET_COUNT=0
|
||||
while IFS=$'\t' read -r DS_NAME DS_USED DS_AVAIL DS_REFER DS_MOUNT; do
|
||||
[[ -z "$DS_NAME" ]] && continue
|
||||
DATASET_COUNT=$((DATASET_COUNT + 1))
|
||||
(( DATASET_COUNT % 2 == 0 )) && ROW_SHADE="#f9fafb" || ROW_SHADE="white"
|
||||
DS_NAME_ESC=$(echo "$DS_NAME" | html_escape)
|
||||
DS_MOUNT_ESC=$(echo "$DS_MOUNT" | html_escape)
|
||||
DATASET_ROWS+="
|
||||
<tr style='background:${ROW_SHADE};'>
|
||||
<td style='padding:7px 10px;font-size:12px;color:#111827;font-family:monospace;border-bottom:1px solid #f3f4f6;'>${DS_NAME_ESC}</td>
|
||||
<td style='padding:7px 10px;font-size:12px;color:#374151;text-align:right;border-bottom:1px solid #f3f4f6;'>${DS_USED}</td>
|
||||
<td style='padding:7px 10px;font-size:12px;color:#374151;text-align:right;border-bottom:1px solid #f3f4f6;'>${DS_AVAIL}</td>
|
||||
<td style='padding:7px 10px;font-size:12px;color:#374151;text-align:right;border-bottom:1px solid #f3f4f6;'>${DS_REFER}</td>
|
||||
<td style='padding:7px 10px;font-size:12px;color:#6b7280;font-family:monospace;border-bottom:1px solid #f3f4f6;'>${DS_MOUNT_ESC}</td>
|
||||
</tr>"
|
||||
done <<< "$ZFS_LIST_RAW"
|
||||
|
||||
DATASET_TABLE="
|
||||
<details style='margin-top:14px;'>
|
||||
<summary style='cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;letter-spacing:.3px;'>▶ Datasets / Filesystems — zfs list -r (${DATASET_COUNT} entries)</summary>
|
||||
<div style='margin-top:8px;overflow-x:auto;border-radius:6px;border:1px solid #e5e7eb;'>
|
||||
<table width='100%' cellspacing='0' cellpadding='0' style='border-collapse:collapse;'>
|
||||
<thead>
|
||||
<tr style='background:#0f172a;'>
|
||||
<th style='padding:8px 10px;font-size:11px;color:#94a3b8;text-align:left;text-transform:uppercase;letter-spacing:.5px;'>Dataset</th>
|
||||
<th style='padding:8px 10px;font-size:11px;color:#94a3b8;text-align:right;text-transform:uppercase;letter-spacing:.5px;'>Used</th>
|
||||
<th style='padding:8px 10px;font-size:11px;color:#94a3b8;text-align:right;text-transform:uppercase;letter-spacing:.5px;'>Available</th>
|
||||
<th style='padding:8px 10px;font-size:11px;color:#94a3b8;text-align:right;text-transform:uppercase;letter-spacing:.5px;'>Referenced</th>
|
||||
<th style='padding:8px 10px;font-size:11px;color:#94a3b8;text-align:left;text-transform:uppercase;letter-spacing:.5px;'>Mountpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${DATASET_ROWS}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>"
|
||||
|
||||
# ── Scrub parse ────────────────────────────────────────────────────────────
|
||||
SCRUB_LINE=$(echo "$STATUS_RAW" | grep -E "scan:|scrub" | head -1 || true)
|
||||
if echo "$SCRUB_LINE" | grep -q "scrub repaired"; then
|
||||
LAST_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\w{3} \w{3} +\d+ \d+:\d+:\d+ \d{4}' | head -1 || echo "unknown")
|
||||
ERRORS_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1 || echo "0 errors")
|
||||
SCRUB_STATUS="Completed"
|
||||
elif echo "$SCRUB_LINE" | grep -q "in progress"; then
|
||||
LAST_SCRUB="In Progress"; SCRUB_STATUS="Running"; ERRORS_SCRUB="—"
|
||||
elif echo "$SCRUB_LINE" | grep -q "none requested"; then
|
||||
LAST_SCRUB="Never"; SCRUB_STATUS="Never Run"; ERRORS_SCRUB="—"
|
||||
else
|
||||
LAST_SCRUB=$(echo "$SCRUB_LINE" | sed 's/.*on //' | sed 's/ with.*//' || echo "unknown")
|
||||
SCRUB_STATUS="Completed"
|
||||
ERRORS_SCRUB=$(echo "$SCRUB_LINE" | grep -oP '\d+ errors' | head -1 || echo "0 errors")
|
||||
fi
|
||||
|
||||
# ── Severity logic ─────────────────────────────────────────────────────────
|
||||
POOL_SEVERITY="ok"
|
||||
POOL_LABEL="HEALTHY"
|
||||
|
||||
if [[ "$HEALTH" != "ONLINE" ]]; then
|
||||
POOL_SEVERITY="critical"; POOL_LABEL="$HEALTH"
|
||||
OVERALL_STATUS="CRITICAL"; PROBLEM_COUNT=$((PROBLEM_COUNT + 1))
|
||||
fi
|
||||
|
||||
CAP_NUM=${CAP_RAW//%/}; CAP_NUM=${CAP_NUM%%.*}
|
||||
if [[ "$CAP_NUM" =~ ^[0-9]+$ ]]; then
|
||||
if (( CAP_NUM >= CAP_CRIT )); then
|
||||
POOL_SEVERITY="critical"; POOL_LABEL="CRITICAL — CAPACITY ${CAP_H}"
|
||||
OVERALL_STATUS="CRITICAL"
|
||||
[[ "$HEALTH" == "ONLINE" ]] && PROBLEM_COUNT=$((PROBLEM_COUNT + 1))
|
||||
elif (( CAP_NUM >= CAP_WARN )); then
|
||||
[[ "$POOL_SEVERITY" == "ok" ]] && POOL_SEVERITY="warning"
|
||||
[[ "$POOL_LABEL" == "HEALTHY" ]] && POOL_LABEL="WARNING — CAPACITY ${CAP_H}"
|
||||
[[ "$OVERALL_STATUS" == "HEALTHY" ]] && OVERALL_STATUS="WARNING"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$LAST_SCRUB" == "Never" ]]; then
|
||||
[[ "$POOL_SEVERITY" == "ok" ]] && POOL_SEVERITY="warning"
|
||||
[[ "$POOL_LABEL" == "HEALTHY" ]] && POOL_LABEL="WARNING — NO SCRUB"
|
||||
[[ "$OVERALL_STATUS" == "HEALTHY" ]] && OVERALL_STATUS="WARNING"
|
||||
fi
|
||||
|
||||
case "$POOL_SEVERITY" in
|
||||
ok) BADGE_BG="#1a7f4b"; BADGE_TEXT="white"; ROW_BG="#f0fdf4" ;;
|
||||
warning) BADGE_BG="#b45309"; BADGE_TEXT="white"; ROW_BG="#fffbeb" ;;
|
||||
critical) BADGE_BG="#b91c1c"; BADGE_TEXT="white"; ROW_BG="#fff1f2" ;;
|
||||
esac
|
||||
|
||||
# ── Error blocks ───────────────────────────────────────────────────────────
|
||||
# a) Pool/vdev state errors
|
||||
ERROR_LINES=$(echo "$STATUS_RAW" \
|
||||
| grep -E "(DEGRADED|FAULTED|OFFLINE|REMOVED|UNAVAIL|errors:)" \
|
||||
| grep -v "errors: No known data errors" || true)
|
||||
|
||||
# b) Permanent per-file errors exposed by -v flag
|
||||
VERBOSE_ERRORS=$(echo "$STATUS_RAW" \
|
||||
| awk '/Permanent errors have been detected/,0' \
|
||||
| grep -v "^$" || true)
|
||||
|
||||
ERROR_HTML=""
|
||||
if [[ -n "$ERROR_LINES" ]]; then
|
||||
ERROR_LINES_ESC=$(echo "$ERROR_LINES" | head -30 | html_escape)
|
||||
ERROR_HTML+="
|
||||
<div style='margin-top:10px;padding:10px 14px;background:#fff1f2;border-left:4px solid #b91c1c;border-radius:4px;'>
|
||||
<div style='font-size:11px;font-weight:700;color:#b91c1c;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;'>⚠ Pool / vdev Errors</div>
|
||||
<pre style='margin:0;font-family:monospace;font-size:12px;color:#7f1d1d;white-space:pre-wrap;'>${ERROR_LINES_ESC}</pre>
|
||||
</div>"
|
||||
fi
|
||||
|
||||
if [[ -n "$VERBOSE_ERRORS" ]]; then
|
||||
VERBOSE_ESC=$(echo "$VERBOSE_ERRORS" | head -50 | html_escape)
|
||||
ERROR_HTML+="
|
||||
<div style='margin-top:10px;padding:10px 14px;background:#fef3c7;border-left:4px solid #d97706;border-radius:4px;'>
|
||||
<div style='font-size:11px;font-weight:700;color:#92400e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;'>📄 Affected Files — zpool status -v</div>
|
||||
<pre style='margin:0;font-family:monospace;font-size:12px;color:#78350f;white-space:pre-wrap;'>${VERBOSE_ESC}</pre>
|
||||
</div>"
|
||||
fi
|
||||
|
||||
# ── vdev config tree ───────────────────────────────────────────────────────
|
||||
VDEV_TREE=$(echo "$STATUS_RAW" \
|
||||
| sed -n '/config:/,/errors:/p' \
|
||||
| grep -v "^$" | head -60 | html_escape || true)
|
||||
|
||||
# ── Assemble pool card ─────────────────────────────────────────────────────
|
||||
POOL_BLOCKS+="
|
||||
<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:${ROW_BG};padding:16px 22px;border-bottom:1px solid #e5e7eb;'>
|
||||
<table width='100%' cellspacing='0' cellpadding='0'><tr>
|
||||
<td><span style='font-size:18px;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:${BADGE_BG};color:${BADGE_TEXT};padding:5px 14px;border-radius:20px;font-size:12px;font-weight:700;letter-spacing:.5px;'>${POOL_LABEL}</span></td>
|
||||
</tr></table>
|
||||
</div>
|
||||
|
||||
<div style='padding:20px 22px;'>
|
||||
|
||||
<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_H}</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;'>Used</div>
|
||||
<div style='font-size:20px;font-weight:700;color:#111827;margin-top:2px;'>${ALLOC_H}</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_H}</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:#111827;margin-top:2px;'>${CAP_H}</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_H}</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:#111827;margin-top:2px;'>${HEALTH}</div>
|
||||
</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;'>${SCRUB_STATUS} | ${LAST_SCRUB} | Errors: ${ERRORS_SCRUB}</span>
|
||||
</div>
|
||||
|
||||
${ERROR_HTML}
|
||||
|
||||
<details style='margin-top:14px;'>
|
||||
<summary style='cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;letter-spacing:.3px;'>▶ vdev / Drive Configuration — zpool status -v</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;white-space:pre;'>${VDEV_TREE}</pre>
|
||||
</details>
|
||||
|
||||
${DATASET_TABLE}
|
||||
|
||||
</div>
|
||||
</div>"
|
||||
|
||||
done <<< "$POOL_LIST"
|
||||
|
||||
# ── Overall banner ─────────────────────────────────────────────────────────────
|
||||
case "$OVERALL_STATUS" in
|
||||
HEALTHY) BANNER_BG="#1a7f4b"; BANNER_ICON="✅"; BANNER_MSG="All pools are healthy — no action required." ;;
|
||||
WARNING) BANNER_BG="#b45309"; BANNER_ICON="⚠️"; BANNER_MSG="One or more pools require your attention." ;;
|
||||
CRITICAL) BANNER_BG="#b91c1c"; BANNER_ICON="🚨"; BANNER_MSG="CRITICAL issue detected — immediate action required!" ;;
|
||||
esac
|
||||
|
||||
SUBJECT="[ZFS] ${TARGET_HOST} — ${OVERALL_STATUS} — ${NOW}"
|
||||
|
||||
# ── Build HTML email ───────────────────────────────────────────────────────────
|
||||
HTML=$(cat <<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="720" cellspacing="0" cellpadding="0" style="max-width:720px;width:100%;">
|
||||
|
||||
<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;">🔌 ${TARGET_HOST}</div>
|
||||
<div style="font-size:13px;color:#94a3b8;margin-top:4px;">${NOW} | ${POOL_COUNT} pool(s) monitored</div>
|
||||
</td>
|
||||
<td align="right">
|
||||
<div style="background:${BANNER_BG};color:white;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:700;">${BANNER_ICON} ${OVERALL_STATUS}</div>
|
||||
</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="background:${BANNER_BG};padding:12px 32px;">
|
||||
<span style="color:white;font-size:14px;font-weight:600;">${BANNER_ICON} ${BANNER_MSG}</span>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:28px 24px 8px;">${POOL_BLOCKS}</td></tr>
|
||||
|
||||
<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:12px;text-transform:uppercase;letter-spacing:.5px;">📋 Report Summary</div>
|
||||
<table width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr><td style="font-size:13px;color:#6b7280;padding-bottom:6px;">Host</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;padding-bottom:6px;">${TARGET_HOST} (${HOSTNAME_REAL})</td></tr>
|
||||
<tr><td style="font-size:13px;color:#6b7280;padding-bottom:6px;">Report Time</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;padding-bottom:6px;">${NOW}</td></tr>
|
||||
<tr><td style="font-size:13px;color:#6b7280;padding-bottom:6px;">Pools Checked</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;padding-bottom:6px;">${POOL_COUNT}</td></tr>
|
||||
<tr><td style="font-size:13px;color:#6b7280;padding-bottom:6px;">Pools with Issues</td>
|
||||
<td style="font-size:13px;font-weight:700;color:$([ "$PROBLEM_COUNT" -gt 0 ] && echo '#b91c1c' || echo '#1a7f4b');padding-bottom:6px;">${PROBLEM_COUNT}</td></tr>
|
||||
<tr><td style="font-size:13px;color:#6b7280;padding-bottom:6px;">Capacity Warning Threshold</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;padding-bottom:6px;">≥ ${CAP_WARN}%</td></tr>
|
||||
<tr><td style="font-size:13px;color:#6b7280;padding-bottom:6px;">Capacity Critical Threshold</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;padding-bottom:6px;">≥ ${CAP_CRIT}%</td></tr>
|
||||
<tr><td style="font-size:13px;color:#6b7280;">Scrub Age Warning</td>
|
||||
<td style="font-size:13px;color:#111827;font-weight:600;">> ${SCRUB_MAX_AGE_DAYS} days</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:14px;padding:10px 12px;background:#f8fafc;border-radius:6px;border:1px solid #e5e7eb;">
|
||||
<div style="font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px;">Commands Executed</div>
|
||||
<code style="font-size:12px;color:#374151;line-height:2;">
|
||||
zpool status -v <pool><br>
|
||||
zpool list -H -o name,size,alloc,free,frag,cap,health <pool><br>
|
||||
zfs list -r -H -o name,used,avail,refer,mountpoint <pool>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<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 | ${TARGET_HOST} | KingDezigns Infrastructure</span>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
)
|
||||
|
||||
# ── Send email ─────────────────────────────────────────────────────────────────
|
||||
send_email() {
|
||||
if command -v sendmail &>/dev/null; then
|
||||
{ echo "To: ${REPORT_TO}"; echo "From: ${REPORT_FROM}"; echo "Subject: ${SUBJECT}"
|
||||
echo "MIME-Version: 1.0"; echo "Content-Type: text/html; charset=UTF-8"; echo ""
|
||||
echo "$HTML"; } | sendmail -t
|
||||
echo "Report sent via sendmail to ${REPORT_TO}"
|
||||
elif command -v mailx &>/dev/null; then
|
||||
echo "$HTML" | mailx -a "Content-Type: text/html" -s "$SUBJECT" "$REPORT_TO"
|
||||
echo "Report sent via mailx to ${REPORT_TO}"
|
||||
elif command -v mail &>/dev/null; then
|
||||
echo "$HTML" | mail -a "Content-Type: text/html; charset=UTF-8" -s "$SUBJECT" "$REPORT_TO"
|
||||
echo "Report sent via mail to ${REPORT_TO}"
|
||||
else
|
||||
echo "ERROR: No mail transport found. Install sendmail, mailx, or msmtp."
|
||||
FALLBACK="/tmp/zfs_report_${TARGET_HOST}_$(date +%Y%m%d_%H%M%S).html"
|
||||
echo "$HTML" > "$FALLBACK"
|
||||
echo "HTML report saved to: $FALLBACK"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
send_email
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ZFS report for ${TARGET_HOST} — Status: ${OVERALL_STATUS} — Pools: ${POOL_COUNT} — Problems: ${PROBLEM_COUNT}"
|
||||
322
zfs/nas08_zfs_scrub-1.sh
Normal file
322
zfs/nas08_zfs_scrub-1.sh
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
#!/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/</\</g; s/>/\>/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/</\</g; s/>/\>/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 | $SCRUB_DATE | $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;\">🚨 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;\">⚠️ 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;\">✅ 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 | $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 | $HOSTNAME | 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 ====="
|
||||
295
zfs/nas08_zfs_scrub.sh
Normal file
295
zfs/nas08_zfs_scrub.sh
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
#!/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"
|
||||
|
||||
# Wait for scrub to finish
|
||||
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 HTML report ──────────────────────────────────────────
|
||||
|
||||
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 "^$")
|
||||
|
||||
# Determine 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 from status
|
||||
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))
|
||||
|
||||
# Health value color
|
||||
case "$STATUS_RAW" in
|
||||
ONLINE) HEALTH_COLOR="#1a7f4b" ;;
|
||||
DEGRADED) HEALTH_COLOR="#b91c1c" ;;
|
||||
FAULTED) HEALTH_COLOR="#b91c1c" ;;
|
||||
*) 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" ;;
|
||||
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=""
|
||||
if [[ -n "$ISSUE_DETAIL" ]]; then
|
||||
ESCAPED_DETAIL=$(echo "$ISSUE_DETAIL" | sed 's/</\</g; s/>/\>/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>"
|
||||
fi
|
||||
|
||||
# Config block
|
||||
ESCAPED_CONFIG=$(echo "$CONFIG_BLOCK" | sed 's/</\</g; s/>/\>/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;\">
|
||||
<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>
|
||||
<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>
|
||||
</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 | $SCRUB_DATE | $SCRUB_ERRORS$SCRUB_AGE_NOTE</span>
|
||||
</div>
|
||||
$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>
|
||||
<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 ────────────────────────────────────────────────────────────
|
||||
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;\">🚨 CRITICAL issue detected — immediate action required!</span></td></tr>"
|
||||
;;
|
||||
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;\">⚠️ Warning condition detected — review recommended.</span></td></tr>"
|
||||
;;
|
||||
*)
|
||||
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;\">✅ All pools are healthy — no action required.</span></td></tr>"
|
||||
;;
|
||||
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%;">
|
||||
|
||||
<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 | $POOL_COUNT pool(s) monitored</div>
|
||||
</td>
|
||||
<td align="right">$BADGE_HTML</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
$BANNER_HTML
|
||||
|
||||
<tr><td style="padding:28px 24px 8px;">
|
||||
$POOL_CARDS_HTML
|
||||
</td></tr>
|
||||
|
||||
<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:#b91c1c;">$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>
|
||||
</table>
|
||||
</div>
|
||||
</td></tr>
|
||||
|
||||
<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 | $HOSTNAME | 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 ====="
|
||||
11
zfs_report_nas08_howto.md
Normal file
11
zfs_report_nas08_howto.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
1. SSH into NAS08
|
||||
2. cd /usr/scripts/
|
||||
3. sudo mkdir zfs
|
||||
4. sudo nano zfs_report_nas08.sh
|
||||
5. Copy the .sh file here
|
||||
6. Save and close the file
|
||||
7. cd /usr/scripts/zfs
|
||||
8. chmod +x zfs_report_nas08.sh
|
||||
9. run the script to test
|
||||
|
||||
/usr/scripts/zfs_report_nas08.sh
|
||||
Loading…
Add table
Reference in a new issue