Add React frontend, ZFS pool mapping, and multi-stage Docker build

- Vite + React frontend with dark-themed dashboard, slot grid per
  enclosure, and SMART detail overlay
- ZFS pool membership via zpool status -P
- Multi-stage Dockerfile (Node build + Python runtime)
- Updated docker-compose with network_mode host and healthcheck
This commit is contained in:
2026-03-07 03:04:23 +00:00
parent e2bd413041
commit 7beead8cae
13 changed files with 554 additions and 6 deletions

View File

@@ -1,4 +1,8 @@
frontend/node_modules
frontend/.vite
static/
__pycache__
*.pyc
.git
.venv
.env
*.md

View File

@@ -1,16 +1,39 @@
# ---- Stage 1: Build frontend ----
FROM node:22-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Output: /app/static/
# ---- Stage 2: Production runtime ----
FROM python:3.13-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends smartmontools sg3-utils && \
rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
smartmontools \
sg3-utils \
lsscsi \
util-linux \
zfsutils-linux \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY main.py .
COPY routers/ routers/
COPY services/ services/
COPY models/ models/
# Copy built frontend from stage 1
COPY --from=frontend-build /app/static/ static/
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
jbod-monitor:
build: .
image: docker.adamksmith.xyz/jbod-monitor:latest
container_name: jbod-monitor
restart: unless-stopped
privileged: true
network_mode: host
volumes:
- /dev:/dev
- /sys:/sys:ro
- /run/udev:/run/udev:ro
environment:
- TZ=America/Denver
- UVICORN_LOG_LEVEL=info

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JBOD Monitor</title>
<style>* { margin: 0; padding: 0; box-sizing: border-box; }</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

16
frontend/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "jbod-monitor-frontend",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.0"
}
}

389
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,389 @@
import React, { useState, useEffect, useCallback } from 'react';
const API_BASE = "";
function App() {
const [overview, setOverview] = useState(null);
const [selectedDrive, setSelectedDrive] = useState(null);
const [driveDetail, setDriveDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const fetchOverview = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/overview`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setOverview(data);
setError(null);
setLastUpdated(new Date());
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchOverview();
const interval = setInterval(fetchOverview, 30000);
return () => clearInterval(interval);
}, [fetchOverview]);
const fetchDriveDetail = async (device) => {
setSelectedDrive(device);
setDriveDetail(null);
try {
const res = await fetch(`${API_BASE}/api/drives/${device}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setDriveDetail(await res.json());
} catch (err) {
setDriveDetail({ error: err.message });
}
};
if (loading) return <div style={styles.loading}>Loading...</div>;
if (error && !overview) return <div style={styles.error}>Error: {error}</div>;
return (
<div style={styles.container}>
<header style={styles.header}>
<h1 style={styles.title}>JBOD Monitor</h1>
<div style={styles.headerRight}>
<StatusBadge healthy={overview?.healthy} />
{lastUpdated && (
<span style={styles.timestamp}>
Updated {lastUpdated.toLocaleTimeString()}
</span>
)}
<button onClick={fetchOverview} style={styles.refreshBtn}>Refresh</button>
</div>
</header>
{overview && (
<div style={styles.summaryBar}>
<SummaryCard label="Drives" value={overview.drive_count} />
<SummaryCard label="Warnings" value={overview.warning_count} color={overview.warning_count > 0 ? '#f59e0b' : undefined} />
<SummaryCard label="Errors" value={overview.error_count} color={overview.error_count > 0 ? '#ef4444' : undefined} />
<SummaryCard label="Enclosures" value={overview.enclosures.length} />
</div>
)}
{overview?.enclosures.map((enc) => (
<EnclosureCard
key={enc.id}
enclosure={enc}
onDriveClick={fetchDriveDetail}
selectedDrive={selectedDrive}
/>
))}
{selectedDrive && (
<DriveDetailPanel
device={selectedDrive}
detail={driveDetail}
onClose={() => { setSelectedDrive(null); setDriveDetail(null); }}
/>
)}
</div>
);
}
function StatusBadge({ healthy }) {
const color = healthy ? '#22c55e' : '#ef4444';
const text = healthy ? 'HEALTHY' : 'DEGRADED';
return (
<span style={{ ...styles.badge, backgroundColor: color }}>{text}</span>
);
}
function SummaryCard({ label, value, color }) {
return (
<div style={styles.summaryCard}>
<div style={{ ...styles.summaryValue, color: color || '#e2e8f0' }}>{value}</div>
<div style={styles.summaryLabel}>{label}</div>
</div>
);
}
function EnclosureCard({ enclosure, onDriveClick, selectedDrive }) {
const [expanded, setExpanded] = useState(true);
const enc = enclosure;
return (
<div style={styles.enclosure}>
<div style={styles.enclosureHeader} onClick={() => setExpanded(!expanded)}>
<div>
<span style={styles.enclosureTitle}>
{enc.vendor.trim()} {enc.model.trim()}
</span>
<span style={styles.enclosureId}>{enc.id}</span>
{enc.sg_device && <span style={styles.enclosureSg}>{enc.sg_device}</span>}
</div>
<div style={styles.slotCount}>
{enc.populated_slots}/{enc.total_slots} slots
<span style={styles.expandIcon}>{expanded ? '\u25BC' : '\u25B6'}</span>
</div>
</div>
{expanded && (
<div style={styles.slotGrid}>
{enc.slots.map((slot) => (
<SlotCell
key={slot.slot}
slot={slot}
selected={selectedDrive === slot.device}
onClick={() => slot.device && onDriveClick(slot.device)}
/>
))}
</div>
)}
</div>
);
}
function SlotCell({ slot, selected, onClick }) {
let bg = '#1e293b'; // empty
let border = '#334155';
if (slot.populated && slot.drive) {
if (slot.drive.smart_healthy === false) {
bg = '#7f1d1d'; border = '#ef4444';
} else if (slot.drive.smart_healthy === null) {
bg = '#78350f'; border = '#f59e0b';
} else {
bg = '#14532d'; border = '#22c55e';
}
} else if (slot.populated) {
bg = '#1e3a5f'; border = '#3b82f6';
}
if (selected) border = '#fff';
return (
<div
style={{
...styles.slot,
backgroundColor: bg,
borderColor: border,
cursor: slot.device ? 'pointer' : 'default',
}}
onClick={onClick}
title={slot.device ? `${slot.device} - Slot ${slot.slot}` : `Slot ${slot.slot} (empty)`}
>
<div style={styles.slotNum}>{slot.slot}</div>
{slot.device && <div style={styles.slotDev}>{slot.device}</div>}
{slot.drive && (
<div style={styles.slotTemp}>
{slot.drive.temperature_c != null ? `${slot.drive.temperature_c}C` : ''}
</div>
)}
</div>
);
}
function DriveDetailPanel({ device, detail, onClose }) {
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.detailPanel} onClick={(e) => e.stopPropagation()}>
<div style={styles.detailHeader}>
<h2 style={styles.detailTitle}>/dev/{device}</h2>
<button onClick={onClose} style={styles.closeBtn}>X</button>
</div>
{!detail ? (
<div style={styles.loading}>Loading SMART data...</div>
) : detail.error ? (
<div style={styles.error}>Error: {detail.error}</div>
) : (
<div style={styles.detailBody}>
<DetailRow label="Model" value={detail.model} />
<DetailRow label="Serial" value={detail.serial} />
<DetailRow label="WWN" value={detail.wwn} />
<DetailRow label="Firmware" value={detail.firmware} />
<DetailRow label="Capacity" value={detail.capacity_bytes ? formatBytes(detail.capacity_bytes) : null} />
<DetailRow label="SMART Healthy" value={detail.smart_healthy == null ? 'Unknown' : detail.smart_healthy ? 'Yes' : 'NO'} />
<DetailRow label="Temperature" value={detail.temperature_c != null ? `${detail.temperature_c} C` : null} />
<DetailRow label="Power-On Hours" value={detail.power_on_hours != null ? detail.power_on_hours.toLocaleString() : null} />
<DetailRow label="Reallocated Sectors" value={detail.reallocated_sectors} />
<DetailRow label="Pending Sectors" value={detail.pending_sectors} />
<DetailRow label="Uncorrectable Errors" value={detail.uncorrectable_errors} />
{detail.wear_leveling_percent != null && (
<DetailRow label="Wear Leveling" value={`${detail.wear_leveling_percent}%`} />
)}
{detail.zfs_pool && <DetailRow label="ZFS Pool" value={detail.zfs_pool} />}
</div>
)}
</div>
</div>
);
}
function DetailRow({ label, value }) {
if (value == null) return null;
return (
<div style={styles.detailRow}>
<span style={styles.detailLabel}>{label}</span>
<span style={styles.detailValue}>{String(value)}</span>
</div>
);
}
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let i = 0;
let val = bytes;
while (val >= 1000 && i < units.length - 1) { val /= 1000; i++; }
return `${val.toFixed(1)} ${units[i]}`;
}
const styles = {
container: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace',
backgroundColor: '#0f172a',
color: '#e2e8f0',
minHeight: '100vh',
padding: '20px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
paddingBottom: '16px',
borderBottom: '1px solid #334155',
},
title: { fontSize: '24px', fontWeight: 'bold', color: '#f8fafc' },
headerRight: { display: 'flex', alignItems: 'center', gap: '12px' },
badge: {
padding: '4px 12px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
color: '#fff',
},
timestamp: { fontSize: '12px', color: '#94a3b8' },
refreshBtn: {
padding: '6px 14px',
backgroundColor: '#334155',
color: '#e2e8f0',
border: '1px solid #475569',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
},
summaryBar: {
display: 'flex',
gap: '16px',
marginBottom: '24px',
},
summaryCard: {
backgroundColor: '#1e293b',
padding: '16px 24px',
borderRadius: '8px',
border: '1px solid #334155',
textAlign: 'center',
flex: 1,
},
summaryValue: { fontSize: '28px', fontWeight: 'bold' },
summaryLabel: { fontSize: '12px', color: '#94a3b8', marginTop: '4px', textTransform: 'uppercase' },
enclosure: {
backgroundColor: '#1e293b',
borderRadius: '8px',
border: '1px solid #334155',
marginBottom: '16px',
overflow: 'hidden',
},
enclosureHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '14px 18px',
cursor: 'pointer',
borderBottom: '1px solid #334155',
},
enclosureTitle: { fontSize: '16px', fontWeight: 'bold', color: '#f8fafc' },
enclosureId: { fontSize: '12px', color: '#64748b', marginLeft: '12px' },
enclosureSg: { fontSize: '12px', color: '#64748b', marginLeft: '8px' },
slotCount: { fontSize: '14px', color: '#94a3b8' },
expandIcon: { marginLeft: '8px', fontSize: '10px' },
slotGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(70px, 1fr))',
gap: '6px',
padding: '12px',
},
slot: {
border: '2px solid',
borderRadius: '6px',
padding: '6px',
textAlign: 'center',
minHeight: '60px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
transition: 'border-color 0.15s',
},
slotNum: { fontSize: '11px', color: '#94a3b8' },
slotDev: { fontSize: '12px', fontWeight: 'bold', color: '#f8fafc' },
slotTemp: { fontSize: '10px', color: '#94a3b8' },
overlay: {
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
detailPanel: {
backgroundColor: '#1e293b',
border: '1px solid #475569',
borderRadius: '12px',
padding: '24px',
width: '450px',
maxHeight: '80vh',
overflowY: 'auto',
},
detailHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
borderBottom: '1px solid #334155',
paddingBottom: '12px',
},
detailTitle: { fontSize: '18px', color: '#f8fafc' },
closeBtn: {
background: 'none',
border: 'none',
color: '#94a3b8',
fontSize: '18px',
cursor: 'pointer',
},
detailBody: {},
detailRow: {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #1e293b',
},
detailLabel: { color: '#94a3b8', fontSize: '13px' },
detailValue: { color: '#f8fafc', fontSize: '13px', fontWeight: '500' },
loading: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontSize: '18px',
color: '#94a3b8',
},
error: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontSize: '18px',
color: '#ef4444',
},
};
export default App;

9
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

10
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: '../static',
emptyOutDir: true,
},
});

18
main.py
View File

@@ -1,8 +1,11 @@
import logging
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from models.schemas import HealthCheck
from routers import drives, enclosures, overview
@@ -50,3 +53,18 @@ async def check_dependencies():
@app.get("/api/health", response_model=HealthCheck, tags=["health"])
async def health():
return HealthCheck(status="ok", tools=_tool_status)
# Serve built frontend static files (must be after all /api routes)
STATIC_DIR = Path(__file__).parent / "static"
if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Catch-all: serve index.html for SPA routing."""
file_path = STATIC_DIR / full_path
if file_path.is_file():
return FileResponse(file_path)
return FileResponse(STATIC_DIR / "index.html")

View File

@@ -32,6 +32,7 @@ class DriveDetail(BaseModel):
pending_sectors: int | None = None
uncorrectable_errors: int | None = None
wear_leveling_percent: int | None = None
zfs_pool: str | None = None
smart_attributes: list[dict] = []
@@ -43,6 +44,7 @@ class DriveHealthSummary(BaseModel):
smart_supported: bool = True
temperature_c: int | None = None
power_on_hours: int | None = None
zfs_pool: str | None = None
class SlotWithDrive(BaseModel):

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException
from models.schemas import DriveDetail
from services.smart import get_smart_data
from services.zfs import get_zfs_pool_map
router = APIRouter(prefix="/api/drives", tags=["drives"])
@@ -17,4 +18,7 @@ async def get_drive_detail(device: str):
if "error" in data:
raise HTTPException(status_code=502, detail=data["error"])
pool_map = await get_zfs_pool_map()
data["zfs_pool"] = pool_map.get(device)
return DriveDetail(**data)

View File

@@ -11,6 +11,7 @@ from models.schemas import (
)
from services.enclosure import discover_enclosures, list_slots
from services.smart import get_smart_data
from services.zfs import get_zfs_pool_map
logger = logging.getLogger(__name__)
@@ -21,6 +22,7 @@ router = APIRouter(prefix="/api/overview", tags=["overview"])
async def get_overview():
"""Aggregate view of all enclosures, slots, and drive health."""
enclosures_raw = discover_enclosures()
pool_map = await get_zfs_pool_map()
enc_results: list[EnclosureWithDrives] = []
total_drives = 0
@@ -74,6 +76,7 @@ async def get_overview():
smart_supported=sd.get("smart_supported", True),
temperature_c=sd.get("temperature_c"),
power_on_hours=sd.get("power_on_hours"),
zfs_pool=pool_map.get(sd["device"]),
)
elif s["populated"]:
total_drives += 1

42
services/zfs.py Normal file
View File

@@ -0,0 +1,42 @@
import asyncio
import os
import logging
logger = logging.getLogger(__name__)
async def get_zfs_pool_map() -> dict[str, str]:
"""Return a dict mapping device names to ZFS pool names.
e.g. {"sda": "tank", "sdb": "tank", "sdc": "fast"}
"""
pool_map = {}
try:
proc = await asyncio.create_subprocess_exec(
"zpool", "status", "-P",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return pool_map
current_pool = None
for line in stdout.decode().splitlines():
stripped = line.strip()
if stripped.startswith("pool:"):
current_pool = stripped.split(":", 1)[1].strip()
elif current_pool and "/dev/" in stripped:
parts = stripped.split()
dev_path = parts[0]
try:
real = os.path.realpath(dev_path)
dev_name = os.path.basename(real)
# Strip partition numbers (sda1 -> sda)
dev_name = dev_name.rstrip("0123456789")
pool_map[dev_name] = current_pool
except Exception:
pass
except FileNotFoundError:
logger.debug("zpool not available")
return pool_map