diff --git a/.dockerignore b/.dockerignore
index b0917de..df7066d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,8 @@
+frontend/node_modules
+frontend/.vite
+static/
__pycache__
*.pyc
.git
-.venv
+.env
+*.md
diff --git a/Dockerfile b/Dockerfile
index 24bae9b..ae371b5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..880663f
--- /dev/null
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..c99a44d
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ JBOD Monitor
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..aa6a1b8
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..6778c8e
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -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 Loading...
;
+ if (error && !overview) return Error: {error}
;
+
+ return (
+
+
+
+ {overview && (
+
+
+ 0 ? '#f59e0b' : undefined} />
+ 0 ? '#ef4444' : undefined} />
+
+
+ )}
+
+ {overview?.enclosures.map((enc) => (
+
+ ))}
+
+ {selectedDrive && (
+
{ setSelectedDrive(null); setDriveDetail(null); }}
+ />
+ )}
+
+ );
+}
+
+function StatusBadge({ healthy }) {
+ const color = healthy ? '#22c55e' : '#ef4444';
+ const text = healthy ? 'HEALTHY' : 'DEGRADED';
+ return (
+ {text}
+ );
+}
+
+function SummaryCard({ label, value, color }) {
+ return (
+
+ );
+}
+
+function EnclosureCard({ enclosure, onDriveClick, selectedDrive }) {
+ const [expanded, setExpanded] = useState(true);
+ const enc = enclosure;
+
+ return (
+
+
setExpanded(!expanded)}>
+
+
+ {enc.vendor.trim()} {enc.model.trim()}
+
+ {enc.id}
+ {enc.sg_device && {enc.sg_device}}
+
+
+ {enc.populated_slots}/{enc.total_slots} slots
+ {expanded ? '\u25BC' : '\u25B6'}
+
+
+
+ {expanded && (
+
+ {enc.slots.map((slot) => (
+ slot.device && onDriveClick(slot.device)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+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 (
+
+
{slot.slot}
+ {slot.device &&
{slot.device}
}
+ {slot.drive && (
+
+ {slot.drive.temperature_c != null ? `${slot.drive.temperature_c}C` : ''}
+
+ )}
+
+ );
+}
+
+function DriveDetailPanel({ device, detail, onClose }) {
+ return (
+
+
e.stopPropagation()}>
+
+
/dev/{device}
+
+
+ {!detail ? (
+
Loading SMART data...
+ ) : detail.error ? (
+
Error: {detail.error}
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ {detail.wear_leveling_percent != null && (
+
+ )}
+ {detail.zfs_pool && }
+
+ )}
+
+
+ );
+}
+
+function DetailRow({ label, value }) {
+ if (value == null) return null;
+ return (
+
+ {label}
+ {String(value)}
+
+ );
+}
+
+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;
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..d76b758
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+);
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..bfd18c6
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: '../static',
+ emptyOutDir: true,
+ },
+});
diff --git a/main.py b/main.py
index b674ab0..99cb57e 100644
--- a/main.py
+++ b/main.py
@@ -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")
diff --git a/models/schemas.py b/models/schemas.py
index 2c2359d..b3c18b3 100644
--- a/models/schemas.py
+++ b/models/schemas.py
@@ -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):
diff --git a/routers/drives.py b/routers/drives.py
index 62695a4..955335f 100644
--- a/routers/drives.py
+++ b/routers/drives.py
@@ -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)
diff --git a/routers/overview.py b/routers/overview.py
index 13ea7ff..5e22de9 100644
--- a/routers/overview.py
+++ b/routers/overview.py
@@ -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
diff --git a/services/zfs.py b/services/zfs.py
new file mode 100644
index 0000000..c0aee1d
--- /dev/null
+++ b/services/zfs.py
@@ -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