From 861f9279c489e6a6b7036fa60fcf3a5c6f86a5ef Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 7 Mar 2026 03:41:35 +0000 Subject: [PATCH] Implement full JBOD monitor frontend from design JSX - Dark/light theme toggle, grid/table view toggle - Expanded DriveHealthSummary with wwn, firmware, capacity, SMART counters, and computed health_status field - Drive detail modal with identity, WWN, ZFS membership, SMART health - 30s auto-refresh --- frontend/src/App.jsx | 997 +++++++++++++++++++++++++++---------------- models/schemas.py | 7 + routers/overview.py | 18 + 3 files changed, 662 insertions(+), 360 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6778c8e..ea16bfe 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,23 +1,532 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from "react"; const API_BASE = ""; +const USE_MOCK = false; +const REFRESH_INTERVAL = 30000; -function App() { - const [overview, setOverview] = useState(null); - const [selectedDrive, setSelectedDrive] = useState(null); - const [driveDetail, setDriveDetail] = useState(null); +// --- Theme definitions --- +const themes = { + dark: { + bg: "linear-gradient(160deg, #0f0f1a 0%, #141422 50%, #0d0d18 100%)", + surface: "#1a1a2e", + surfaceBorder: "#2a2a40", + surfaceHover: "#222238", + cardBg: "#16162a", + cardBorder: "#252540", + text: "#e8e8f0", + textSecondary: "#8888a8", + textMuted: "#5a5a78", + mono: "#c4c4e0", + accent: "#a78bfa", + divider: "#252540", + modalOverlay: "rgba(0,0,0,0.55)", + modalBg: "#1a1a2e", + btnBg: "#252540", + btnHover: "#30304a", + tableBorder: "#252540", + tableRowHover: "#1e1e35", + scrollThumb: "#333", + health: { + healthy: { bg: "#0d2818", border: "#1b5e30", text: "#66bb6a", dot: "#43a047" }, + warning: { bg: "#2e2200", border: "#6d5000", text: "#ffca28", dot: "#ffa000" }, + error: { bg: "#2e0a0a", border: "#7f1d1d", text: "#ef5350", dot: "#e53935" }, + empty: { bg: "#111122", border: "#252540" }, + }, + statAccent: "#a78bfa", + toggleBg: "#1a1a2e", + toggleBorder: "#2a2a40", + toggleActive: "#7c3aed", + toggleText: "#8888a8", + toggleActiveText: "#fff", + mockBadge: { bg: "#2e0a20", text: "#f48fb1" }, + }, + light: { + bg: "linear-gradient(160deg, #f7f8fc 0%, #eef0f7 50%, #f4f1f8 100%)", + surface: "#ffffff", + surfaceBorder: "#f0f0f0", + surfaceHover: "#f8f9fa", + cardBg: "#ffffff", + cardBorder: "#f0f0f0", + text: "#1a1a2e", + textSecondary: "#999999", + textMuted: "#bbbbbb", + mono: "#444444", + accent: "#7c3aed", + divider: "#f0f0f0", + modalOverlay: "rgba(0,0,0,0.25)", + modalBg: "#ffffff", + btnBg: "#f5f5f5", + btnHover: "#eeeeee", + tableBorder: "#f0f0f0", + tableRowHover: "#f8f9fa", + scrollThumb: "#ccc", + health: { + healthy: { bg: "#e8f5e9", border: "#66bb6a", text: "#2e7d32", dot: "#43a047" }, + warning: { bg: "#fff8e1", border: "#ffca28", text: "#f57f17", dot: "#ffa000" }, + error: { bg: "#ffebee", border: "#ef5350", text: "#c62828", dot: "#e53935" }, + empty: { bg: "#fafafa", border: "#e0e0e0" }, + }, + statAccent: "#7c3aed", + toggleBg: "#ffffff", + toggleBorder: "#e8e8e8", + toggleActive: "#7c3aed", + toggleText: "#888888", + toggleActiveText: "#ffffff", + mockBadge: { bg: "#fce4ec", text: "#c62828" }, + }, +}; + +// --- Helpers --- +const formatCapacity = (bytes) => { + if (!bytes) return "\u2014"; + const tb = bytes / 1e12; + return tb >= 1 ? `${tb.toFixed(1)} TB` : `${(bytes / 1e9).toFixed(0)} GB`; +}; + +const formatHours = (h) => { + if (h == null) return "\u2014"; + const years = h / 8766; + if (years >= 1) return `${years.toFixed(1)}y`; + const days = h / 24; + if (days >= 1) return `${days.toFixed(0)}d`; + return `${h}h`; +}; + +// --- Components --- + +function ThemeToggle({ dark, onToggle, t }) { + return ( + + ); +} + +function StatusPill({ status, label, t }) { + const c = t.health[status] || t.health.empty; + return ( + + + {label || status} + + ); +} + +function StatCard({ value, label, accent, t }) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} + +function useResizeScale(ref) { + const [scale, setScale] = useState(1); + useEffect(() => { + if (!ref.current) return; + const obs = new ResizeObserver(([entry]) => { + const w = entry.contentRect.width; + setScale(Math.max(0.6, Math.min(1.8, w / 72))); + }); + obs.observe(ref.current); + return () => obs.disconnect(); + }, [ref]); + return scale; +} + +function SlotCell({ slot, onClick, t }) { + const ref = useRef(null); + const s = useResizeScale(ref); + + if (!slot.populated) { + const e = t.health.empty; + return ( + + ); + } + + const healthStatus = slot.drive?.health_status || "healthy"; + const c = t.health[healthStatus] || t.health.healthy; + return ( + + ); +} + +function GridView({ enclosure, onSelect, t }) { + const cols = enclosure.total_slots <= 12 ? 6 : enclosure.total_slots <= 24 ? 6 : 9; + return ( +
+ {enclosure.slots.map((slot) => ( + + ))} +
+ ); +} + +function TableView({ enclosure, onSelect, selectedSerial, t }) { + const populated = enclosure.slots.filter((s) => s.populated); + return ( +
+ + + + {["Slot", "Device", "Model", "Serial", "WWN", "FW", "Capacity", "Pool", "Temp", "Hours", "Health"].map((h) => ( + + ))} + + + + {populated.map((slot) => { + const d = slot.drive || {}; + const healthStatus = d.health_status || "healthy"; + const c = t.health[healthStatus] || t.health.healthy; + const isSelected = selectedSerial === d.serial; + return ( + onSelect?.(slot)} + style={{ + cursor: "pointer", + background: isSelected ? c.bg : "transparent", + transition: "background 0.15s", + }} + onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = t.tableRowHover; }} + onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "transparent"; }} + > + + + + + + + + + + + + + ); + })} + +
{h}
{slot.slot}{slot.device}{d.model}{d.serial}{d.wwn}{d.firmware}{formatCapacity(d.capacity_bytes)} + {d.zfs_pool ? ( + {d.zfs_pool} + ) : ( + {"\u2014"} + )} + = 40 ? t.health.warning.text : t.text }}>{d.temperature_c != null ? `${d.temperature_c}\u00B0C` : "\u2014"}{formatHours(d.power_on_hours)}
+
+ ); +} + +function DriveDetail({ slot, onClose, t }) { + if (!slot) return null; + const d = slot.drive || {}; + const healthStatus = d.health_status || "healthy"; + const c = t.health[healthStatus] || t.health.healthy; + + const smartFields = [ + ["SMART Status", d.smart_healthy === true ? "PASSED" : d.smart_healthy === false ? "FAILED" : "Unknown"], + ["Temperature", d.temperature_c != null ? `${d.temperature_c}\u00B0C` : "\u2014"], + ["Power-On Hours", d.power_on_hours != null ? `${d.power_on_hours.toLocaleString()}h (${formatHours(d.power_on_hours)})` : "\u2014"], + ["Reallocated Sectors", d.reallocated_sectors], + ["Pending Sectors", d.pending_sectors], + ["Uncorrectable Errors", d.uncorrectable_errors], + ]; + + return ( +
+
e.stopPropagation()} + style={{ + background: t.modalBg, borderRadius: 20, width: "100%", maxWidth: 520, + boxShadow: "0 20px 60px rgba(0,0,0,0.3)", overflow: "hidden", + border: `1px solid ${t.cardBorder}`, + animation: "slideUp 0.25s ease-out", + }} + > +
+
+
+ Slot {slot.slot} · /dev/{slot.device} +
+
{d.model || "Unknown"}
+
+ +
+ +
+
+ Identity +
+ {[ + ["Model", d.model], + ["Serial Number", d.serial], + ["Firmware", d.firmware], + ["Capacity", formatCapacity(d.capacity_bytes)], + ].map(([label, val]) => ( +
+ {label} + {val || "\u2014"} +
+ ))} + + {/* WWN */} +
+
+ World Wide Name +
+
+ {d.wwn || "N/A"} +
+
+ + {/* ZFS Membership */} +
+
+ ZFS Membership +
+
+ {d.zfs_pool ? ( + <> + + + {d.zfs_pool} + + + MEMBER + + + ) : ( + <> + + Standalone + + + NO POOL + + + )} +
+
+ +
+ SMART Health +
+ {smartFields.map(([label, val]) => { + const isWarn = (label === "Reallocated Sectors" && val > 0) || (label === "Pending Sectors" && val > 0) || (label === "SMART Status" && val === "FAILED"); + return ( +
+ {label} + + {val != null ? val : "\u2014"} + +
+ ); + })} +
+ +
+ +
+
+
+ ); +} + +function EnclosureCard({ enclosure, view, onSelect, selectedSerial, t }) { + return ( +
+
+
+
+ {enclosure.vendor} {enclosure.model} +
+
+ {enclosure.sg_device} · {enclosure.populated_slots}/{enclosure.total_slots} slots populated +
+
+
+ ID {enclosure.id} +
+
+
+ {view === "grid" ? ( + + ) : ( + + )} +
+
+ ); +} + +export default function App() { + const [dark, setDark] = useState(true); + const [data, setData] = useState(null); + const [view, setView] = useState("grid"); + const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); - const fetchOverview = useCallback(async () => { + const t = dark ? themes.dark : themes.light; + + const fetchData = 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); + setData(await res.json()); setError(null); - setLastUpdated(new Date()); } catch (err) { setError(err.message); } finally { @@ -26,364 +535,132 @@ function App() { }, []); useEffect(() => { - fetchOverview(); - const interval = setInterval(fetchOverview, 30000); + fetchData(); + const interval = setInterval(fetchData, REFRESH_INTERVAL); 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}
; + }, [fetchData]); return ( -
-
-

JBOD Monitor

-
- - {lastUpdated && ( - - Updated {lastUpdated.toLocaleTimeString()} - - )} - +
+ + +
+ {/* Header */} +
+
+

+ JBOD Monitor +

+

+ Drive health & identity dashboard +

+
+ +
+ setDark(!dark)} t={t} /> + +
+ {[ + { key: "grid", icon: "\u229E", label: "Grid" }, + { key: "table", icon: "\u2630", label: "Table" }, + ].map((v) => ( + + ))} +
+
-
- {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 && } + {/* Loading / Error */} + {loading && ( +
+ Loading enclosure data...
)} + {error && ( +
+ Failed to fetch: {error} +
+ )} + + {data && ( + <> + {/* Stats */} +
+ + + 0 ? t.health.warning.text : t.health.healthy.dot} + t={t} + /> + 0 ? t.health.error.text : t.health.healthy.dot} + t={t} + /> + +
+ + {/* Enclosures */} +
+ {data.enclosures.map((enc) => ( + + ))} +
+ + )}
+ + {selected && setSelected(null)} t={t} />}
); } - -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/models/schemas.py b/models/schemas.py index b3c18b3..6a79ad8 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -40,11 +40,18 @@ class DriveHealthSummary(BaseModel): device: str model: str | None = None serial: str | None = None + wwn: str | None = None + firmware: str | None = None + capacity_bytes: int | None = None smart_healthy: bool | None = None smart_supported: bool = True temperature_c: int | None = None power_on_hours: int | None = None + reallocated_sectors: int | None = None + pending_sectors: int | None = None + uncorrectable_errors: int | None = None zfs_pool: str | None = None + health_status: str = "healthy" class SlotWithDrive(BaseModel): diff --git a/routers/overview.py b/routers/overview.py index 5e22de9..246305b 100644 --- a/routers/overview.py +++ b/routers/overview.py @@ -68,15 +68,33 @@ async def get_overview(): if sd.get("uncorrectable_errors") and sd["uncorrectable_errors"] > 0: warnings += 1 + # Compute health_status for frontend + realloc = sd.get("reallocated_sectors") or 0 + pending = sd.get("pending_sectors") or 0 + unc = sd.get("uncorrectable_errors") or 0 + if healthy is False: + health_status = "error" + elif realloc > 0 or pending > 0 or unc > 0 or (healthy is None and sd.get("smart_supported", True)): + health_status = "warning" + else: + health_status = "healthy" + drive_summary = DriveHealthSummary( device=sd["device"], model=sd.get("model"), serial=sd.get("serial"), + wwn=sd.get("wwn"), + firmware=sd.get("firmware"), + capacity_bytes=sd.get("capacity_bytes"), smart_healthy=healthy, smart_supported=sd.get("smart_supported", True), temperature_c=sd.get("temperature_c"), power_on_hours=sd.get("power_on_hours"), + reallocated_sectors=sd.get("reallocated_sectors"), + pending_sectors=sd.get("pending_sectors"), + uncorrectable_errors=sd.get("uncorrectable_errors"), zfs_pool=pool_map.get(sd["device"]), + health_status=health_status, ) elif s["populated"]: total_drives += 1