92bed208e0
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
598 lines
24 KiB
TypeScript
598 lines
24 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useRef } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { api, Pool } from "@/lib/api"
|
|
import { Header } from "@/components/Header"
|
|
import { PoolCard } from "@/components/PoolCard"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { RefreshCw, AlertCircle, Cpu, HardDrive, Zap, Clock, Network, Database } from "lucide-react"
|
|
|
|
export default function Dashboard() {
|
|
const router = useRouter()
|
|
const [pools, setPools] = useState<Pool[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
|
|
const [zfsAvailable, setZfsAvailable] = useState<boolean | null>(null)
|
|
const [systemInfo, setSystemInfo] = useState<any>(null)
|
|
const [memoryInfo, setMemoryInfo] = useState<any>(null)
|
|
const [cpuInfo, setCpuInfo] = useState<any>(null)
|
|
const [uptimeInfo, setUptimeInfo] = useState<any>(null)
|
|
const [networkInfo, setNetworkInfo] = useState<any>(null)
|
|
const [networkTraffic, setNetworkTraffic] = useState<any>(null)
|
|
const [diskIO, setDiskIO] = useState<any>(null)
|
|
|
|
// History buffers for sparklines (rolling window of 30 points, ~2.5 minutes at 5s intervals)
|
|
const cpuHistoryRef = useRef<number[]>([])
|
|
const memoryHistoryRef = useRef<number[]>([])
|
|
const networkTrafficHistoryRef = useRef<Map<string, number[]>>(new Map())
|
|
const [cpuHistory, setCpuHistory] = useState<number[]>([])
|
|
const [memoryHistory, setMemoryHistory] = useState<number[]>([])
|
|
|
|
useEffect(() => {
|
|
// Check authentication
|
|
const token = localStorage.getItem("access_token")
|
|
if (!token) {
|
|
router.push("/login")
|
|
return
|
|
}
|
|
|
|
// Load data if authenticated
|
|
const init = async () => {
|
|
await checkZfsStatus()
|
|
await fetchPools()
|
|
await loadSystemStats()
|
|
const interval = setInterval(fetchPools, 30000) // Refresh every 30 seconds
|
|
return () => clearInterval(interval)
|
|
}
|
|
init()
|
|
}, [router])
|
|
|
|
const checkZfsStatus = async () => {
|
|
try {
|
|
const response = await fetch("/api/status")
|
|
const data = await response.json()
|
|
setZfsAvailable(data.zfs_available ?? false)
|
|
return data.zfs_available ?? false
|
|
} catch (err) {
|
|
console.error("Failed to check ZFS status:", err)
|
|
setZfsAvailable(false)
|
|
return false
|
|
}
|
|
}
|
|
|
|
const formatBytes = (bytes: number) => {
|
|
if (bytes === 0) return "0 B"
|
|
const k = 1024
|
|
const sizes = ["B", "KB", "MB", "GB"]
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i]
|
|
}
|
|
|
|
const formatUptime = (seconds: number) => {
|
|
const days = Math.floor(seconds / 86400)
|
|
const hours = Math.floor((seconds % 86400) / 3600)
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|
const parts = []
|
|
if (days > 0) parts.push(`${days} day${days > 1 ? 's' : ''}`)
|
|
if (hours > 0) parts.push(`${hours} hr${hours > 1 ? 's' : ''}`)
|
|
if (minutes > 0 || parts.length === 0) parts.push(`${minutes} min`)
|
|
return parts.join(', ')
|
|
}
|
|
|
|
const formatBootTime = (timestamp: number) => {
|
|
try {
|
|
return new Date(timestamp * 1000).toLocaleString()
|
|
} catch {
|
|
return 'N/A'
|
|
}
|
|
}
|
|
|
|
// Sparkline helper: convert array of 0-100 values to SVG polyline points
|
|
const sparklinePoints = (data: number[], width = 120, height = 32): string => {
|
|
if (data.length < 2) return ""
|
|
const step = width / (data.length - 1)
|
|
return data.map((v, i) => `${i * step},${height - Math.max(0, Math.min(100, v)) / 100 * height}`).join(" ")
|
|
}
|
|
|
|
const loadSystemStats = async () => {
|
|
try {
|
|
const [sysInfo, memInfo, cpuData, uptime, network, traffic, diskio] = await Promise.all([
|
|
api.getSystemInfo().catch(() => null),
|
|
api.getMemory().catch(() => null),
|
|
api.getCpuInfo().catch(() => null),
|
|
api.getUptime().catch(() => null),
|
|
api.getNetwork().catch(() => null),
|
|
api.getNetworkTraffic().catch(() => null),
|
|
api.getDiskIO().catch(() => null),
|
|
])
|
|
setSystemInfo(sysInfo)
|
|
setMemoryInfo(memInfo)
|
|
setCpuInfo(cpuData)
|
|
setUptimeInfo(uptime)
|
|
setNetworkInfo(network)
|
|
setNetworkTraffic(traffic)
|
|
setDiskIO(diskio)
|
|
|
|
// Add to history
|
|
if (cpuData?.percent !== undefined) {
|
|
const newCpuHistory = [...cpuHistoryRef.current, cpuData.percent]
|
|
if (newCpuHistory.length > 30) newCpuHistory.shift()
|
|
cpuHistoryRef.current = newCpuHistory
|
|
setCpuHistory(newCpuHistory)
|
|
}
|
|
if (memInfo?.total && memInfo?.used !== undefined) {
|
|
const memPercent = (memInfo.used / memInfo.total) * 100
|
|
const newMemHistory = [...memoryHistoryRef.current, memPercent]
|
|
if (newMemHistory.length > 30) newMemHistory.shift()
|
|
memoryHistoryRef.current = newMemHistory
|
|
setMemoryHistory(newMemHistory)
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load system stats:", err)
|
|
}
|
|
}
|
|
|
|
// Periodic update for history every 5 seconds
|
|
useEffect(() => {
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const [cpuData, memInfo, traffic, diskio] = await Promise.all([
|
|
api.getCpuInfo().catch(() => null),
|
|
api.getMemory().catch(() => null),
|
|
api.getNetworkTraffic().catch(() => null),
|
|
api.getDiskIO().catch(() => null),
|
|
])
|
|
|
|
if (cpuData?.percent !== undefined) {
|
|
const newCpuHistory = [...cpuHistoryRef.current, cpuData.percent]
|
|
if (newCpuHistory.length > 30) newCpuHistory.shift()
|
|
cpuHistoryRef.current = newCpuHistory
|
|
setCpuHistory(newCpuHistory)
|
|
}
|
|
if (memInfo?.total && memInfo?.used !== undefined) {
|
|
const memPercent = (memInfo.used / memInfo.total) * 100
|
|
const newMemHistory = [...memoryHistoryRef.current, memPercent]
|
|
if (newMemHistory.length > 30) newMemHistory.shift()
|
|
memoryHistoryRef.current = newMemHistory
|
|
setMemoryHistory(newMemHistory)
|
|
}
|
|
if (traffic?.interfaces) {
|
|
setNetworkTraffic(traffic)
|
|
}
|
|
if (diskio?.disks) {
|
|
setDiskIO(diskio)
|
|
}
|
|
|
|
if (traffic?.interfaces) {
|
|
const newHistory = new Map(networkTrafficHistoryRef.current)
|
|
for (const iface of traffic.interfaces) {
|
|
if (iface.name === 'lo') continue // Skip loopback
|
|
const key = `${iface.name}_rx`
|
|
const current = newHistory.get(key) || []
|
|
const updated = [...current, iface.rx_bytes]
|
|
if (updated.length > 30) updated.shift()
|
|
newHistory.set(key, updated)
|
|
}
|
|
networkTrafficHistoryRef.current = newHistory
|
|
}
|
|
} catch (err) {
|
|
// Silently fail
|
|
}
|
|
}, 5000) // Every 5 seconds
|
|
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
const fetchPools = async () => {
|
|
// If ZFS is not available, don't try to fetch pools
|
|
if (zfsAvailable === false) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
const data = await api.getPools()
|
|
setPools(data)
|
|
setLastUpdate(new Date())
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to fetch pools"
|
|
setError(message)
|
|
console.error(err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Page Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Dashboard</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{lastUpdate ? `Last updated: ${lastUpdate.toLocaleTimeString()}` : "Loading..."}
|
|
</p>
|
|
</div>
|
|
<Button onClick={fetchPools} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Quick Stats - System Metrics (Phase 3a) */}
|
|
{(systemInfo || memoryInfo || cpuInfo || uptimeInfo) && (
|
|
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{/* Hostname & Uptime */}
|
|
{systemInfo && uptimeInfo && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Zap className="w-4 h-4" />
|
|
System
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-lg font-bold truncate">{systemInfo.hostname}</p>
|
|
<p className="text-xs text-muted-foreground">Uptime: {uptimeInfo.uptime_string}</p>
|
|
<p className="text-xs text-muted-foreground mt-2">{systemInfo.kernel}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* CPU Usage with Sparkline */}
|
|
{cpuInfo && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Cpu className="w-4 h-4" />
|
|
CPU
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<p className="text-lg font-bold">{cpuInfo.percent !== undefined ? cpuInfo.percent.toFixed(1) : "N/A"}%</p>
|
|
{cpuHistory.length > 1 && (
|
|
<svg width="100%" height="32" viewBox="0 0 120 32" preserveAspectRatio="none" className="w-full h-8">
|
|
<polyline
|
|
points={sparklinePoints(cpuHistory, 120, 32)}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
className="text-primary"
|
|
/>
|
|
</svg>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">Load: {cpuInfo.load_average?.[0]?.toFixed(2)}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Memory Usage with Sparkline */}
|
|
{memoryInfo && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<HardDrive className="w-4 h-4" />
|
|
Memory
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<p className="text-lg font-bold">
|
|
{((memoryInfo.used / memoryInfo.total) * 100).toFixed(1)}%
|
|
</p>
|
|
{memoryHistory.length > 1 && (
|
|
<svg width="100%" height="32" viewBox="0 0 120 32" preserveAspectRatio="none" className="w-full h-8">
|
|
<polyline
|
|
points={sparklinePoints(memoryHistory, 120, 32)}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
className="text-primary"
|
|
/>
|
|
</svg>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatBytes(memoryInfo.used)} / {formatBytes(memoryInfo.total)}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Uptime */}
|
|
{uptimeInfo && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Clock className="w-4 h-4" />
|
|
System Uptime
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Uptime</p>
|
|
<p className="text-sm font-semibold">{formatUptime(uptimeInfo.uptime_seconds || 0)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Booted</p>
|
|
<p className="text-xs font-mono">{formatBootTime(uptimeInfo.boot_time || 0)}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Disk Usage */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<HardDrive className="w-4 h-4" />
|
|
Storage
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{zfsAvailable ? (
|
|
<div>
|
|
<p className="text-lg font-bold">ZFS</p>
|
|
<p className="text-xs text-muted-foreground">View pools below</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="text-lg font-bold">N/A</p>
|
|
<p className="text-xs text-muted-foreground">ZFS not available</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* System Details Card */}
|
|
{systemInfo && (
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Systeminformationen</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{systemInfo.model && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Modell</p>
|
|
<p className="text-base font-semibold mt-1">{systemInfo.model}</p>
|
|
</div>
|
|
)}
|
|
{systemInfo.machine_id && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Maschinen-ID</p>
|
|
<p className="text-base font-mono text-xs mt-1 break-all">
|
|
{systemInfo.machine_id}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{systemInfo.processor && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Prozessor</p>
|
|
<p className="text-base font-semibold mt-1 line-clamp-2">
|
|
{systemInfo.processor}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{systemInfo.kernel && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Kernel</p>
|
|
<p className="text-base font-semibold mt-1">{systemInfo.kernel}</p>
|
|
</div>
|
|
)}
|
|
{systemInfo.system && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Betriebssystem</p>
|
|
<p className="text-base font-semibold mt-1">{systemInfo.system}</p>
|
|
</div>
|
|
)}
|
|
{systemInfo.domain && (
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Domain</p>
|
|
<p className="text-base font-semibold mt-1">{systemInfo.domain}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Error Message */}
|
|
{error && zfsAvailable !== false && (
|
|
<Card className="mb-6 border-red-200 bg-red-50">
|
|
<CardContent className="flex items-center gap-3 pt-6">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
|
<div>
|
|
<p className="font-medium text-red-900">Error</p>
|
|
<p className="text-sm text-red-800">{error}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{loading && pools.length === 0 && zfsAvailable !== false && (
|
|
<div className="text-center py-12">
|
|
<div className="inline-block animate-spin">
|
|
<RefreshCw className="w-8 h-8 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-4 text-muted-foreground">Loading pools...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Network Interfaces */}
|
|
{networkInfo?.interfaces && networkInfo.interfaces.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xl font-semibold mb-4">Network Interfaces</h2>
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-border bg-muted/30">
|
|
<tr>
|
|
<th className="text-left py-3 px-4 font-medium">Interface</th>
|
|
<th className="text-left py-3 px-4 font-medium">Status</th>
|
|
<th className="text-left py-3 px-4 font-medium">IP Address</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{networkInfo.interfaces.map((iface: any) => (
|
|
<tr key={iface.name} className="border-b border-border/50 hover:bg-muted/30">
|
|
<td className="py-3 px-4 font-mono text-xs">{iface.name}</td>
|
|
<td className="py-3 px-4">
|
|
<Badge variant={iface.state === "UP" ? "default" : "secondary"}>
|
|
{iface.state}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3 px-4 text-xs">
|
|
{iface.addresses && iface.addresses.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{iface.addresses.map((addr: any, idx: number) => (
|
|
<div key={idx}>{addr.local}</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Network Traffic */}
|
|
{networkTraffic?.interfaces && networkTraffic.interfaces.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xl font-semibold mb-4">Network Traffic</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{networkTraffic.interfaces
|
|
.filter((iface: any) => iface.name !== 'lo') // Skip loopback
|
|
.map((iface: any) => (
|
|
<Card key={`${iface.name}_traffic`}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Network className="w-4 h-4" />
|
|
{iface.name}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">RX</p>
|
|
<p className="text-sm font-semibold">{formatBytes(iface.rx_bytes)}</p>
|
|
<p className="text-xs text-muted-foreground">{iface.rx_packets.toLocaleString()} packets</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">TX</p>
|
|
<p className="text-sm font-semibold">{formatBytes(iface.tx_bytes)}</p>
|
|
<p className="text-xs text-muted-foreground">{iface.tx_packets.toLocaleString()} packets</p>
|
|
</div>
|
|
{(iface.rx_drops > 0 || iface.tx_drops > 0) && (
|
|
<div className="pt-2 border-t border-border/30">
|
|
<p className="text-xs text-amber-600">
|
|
⚠ {iface.rx_drops + iface.tx_drops} dropped packets
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Disk I/O */}
|
|
{diskIO?.disks && diskIO.disks.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xl font-semibold mb-4">Disk I/O</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{diskIO.disks.map((disk: any) => (
|
|
<Card key={`${disk.name}_io`}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Database className="w-4 h-4" />
|
|
{disk.name}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Reads</p>
|
|
<p className="text-sm font-semibold">{disk.reads_completed.toLocaleString()} ops</p>
|
|
<p className="text-xs text-muted-foreground">{formatBytes(disk.reads_bytes)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Writes</p>
|
|
<p className="text-sm font-semibold">{disk.writes_completed.toLocaleString()} ops</p>
|
|
<p className="text-xs text-muted-foreground">{formatBytes(disk.writes_bytes)}</p>
|
|
</div>
|
|
<div className="pt-2 border-t border-border/30">
|
|
<p className="text-xs text-muted-foreground">
|
|
Total: {formatBytes(disk.reads_bytes + disk.writes_bytes)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pools Grid */}
|
|
{!loading && pools.length > 0 && zfsAvailable !== false && (
|
|
<div>
|
|
<h2 className="text-xl font-semibold mb-4">Storage Pools ({pools.length})</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{pools.map((pool) => (
|
|
<PoolCard
|
|
key={pool.name}
|
|
pool={pool}
|
|
onClick={() => router.push(`/pools/${pool.name}`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!loading && pools.length === 0 && !error && zfsAvailable !== false && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>No Pools Found</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground">
|
|
No ZFS pools are available on this system. Create a new pool to get started.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|