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 ( +
+
+

JBOD Monitor

+
+ + {lastUpdated && ( + + Updated {lastUpdated.toLocaleTimeString()} + + )} + +
+
+ + {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 ( +
+
{value}
+
{label}
+
+ ); +} + +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