"use client" import { useEffect, useState, useCallback } from "react" import { api, Pool, PoolStatus, Dataset, Snapshot } from "@/lib/api" import { Header } from "@/components/Header" import { Button } from "@/components/ui/button" import { Dialog } from "@/components/ui/dialog" import { RefreshCw, ChevronDown, ChevronRight, MoreVertical, Camera, Plus, HardDrive, } from "lucide-react" type PoolTab = "filesystems" | "snapshots" | "status" interface PoolRowState { expanded: boolean tab: PoolTab status: PoolStatus | null datasets: Dataset[] snapshots: Snapshot[] loadingStatus: boolean loadingDatasets: boolean loadingSnapshots: boolean expandedSnapGroups: Set } function formatBytes(bytes: number): string { if (!bytes || bytes === 0) return "0 B" const units = ["B", "KiB", "MiB", "GiB", "TiB"] const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) return (bytes / Math.pow(1024, i)).toFixed(2) + " " + units[i] } function HealthBadge({ health }: { health: string }) { const color = health === "ONLINE" ? "text-green-600" : health === "DEGRADED" ? "text-yellow-500" : "text-red-500" return ( {health} ) } function UsageBar({ alloc, size }: { alloc: number; size: number }) { const pct = size > 0 ? Math.min((alloc / size) * 100, 100) : 0 const color = pct > 85 ? "bg-red-500" : pct > 70 ? "bg-yellow-500" : "bg-blue-500" return (
{pct.toFixed(0)}%
) } type SmartData = { model?: string serial?: string protocol?: string power_on_hours?: number temperature?: number passed?: boolean reallocated_sectors?: number pending_sectors?: number uncorrectable?: number } function DiskRow({ v, depth, poolName, onDiskMenu, }: { v: any depth: number poolName: string onDiskMenu: (e: React.MouseEvent, disk: string, poolName: string) => void }) { const [expanded, setExpanded] = useState(false) const [smart, setSmart] = useState(null) const [loadingSmart, setLoadingSmart] = useState(false) const toggleSmart = async () => { if (!expanded && smart === null) { setLoadingSmart(true) try { const data = await api.getDiskSmart(v.name) setSmart(data) } catch { setSmart({}) } finally { setLoadingSmart(false) } } setExpanded((x) => !x) } const diskLabel = v.disk_id ? v.disk_id.replace(/^(ata|nvme|scsi|wwn)-/, "").replace(/_/g, " ").replace(/-[A-Z0-9]{12,}$/, "").trim() : null const tempColor = smart?.temperature == null ? "" : smart.temperature >= 55 ? "text-red-500" : smart.temperature >= 45 ? "text-yellow-500" : "text-green-600" return ( <> {v.read ?? 0} {v.write ?? 0} {v.cksum ?? 0} {smart?.temperature != null && ( {smart.temperature}°C )} {smart?.passed != null && ( {smart.passed ? "PASSED" : "FAILED"} )} {expanded && ( {loadingSmart ? ( Loading SMART data… ) : smart && Object.keys(smart).length > 0 ? (
{smart.model && Model: {smart.model}} {smart.serial && Serial: {smart.serial}} {smart.power_on_hours != null && ( Power-On: {smart.power_on_hours.toLocaleString()} h )} {smart.reallocated_sectors != null && ( 0 ? "text-red-500" : ""}> Reallocated: {smart.reallocated_sectors} )} {smart.pending_sectors != null && ( 0 ? "text-yellow-500" : ""}> Pending: {smart.pending_sectors} )} {smart.uncorrectable != null && ( 0 ? "text-red-500" : ""}> Uncorrectable: {smart.uncorrectable} )}
) : ( No SMART data available )} )} ) } function VdevTree({ vdevs, depth = 0, poolName, onDiskMenu, }: { vdevs: any[] depth?: number poolName: string onDiskMenu: (e: React.MouseEvent, disk: string, poolName: string) => void }) { const isDisk = (v: any) => !v.children || v.children.length === 0 return ( <> {vdevs.map((v, i) => ( isDisk(v) ? ( ) : ( <> {v.name} {v.read ?? 0} {v.write ?? 0} {v.cksum ?? 0} ) ))} ) } export default function ZfsPage() { const [pools, setPools] = useState([]) const [loading, setLoading] = useState(true) const [lastRefresh, setLastRefresh] = useState(null) const [poolStates, setPoolStates] = useState>(new Map()) // Pool context menu const [poolMenu, setPoolMenu] = useState<{ pool: Pool; x: number; y: number } | null>(null) // Disk context menu const [diskMenu, setDiskMenu] = useState<{ disk: string; poolName: string; x: number; y: number } | null>(null) // Dialogs const [showCreateFilesystem, setShowCreateFilesystem] = useState(false) const [createFsPool, setCreateFsPool] = useState("") const [newFsName, setNewFsName] = useState("") const [snapContextMenu, setSnapContextMenu] = useState<{ snap: Snapshot; x: number; y: number } | null>(null) const [renameTarget, setRenameTarget] = useState(null) const [renameValue, setRenameValue] = useState("") const [cloneTarget, setCloneTarget] = useState(null) const [cloneValue, setCloneValue] = useState("") const loadPools = useCallback(async () => { setLoading(true) try { const data = await api.getPools() setPools(Array.isArray(data) ? data : []) setLastRefresh(new Date()) } catch (err) { console.error("Failed to load pools:", err) } finally { setLoading(false) } }, []) useEffect(() => { loadPools() }, [loadPools]) const updatePoolState = (name: string, patch: Partial) => { setPoolStates((prev) => { const next = new Map(prev) const cur = next.get(name) ?? { expanded: false, tab: "filesystems", status: null, datasets: [], snapshots: [], loadingStatus: false, loadingDatasets: false, loadingSnapshots: false, expandedSnapGroups: new Set(), } next.set(name, { ...cur, ...patch }) return next }) } const togglePool = async (pool: Pool) => { const cur = poolStates.get(pool.name) const isExpanded = cur?.expanded ?? false if (!isExpanded) { updatePoolState(pool.name, { expanded: true, tab: "filesystems", loadingDatasets: true }) try { const ds = await api.getDatasets(pool.name) updatePoolState(pool.name, { datasets: ds, loadingDatasets: false }) } catch { updatePoolState(pool.name, { loadingDatasets: false }) } } else { updatePoolState(pool.name, { expanded: false }) } } const switchTab = async (poolName: string, tab: PoolTab) => { updatePoolState(poolName, { tab }) const cur = poolStates.get(poolName) if (tab === "status" && !cur?.status) { updatePoolState(poolName, { loadingStatus: true }) try { const s = await api.getPoolStatus(poolName) updatePoolState(poolName, { status: s, loadingStatus: false }) } catch { updatePoolState(poolName, { loadingStatus: false }) } } if (tab === "snapshots" && (!cur?.snapshots || cur.snapshots.length === 0)) { updatePoolState(poolName, { loadingSnapshots: true }) try { const snaps = await api.getSnapshots(poolName, 200) updatePoolState(poolName, { snapshots: snaps, loadingSnapshots: false }) } catch { updatePoolState(poolName, { loadingSnapshots: false }) } } } const refreshPoolStatus = async (poolName: string) => { updatePoolState(poolName, { loadingStatus: true }) try { const s = await api.getPoolStatus(poolName) updatePoolState(poolName, { status: s, loadingStatus: false }) } catch { updatePoolState(poolName, { loadingStatus: false }) } } const refreshSnapshots = async (poolName: string) => { updatePoolState(poolName, { loadingSnapshots: true }) try { const snaps = await api.getSnapshots(poolName, 200) updatePoolState(poolName, { snapshots: snaps, loadingSnapshots: false }) } catch { updatePoolState(poolName, { loadingSnapshots: false }) } } const toggleSnapGroup = (poolName: string, groupKey: string) => { setPoolStates((prev) => { const next = new Map(prev) const cur = next.get(poolName) if (!cur) return prev const groups = new Set(cur.expandedSnapGroups) if (groups.has(groupKey)) groups.delete(groupKey) else groups.add(groupKey) next.set(poolName, { ...cur, expandedSnapGroups: groups }) return next }) } const handleSnapAction = async (action: string, snap: Snapshot, poolName: string) => { setSnapContextMenu(null) if (action === "rollback") { if (!confirm(`Roll back ${snap.dataset} to ${snap.name.split("@")[1]}? Data after this snapshot will be destroyed!`)) return await api.rollbackSnapshot(snap.name) refreshSnapshots(poolName) } else if (action === "destroy") { if (!confirm(`Destroy snapshot ${snap.name.split("@")[1]}?`)) return await api.deleteSnapshot(snap.name) refreshSnapshots(poolName) } else if (action === "rename") { setRenameValue(snap.name.split("@")[1]) setRenameTarget(snap) } else if (action === "clone") { setCloneValue(`${snap.dataset}-clone`) setCloneTarget(snap) } } const handleRenameConfirm = async () => { if (!renameTarget || !renameValue.trim()) return await api.renameSnapshot(renameTarget.name, renameValue.trim()) refreshSnapshots(renameTarget.name.split("@")[0].split("/")[0]) setRenameTarget(null) } const handleCloneConfirm = async () => { if (!cloneTarget || !cloneValue.trim()) return await api.cloneSnapshot(cloneTarget.name, cloneValue.trim()) setCloneTarget(null) } const handleDiskMenuOpen = (e: React.MouseEvent, disk: string, poolName: string) => { setDiskMenu({ disk, poolName, x: e.clientX, y: e.clientY }) } const handleDiskAction = async (action: string) => { if (!diskMenu) return const { disk, poolName } = diskMenu setDiskMenu(null) if (action === "offline") { await api.diskOffline(poolName, disk) refreshPoolStatus(poolName) } else if (action === "online") { await api.diskOnline(poolName, disk) refreshPoolStatus(poolName) } else if (action === "detach") { if (!confirm(`Detach ${disk} from ${poolName}?`)) return await api.diskDetach(poolName, disk) refreshPoolStatus(poolName) loadPools() } else if (action === "clear") { await api.clearDiskErrors(poolName, disk) refreshPoolStatus(poolName) } } const handlePoolAction = async (action: string, pool: Pool) => { setPoolMenu(null) if (action === "scrub") { await api.startScrub(pool.name) refreshPoolStatus(pool.name) } else if (action === "clear") { await api.clearPoolErrors(pool.name) refreshPoolStatus(pool.name) } else if (action === "resilver") { await api.resilverPool(pool.name) refreshPoolStatus(pool.name) } } const handleCreateFilesystem = async () => { if (!newFsName.trim()) return const fullName = createFsPool ? `${createFsPool}/${newFsName.trim()}` : newFsName.trim() try { await api.createDataset(fullName, {}) setNewFsName("") setShowCreateFilesystem(false) const cur = poolStates.get(createFsPool) if (cur?.expanded) { const ds = await api.getDatasets(createFsPool) updatePoolState(createFsPool, { datasets: ds }) } } catch (err) { console.error("Failed to create filesystem:", err) } } return (
{/* Page Header */}

Storage Pools

{lastRefresh && (

{lastRefresh.toLocaleString()} · {pools.length} pool{pools.length !== 1 ? "s" : ""}

)} {/* Pools Table */}
{loading && ( )} {!loading && pools.length === 0 && ( )} {pools.map((pool) => { const state = poolStates.get(pool.name) const isExpanded = state?.expanded ?? false const currentTab = state?.tab ?? "filesystems" return [ /* Pool row */ togglePool(pool)} > , /* Expanded panel */ isExpanded && ( ), ] })}
Name Health Size Allocated Free Fragmentation Usage
Loading…
No storage pools found
{isExpanded ? : } {pool.name} {formatBytes(pool.size)} {formatBytes(pool.alloc)} {formatBytes(pool.free)} {pool.fragmentation} e.stopPropagation()}>
{/* Tabs */}
{(["filesystems", "snapshots", "status"] as PoolTab[]).map((t) => ( ))} {/* Tab-level actions */}
{currentTab === "filesystems" && ( <> )} {currentTab === "snapshots" && ( <> )} {currentTab === "status" && ( )}
{/* ── File Systems Tab ── */} {currentTab === "filesystems" && ( state?.loadingDatasets ? (
Loading…
) : (
{(state?.datasets ?? []).map((ds) => { const depth = ds.name.split("/").length - 1 return ( ) })} {(state?.datasets ?? []).length === 0 && ( )}
Name Available Used Snapshots Refreservation Record Size Compression Deduplication Share Mounted
{ds.name.split("/").pop()} {formatBytes(ds.avail)} {formatBytes(ds.used)} {ds.reservation ? formatBytes(ds.reservation) : "0 B"} {ds.compression || "off"} Off Off {ds.mountpoint ? "Yes" : "No"}
No filesystems
) )} {/* ── Snapshots Tab ── */} {currentTab === "snapshots" && ( state?.loadingSnapshots ? (
Loading…
) : (state?.snapshots ?? []).length === 0 ? (
No snapshots
) : (() => { const groups = new Map() ;(state?.snapshots ?? []).forEach((s) => { const ds = s.dataset || s.name.split("@")[0] if (!groups.has(ds)) groups.set(ds, []) groups.get(ds)!.push(s) }) return (
{Array.from(groups.entries()).map(([ds, snaps]) => { const groupExpanded = state?.expandedSnapGroups.has(ds) ?? false return [ toggleSnapGroup(pool.name, ds)} > , ...(groupExpanded ? snaps.map((snap) => ( )) : []) ] })}
Name Created Used Referenced Clones
{groupExpanded ? : } {ds} ({snaps.length})
{snap.name.split("@")[1]} {snap.creation_datetime ? new Date(snap.creation_datetime).toLocaleString() : "—"} {formatBytes(snap.used)} {formatBytes(snap.referenced)}
) })() )} {/* ── Status Tab ── */} {currentTab === "status" && ( state?.loadingStatus ? (
Loading…
) : state?.status ? (
Pool: {state.status.name}
State:
Scan: {state.status.scan ?? "—"}
Errors: {state.status.errors ?? "No known data errors"}
Name State Read Write Checksum Temp SMART
) : (
No status data available
) )}
{/* Create Filesystem Dialog */} setShowCreateFilesystem(false)} title="Create Filesystem" >

Pool: {createFsPool || "—"}

setNewFsName(e.target.value)} className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono text-sm" onKeyDown={(e) => e.key === "Enter" && handleCreateFilesystem()} autoFocus />
{/* Rename Snapshot Dialog */} setRenameTarget(null)} title="Rename Snapshot">

New name for {renameTarget?.name.split("@")[1]}

setRenameValue(e.target.value)} className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono" onKeyDown={(e) => e.key === "Enter" && handleRenameConfirm()} autoFocus />
{/* Clone Snapshot Dialog */} setCloneTarget(null)} title="Clone Snapshot">

Clone {cloneTarget?.name.split("@")[1]} to:

setCloneValue(e.target.value)} placeholder="target dataset name" className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground font-mono" onKeyDown={(e) => e.key === "Enter" && handleCloneConfirm()} autoFocus />
{/* Disk Context Menu */} {diskMenu && ( <>
setDiskMenu(null)} />
{diskMenu.disk}
{[ { action: "clear", label: "Clear Disk Errors" }, { action: "offline", label: "Offline Disk" }, { action: "online", label: "Online Disk" }, { action: "detach", label: "Detach Disk", danger: true }, ].map(({ action, label, danger }) => ( ))}
)} {/* Pool Context Menu */} {poolMenu && ( <>
setPoolMenu(null)} />
{[ { action: "scrub", label: "Scrub Storage Pool" }, { action: "resilver", label: "Resilver Storage Pool" }, { action: "clear", label: "Clear Storage Pool Errors" }, ].map(({ action, label }) => ( ))}
)} {/* Snapshot Context Menu */} {snapContextMenu && ( <>
setSnapContextMenu(null)} />
{[ { action: "clone", label: "Clone Snapshot" }, { action: "rename", label: "Rename Snapshot" }, { action: "rollback", label: "Roll Back Snapshot" }, { action: "destroy", label: "Destroy Snapshot", danger: true }, ].map(({ action, label, danger }) => ( ))}
)}
) }