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:
@@ -1,4 +1,8 @@
|
|||||||
|
frontend/node_modules
|
||||||
|
frontend/.vite
|
||||||
|
static/
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
.git
|
.git
|
||||||
.venv
|
.env
|
||||||
|
*.md
|
||||||
|
|||||||
33
Dockerfile
33
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
|
FROM python:3.13-slim
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
apt-get install -y --no-install-recommends smartmontools sg3-utils && \
|
smartmontools \
|
||||||
rm -rf /var/lib/apt/lists/*
|
sg3-utils \
|
||||||
|
lsscsi \
|
||||||
|
util-linux \
|
||||||
|
zfsutils-linux \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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
|
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"]
|
||||||
|
|||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -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
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
16
frontend/package.json
Normal 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
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;
|
||||||
9
frontend/src/main.jsx
Normal file
9
frontend/src/main.jsx
Normal 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
10
frontend/vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
18
main.py
18
main.py
@@ -1,8 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from models.schemas import HealthCheck
|
from models.schemas import HealthCheck
|
||||||
from routers import drives, enclosures, overview
|
from routers import drives, enclosures, overview
|
||||||
@@ -50,3 +53,18 @@ async def check_dependencies():
|
|||||||
@app.get("/api/health", response_model=HealthCheck, tags=["health"])
|
@app.get("/api/health", response_model=HealthCheck, tags=["health"])
|
||||||
async def health():
|
async def health():
|
||||||
return HealthCheck(status="ok", tools=_tool_status)
|
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")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class DriveDetail(BaseModel):
|
|||||||
pending_sectors: int | None = None
|
pending_sectors: int | None = None
|
||||||
uncorrectable_errors: int | None = None
|
uncorrectable_errors: int | None = None
|
||||||
wear_leveling_percent: int | None = None
|
wear_leveling_percent: int | None = None
|
||||||
|
zfs_pool: str | None = None
|
||||||
smart_attributes: list[dict] = []
|
smart_attributes: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ class DriveHealthSummary(BaseModel):
|
|||||||
smart_supported: bool = True
|
smart_supported: bool = True
|
||||||
temperature_c: int | None = None
|
temperature_c: int | None = None
|
||||||
power_on_hours: int | None = None
|
power_on_hours: int | None = None
|
||||||
|
zfs_pool: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SlotWithDrive(BaseModel):
|
class SlotWithDrive(BaseModel):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException
|
|||||||
|
|
||||||
from models.schemas import DriveDetail
|
from models.schemas import DriveDetail
|
||||||
from services.smart import get_smart_data
|
from services.smart import get_smart_data
|
||||||
|
from services.zfs import get_zfs_pool_map
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/drives", tags=["drives"])
|
router = APIRouter(prefix="/api/drives", tags=["drives"])
|
||||||
|
|
||||||
@@ -17,4 +18,7 @@ async def get_drive_detail(device: str):
|
|||||||
if "error" in data:
|
if "error" in data:
|
||||||
raise HTTPException(status_code=502, detail=data["error"])
|
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)
|
return DriveDetail(**data)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from models.schemas import (
|
|||||||
)
|
)
|
||||||
from services.enclosure import discover_enclosures, list_slots
|
from services.enclosure import discover_enclosures, list_slots
|
||||||
from services.smart import get_smart_data
|
from services.smart import get_smart_data
|
||||||
|
from services.zfs import get_zfs_pool_map
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ router = APIRouter(prefix="/api/overview", tags=["overview"])
|
|||||||
async def get_overview():
|
async def get_overview():
|
||||||
"""Aggregate view of all enclosures, slots, and drive health."""
|
"""Aggregate view of all enclosures, slots, and drive health."""
|
||||||
enclosures_raw = discover_enclosures()
|
enclosures_raw = discover_enclosures()
|
||||||
|
pool_map = await get_zfs_pool_map()
|
||||||
|
|
||||||
enc_results: list[EnclosureWithDrives] = []
|
enc_results: list[EnclosureWithDrives] = []
|
||||||
total_drives = 0
|
total_drives = 0
|
||||||
@@ -74,6 +76,7 @@ async def get_overview():
|
|||||||
smart_supported=sd.get("smart_supported", True),
|
smart_supported=sd.get("smart_supported", True),
|
||||||
temperature_c=sd.get("temperature_c"),
|
temperature_c=sd.get("temperature_c"),
|
||||||
power_on_hours=sd.get("power_on_hours"),
|
power_on_hours=sd.get("power_on_hours"),
|
||||||
|
zfs_pool=pool_map.get(sd["device"]),
|
||||||
)
|
)
|
||||||
elif s["populated"]:
|
elif s["populated"]:
|
||||||
total_drives += 1
|
total_drives += 1
|
||||||
|
|||||||
42
services/zfs.py
Normal file
42
services/zfs.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user