- 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
390 lines
12 KiB
JavaScript
390 lines
12 KiB
JavaScript
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;
|