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

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,
},
});