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:
389
frontend/src/App.jsx
Normal file
389
frontend/src/App.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user