This commit is contained in:
2026-03-24 14:32:16 -04:00
parent 8a9f5f2569
commit 43838eb666
3 changed files with 14 additions and 3 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
# ZPulse - Developed by acidvegas in Python (https://github.com/acidvegas/rackwatch)
# zpulse/.gitignore
venv/
venv/

View File

@@ -2,22 +2,29 @@
Real-time ZFS & disk monitoring for home server racks. Built to watch over multiple nodes with dozens of drives, streaming SMART health, ZFS pool status, I/O rates, and temperatures to a single dashboard over WebSocket. Alerts go to Gotify.
## Why
Every time I looked for a way to keep tabs on disk health across all the nodes in my home rack, the answer was always the same stack: Grafana, Telegraf, Prometheus, maybe throw InfluxDB in there too. That is an absurd amount of infrastructure just to answer "are my drives dying?" I didn't need time-series databases and query languages and dashboarding frameworks. I needed something that tells me if a disk is getting hot, if a ZFS pool is degraded, or if SMART errors are creeping up, across every machine, in one place.
Nothing out there was built for this. Everything either does way too much or only monitors the local machine. So I wrote ZPulse. It is purpose-built for home racks: lightweight agents that stream disk and ZFS telemetry over a single WebSocket connection to one central dashboard. No metric pipelines, no config files longer than the code itself, no containers, no databases. Just a Python agent on each node and a dashboard on whatever box you have lying around.
## Preview
###### Overview
![](./.screens/preview.png)
###### Individual Node Monitoring
![](./.screens/preview2.png)
###### SMART Metadata parsing
![](./.screens/preview3.png)
## Architecture
```
@@ -30,6 +37,7 @@ Nothing out there was built for this. Everything either does way too much or onl
- `dashboard.py` runs on a central machine *(Raspberry Pi, NUC, whatever)*, aggregates data from all agents, & serves the web UI
- All data flows over persistent WebSocket connections, no polling
## Dashboard Setup
Installs to `/opt/zpulse-dashboard`. No root required at runtime, just for the setup itself.
@@ -77,10 +85,12 @@ pip install -r requirements.txt
sudo ./venv/bin/python agent.py ws://DASHBOARD_IP:8888/ws/agent
```
## Gotify Notifications
Open the dashboard in a browser, click Settings. Enter your Gotify server URL and app token, hit Test, then Save. Alert thresholds for temperature, space usage, SMART failures, and pool health are all configured from the same panel.
## What It Monitors
- Fleet overview with all connected servers, health status, storage usage, alert counts

View File

@@ -191,6 +191,7 @@ const latCol=ms=>ms>50?'var(--red)':ms>20?'var(--yellow)':ms>0?'var(--text)':'va
const busyCol=p=>p>80?'var(--red)':p>40?'var(--yellow)':p>5?'var(--text)':'var(--text2)';
const scoreCol=s=>s>=80?'var(--green)':s>=50?'var(--yellow)':'var(--red)';
const scoreBg=s=>s>=80?'rgba(34,197,94,.12)':s>=50?'rgba(234,179,8,.12)':'rgba(239,68,68,.12)';
const scoreLabel=s=>{if(typeof s!=='number')return'';return s>=90?'Excellent':s>=80?'Good':s>=50?'Fair':s>=25?'Poor':'Critical'};
const relTime=ts=>{const s=Math.floor(Date.now()/1000-ts);if(s<60)return'just now';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';return Math.floor(s/86400)+'d ago'};
const fmtUptime=sec=>{if(!sec)return'';const d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600);return d>0?d+'d '+h+'h':h+'h '+Math.floor((sec%3600)/60)+'m'};
const SEAGATE=new Set([1,7,195]);
@@ -368,7 +369,7 @@ function renderDisks(disks){
const ht=d.health===true?'PASSED':d.health===false?'FAILED':'N/A';
const hs=d.health_score!=null?d.health_score:'-';
return`<div class="card" style="padding:1rem">
<div class="disk-header"><div><span class="disk-name">/dev/${d.name}</span>${d.pool?`<span style="font-size:.65rem;color:var(--text2);margin-left:.35rem">${d.pool}</span>`:''}</div><div style="display:flex;gap:.25rem;align-items:center"><span class="score-badge" style="background:${scoreBg(hs)};color:${scoreCol(hs)}">${hs}</span><span class="${dtc}">${proto||'?'}</span><span class="hb ${d.health===true?'h-on':d.health===false?'h-flt':'h-unk'}">${ht}</span></div></div>
<div class="disk-header"><div><span class="disk-name">/dev/${d.name}</span>${d.pool?`<span style="font-size:.65rem;color:var(--text2);margin-left:.35rem">${d.pool}</span>`:''}</div><div style="display:flex;gap:.25rem;align-items:center"><span class="score-badge" title="Health Score: ${hs}/100 — ${scoreLabel(hs)}" style="background:${scoreBg(hs)};color:${scoreCol(hs)}">${hs}/100 ${scoreLabel(hs)}</span><span class="${dtc}">${proto||'?'}</span><span class="hb ${d.health===true?'h-on':d.health===false?'h-flt':'h-unk'}">${ht}</span></div></div>
<div class="disk-stats">
<div class="ds"><div class="ds-l">Capacity</div><div class="ds-v">${B(d.user_capacity||d.size)}</div></div>
<div class="ds"><div class="ds-l">Temp</div><div class="ds-v ${tc(d.temperature)}">${d.temperature!=null?d.temperature+'°C':'—'}</div></div>
@@ -400,7 +401,7 @@ function openSmartModal(idx){
}
if(d.grown_defect_count!=null)sasHtml+=`<div style="margin-top:.4rem;font-size:.75rem"><strong>Grown Defects:</strong> <span style="color:${d.grown_defect_count>0?'var(--red)':'var(--green)'};font-weight:600">${d.grown_defect_count}</span></div>`;
document.getElementById('smart-modal-content').innerHTML=`
<h2><span>/dev/${d.name}</span><span class="score-badge" style="background:${scoreBg(hs)};color:${scoreCol(hs)}">${hs}/100</span><span class="${dtc}">${proto}</span><span class="hb ${d.health===true?'h-on':d.health===false?'h-flt':'h-unk'}">${ht}</span></h2>
<h2><span>/dev/${d.name}</span><span class="score-badge" title="Health Score: ${hs}/100 — ${scoreLabel(hs)}" style="background:${scoreBg(hs)};color:${scoreCol(hs)}">${hs}/100 ${scoreLabel(hs)}</span><span class="${dtc}">${proto}</span><span class="hb ${d.health===true?'h-on':d.health===false?'h-flt':'h-unk'}">${ht}</span></h2>
<div class="si-grid">
<div class="si-item"><div class="si-l">Model</div><div class="si-v">${d.device_model||d.model||'—'}</div></div>
<div class="si-item"><div class="si-l">Serial</div><div class="si-v">${d.serial_number||d.serial||'—'}</div></div>