ce78f0ae95
- Backend: get_disk_id_map() liest /dev/disk/by-id/ dynamisch aus (ata/nvme/scsi/wwn)
- Backend: _annotate_disk_ids() hängt disk_id an Leaf-Vdevs in get_pool_status()
- Backend: get_smart_info() liest smartctl --json (Modell, Temp, Health, Stunden, Sektoren)
- Backend: GET /api/pools/disks/{disk}/smart Endpoint
- Frontend: DiskRow zeigt Modellname neben sda/sdb, aufklappbar für SMART-Details
- Frontend: Temp-Spalte farbcodiert (grün/gelb/rot), SMART-Spalte zeigt PASSED/FAILED
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
986 lines
44 KiB
TypeScript
986 lines
44 KiB
TypeScript
"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<string>
|
|
}
|
|
|
|
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 (
|
|
<span className={`flex items-center gap-1 font-medium text-sm ${color}`}>
|
|
<span className="w-2 h-2 rounded-full bg-current inline-block" />
|
|
{health}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="flex items-center gap-2 min-w-[120px]">
|
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
<div className={`h-full ${color} rounded-full`} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground w-9 text-right">{pct.toFixed(0)}%</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<SmartData | null>(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 (
|
|
<>
|
|
<tr className="border-b border-border/40 hover:bg-muted/20">
|
|
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
|
<button
|
|
className="flex items-center gap-1.5 hover:text-foreground text-left"
|
|
onClick={toggleSmart}
|
|
title="SMART details"
|
|
>
|
|
{expanded ? <ChevronDown className="w-3 h-3 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 flex-shrink-0" />}
|
|
<span>{v.name}</span>
|
|
{diskLabel && (
|
|
<span className="text-muted-foreground font-sans ml-1 truncate max-w-[160px]" title={v.disk_id}>
|
|
{diskLabel}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<HealthBadge health={v.state || "—"} />
|
|
</td>
|
|
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
|
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td>
|
|
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
|
|
<td className="px-4 py-2 text-xs text-muted-foreground">
|
|
{smart?.temperature != null && (
|
|
<span className={`font-medium ${tempColor}`}>{smart.temperature}°C</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-xs text-muted-foreground">
|
|
{smart?.passed != null && (
|
|
<span className={smart.passed ? "text-green-600" : "text-red-500"}>
|
|
{smart.passed ? "PASSED" : "FAILED"}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-xs text-right">
|
|
<button
|
|
className="p-1 rounded hover:bg-muted"
|
|
onClick={(e) => { e.stopPropagation(); onDiskMenu(e, v.name, poolName) }}
|
|
>
|
|
<MoreVertical className="w-3 h-3" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{expanded && (
|
|
<tr className="border-b border-border/40 bg-muted/10">
|
|
<td colSpan={8} className="px-4 py-3" style={{ paddingLeft: `${depth * 20 + 36}px` }}>
|
|
{loadingSmart ? (
|
|
<span className="text-xs text-muted-foreground animate-pulse">Loading SMART data…</span>
|
|
) : smart && Object.keys(smart).length > 0 ? (
|
|
<div className="text-xs text-muted-foreground flex flex-wrap gap-x-6 gap-y-1">
|
|
{smart.model && <span><span className="text-foreground font-medium">Model:</span> {smart.model}</span>}
|
|
{smart.serial && <span><span className="text-foreground font-medium">Serial:</span> {smart.serial}</span>}
|
|
{smart.power_on_hours != null && (
|
|
<span><span className="text-foreground font-medium">Power-On:</span> {smart.power_on_hours.toLocaleString()} h</span>
|
|
)}
|
|
{smart.reallocated_sectors != null && (
|
|
<span className={smart.reallocated_sectors > 0 ? "text-red-500" : ""}>
|
|
<span className="text-foreground font-medium">Reallocated:</span> {smart.reallocated_sectors}
|
|
</span>
|
|
)}
|
|
{smart.pending_sectors != null && (
|
|
<span className={smart.pending_sectors > 0 ? "text-yellow-500" : ""}>
|
|
<span className="text-foreground font-medium">Pending:</span> {smart.pending_sectors}
|
|
</span>
|
|
)}
|
|
{smart.uncorrectable != null && (
|
|
<span className={smart.uncorrectable > 0 ? "text-red-500" : ""}>
|
|
<span className="text-foreground font-medium">Uncorrectable:</span> {smart.uncorrectable}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">No SMART data available</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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) ? (
|
|
<DiskRow key={`${depth}-${i}-${v.name}`} v={v} depth={depth} poolName={poolName} onDiskMenu={onDiskMenu} />
|
|
) : (
|
|
<>
|
|
<tr key={`${depth}-${i}-${v.name}`} className="border-b border-border/40 hover:bg-muted/20">
|
|
<td className="px-4 py-2 font-mono text-xs font-medium" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
|
|
{v.name}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<HealthBadge health={v.state || "—"} />
|
|
</td>
|
|
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
|
<td className="px-4 py-2 text-xs text-center">{v.write ?? 0}</td>
|
|
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
|
|
<td /><td /><td />
|
|
</tr>
|
|
<VdevTree vdevs={v.children} depth={depth + 1} poolName={poolName} onDiskMenu={onDiskMenu} />
|
|
</>
|
|
)
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default function ZfsPage() {
|
|
const [pools, setPools] = useState<Pool[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
|
const [poolStates, setPoolStates] = useState<Map<string, PoolRowState>>(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<Snapshot | null>(null)
|
|
const [renameValue, setRenameValue] = useState("")
|
|
const [cloneTarget, setCloneTarget] = useState<Snapshot | null>(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<PoolRowState>) => {
|
|
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 (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
<div 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-6">
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<HardDrive className="w-6 h-6" />
|
|
Storage Pools
|
|
</h1>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => { setCreateFsPool(""); setNewFsName(""); setShowCreateFilesystem(true) }}
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Create Storage Pool
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={loadPools} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
|
<span className="ml-1 hidden sm:inline">Refresh</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{lastRefresh && (
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
{lastRefresh.toLocaleString()} · {pools.length} pool{pools.length !== 1 ? "s" : ""}
|
|
</p>
|
|
)}
|
|
|
|
{/* Pools Table */}
|
|
<div className="border border-border rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted border-b border-border">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium w-8" />
|
|
<th className="px-4 py-3 text-left font-medium">Name</th>
|
|
<th className="px-4 py-3 text-left font-medium">Health</th>
|
|
<th className="px-4 py-3 text-left font-medium">Size</th>
|
|
<th className="px-4 py-3 text-left font-medium">Allocated</th>
|
|
<th className="px-4 py-3 text-left font-medium">Free</th>
|
|
<th className="px-4 py-3 text-left font-medium">Fragmentation</th>
|
|
<th className="px-4 py-3 text-left font-medium">Usage</th>
|
|
<th className="px-4 py-3 text-right font-medium w-10" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && (
|
|
<tr>
|
|
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
|
|
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />
|
|
Loading…
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{!loading && pools.length === 0 && (
|
|
<tr>
|
|
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
|
|
No storage pools found
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{pools.map((pool) => {
|
|
const state = poolStates.get(pool.name)
|
|
const isExpanded = state?.expanded ?? false
|
|
const currentTab = state?.tab ?? "filesystems"
|
|
|
|
return [
|
|
/* Pool row */
|
|
<tr
|
|
key={pool.name}
|
|
className="border-b border-border hover:bg-muted/30 cursor-pointer select-none"
|
|
onClick={() => togglePool(pool)}
|
|
>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
</td>
|
|
<td className="px-4 py-3 font-medium font-mono">{pool.name}</td>
|
|
<td className="px-4 py-3">
|
|
<HealthBadge health={pool.health} />
|
|
</td>
|
|
<td className="px-4 py-3 text-xs">{formatBytes(pool.size)}</td>
|
|
<td className="px-4 py-3 text-xs">{formatBytes(pool.alloc)}</td>
|
|
<td className="px-4 py-3 text-xs">{formatBytes(pool.free)}</td>
|
|
<td className="px-4 py-3 text-xs">{pool.fragmentation}</td>
|
|
<td className="px-4 py-3">
|
|
<UsageBar alloc={pool.alloc} size={pool.size} />
|
|
</td>
|
|
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
className="p-1 rounded hover:bg-muted"
|
|
onClick={(e) => setPoolMenu({ pool, x: e.clientX, y: e.clientY })}
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>,
|
|
|
|
/* Expanded panel */
|
|
isExpanded && (
|
|
<tr key={`${pool.name}-panel`}>
|
|
<td colSpan={9} className="border-b border-border bg-muted/10 p-0">
|
|
<div className="px-6 pt-2 pb-4">
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-0 border-b border-border mb-4">
|
|
{(["filesystems", "snapshots", "status"] as PoolTab[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => switchTab(pool.name, t)}
|
|
className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
|
|
currentTab === t
|
|
? "border-primary text-primary"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{t === "filesystems" ? "File Systems" : t.charAt(0).toUpperCase() + t.slice(1)}
|
|
</button>
|
|
))}
|
|
|
|
{/* Tab-level actions */}
|
|
<div className="ml-auto flex items-center gap-2 pb-1">
|
|
{currentTab === "filesystems" && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
onClick={() => { setCreateFsPool(pool.name); setNewFsName(""); setShowCreateFilesystem(true) }}
|
|
>
|
|
<Plus className="w-3 h-3 mr-1" />
|
|
Create Filesystem
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={async () => {
|
|
updatePoolState(pool.name, { loadingDatasets: true })
|
|
const ds = await api.getDatasets(pool.name)
|
|
updatePoolState(pool.name, { datasets: ds, loadingDatasets: false })
|
|
}}>
|
|
<RefreshCw className={`w-3 h-3 ${state?.loadingDatasets ? "animate-spin" : ""}`} />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{currentTab === "snapshots" && (
|
|
<>
|
|
<Button size="sm" variant="default" onClick={async () => {
|
|
await api.createSnapshot(pool.name)
|
|
refreshSnapshots(pool.name)
|
|
}}>
|
|
<Camera className="w-3 h-3 mr-1" />
|
|
Create Snapshot
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => refreshSnapshots(pool.name)}>
|
|
<RefreshCw className={`w-3 h-3 ${state?.loadingSnapshots ? "animate-spin" : ""}`} />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{currentTab === "status" && (
|
|
<Button size="sm" variant="outline" onClick={() => refreshPoolStatus(pool.name)}>
|
|
<RefreshCw className={`w-3 h-3 mr-1 ${state?.loadingStatus ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── File Systems Tab ── */}
|
|
{currentTab === "filesystems" && (
|
|
state?.loadingDatasets ? (
|
|
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-medium">Name</th>
|
|
<th className="px-3 py-2 text-left font-medium">Available</th>
|
|
<th className="px-3 py-2 text-left font-medium">Used</th>
|
|
<th className="px-3 py-2 text-left font-medium">Snapshots</th>
|
|
<th className="px-3 py-2 text-left font-medium">Refreservation</th>
|
|
<th className="px-3 py-2 text-left font-medium">Record Size</th>
|
|
<th className="px-3 py-2 text-left font-medium">Compression</th>
|
|
<th className="px-3 py-2 text-left font-medium">Deduplication</th>
|
|
<th className="px-3 py-2 text-left font-medium">Share</th>
|
|
<th className="px-3 py-2 text-left font-medium">Mounted</th>
|
|
<th className="px-3 py-2 text-right font-medium w-8" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(state?.datasets ?? []).map((ds) => {
|
|
const depth = ds.name.split("/").length - 1
|
|
return (
|
|
<tr key={ds.name} className="border-b border-border/40 hover:bg-muted/30">
|
|
<td className="px-3 py-2 font-mono" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
|
|
{ds.name.split("/").pop()}
|
|
</td>
|
|
<td className="px-3 py-2">{formatBytes(ds.avail)}</td>
|
|
<td className="px-3 py-2">{formatBytes(ds.used)}</td>
|
|
<td className="px-3 py-2 text-muted-foreground">—</td>
|
|
<td className="px-3 py-2 text-muted-foreground">
|
|
{ds.reservation ? formatBytes(ds.reservation) : "0 B"}
|
|
</td>
|
|
<td className="px-3 py-2 text-muted-foreground">—</td>
|
|
<td className="px-3 py-2">{ds.compression || "off"}</td>
|
|
<td className="px-3 py-2 text-muted-foreground">Off</td>
|
|
<td className="px-3 py-2 text-muted-foreground">Off</td>
|
|
<td className="px-3 py-2">{ds.mountpoint ? "Yes" : "No"}</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<button className="p-1 rounded hover:bg-muted">
|
|
<MoreVertical className="w-3 h-3" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
{(state?.datasets ?? []).length === 0 && (
|
|
<tr>
|
|
<td colSpan={11} className="px-3 py-4 text-center text-muted-foreground">
|
|
No filesystems
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* ── Snapshots Tab ── */}
|
|
{currentTab === "snapshots" && (
|
|
state?.loadingSnapshots ? (
|
|
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…
|
|
</div>
|
|
) : (state?.snapshots ?? []).length === 0 ? (
|
|
<div className="py-6 text-center text-muted-foreground text-sm">No snapshots</div>
|
|
) : (() => {
|
|
const groups = new Map<string, Snapshot[]>()
|
|
;(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 (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-medium">Name</th>
|
|
<th className="px-3 py-2 text-left font-medium">Created</th>
|
|
<th className="px-3 py-2 text-left font-medium">Used</th>
|
|
<th className="px-3 py-2 text-left font-medium">Referenced</th>
|
|
<th className="px-3 py-2 text-left font-medium">Clones</th>
|
|
<th className="px-3 py-2 text-right font-medium w-8" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Array.from(groups.entries()).map(([ds, snaps]) => {
|
|
const groupExpanded = state?.expandedSnapGroups.has(ds) ?? false
|
|
return [
|
|
<tr
|
|
key={`grp-${ds}`}
|
|
className="border-b border-border bg-muted/20 hover:bg-muted/40 cursor-pointer"
|
|
onClick={() => toggleSnapGroup(pool.name, ds)}
|
|
>
|
|
<td className="px-3 py-2 font-mono font-medium" colSpan={5}>
|
|
<div className="flex items-center gap-2">
|
|
{groupExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
{ds}
|
|
<span className="text-muted-foreground font-normal">({snaps.length})</span>
|
|
</div>
|
|
</td>
|
|
<td />
|
|
</tr>,
|
|
...(groupExpanded ? snaps.map((snap) => (
|
|
<tr key={snap.name} className="border-b border-border/40 hover:bg-muted/30">
|
|
<td className="px-3 py-1.5 font-mono pl-8">{snap.name.split("@")[1]}</td>
|
|
<td className="px-3 py-1.5 text-muted-foreground">
|
|
{snap.creation_datetime ? new Date(snap.creation_datetime).toLocaleString() : "—"}
|
|
</td>
|
|
<td className="px-3 py-1.5">{formatBytes(snap.used)}</td>
|
|
<td className="px-3 py-1.5">{formatBytes(snap.referenced)}</td>
|
|
<td className="px-3 py-1.5 text-muted-foreground">—</td>
|
|
<td className="px-3 py-1.5 text-right">
|
|
<button
|
|
className="p-1 rounded hover:bg-muted"
|
|
onClick={(e) => { e.stopPropagation(); setSnapContextMenu({ snap, x: e.clientX, y: e.clientY }) }}
|
|
>
|
|
<MoreVertical className="w-3 h-3" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)) : [])
|
|
]
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
})()
|
|
)}
|
|
|
|
{/* ── Status Tab ── */}
|
|
{currentTab === "status" && (
|
|
state?.loadingStatus ? (
|
|
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…
|
|
</div>
|
|
) : state?.status ? (
|
|
<div>
|
|
<div className="grid grid-cols-2 gap-x-8 gap-y-2 mb-6 text-sm">
|
|
<div>
|
|
<span className="font-medium">Pool: </span>
|
|
<span className="font-mono">{state.status.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">State: </span>
|
|
<HealthBadge health={state.status.state ?? state.status.health} />
|
|
</div>
|
|
<div className="col-span-2">
|
|
<span className="font-medium">Scan: </span>
|
|
<span className="text-muted-foreground">{state.status.scan ?? "—"}</span>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<span className="font-medium">Errors: </span>
|
|
<span className="text-muted-foreground">{state.status.errors ?? "No known data errors"}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left font-medium">Name</th>
|
|
<th className="px-4 py-2 text-left font-medium">State</th>
|
|
<th className="px-4 py-2 text-center font-medium">Read</th>
|
|
<th className="px-4 py-2 text-center font-medium">Write</th>
|
|
<th className="px-4 py-2 text-center font-medium">Checksum</th>
|
|
<th className="px-4 py-2 text-left font-medium">Temp</th>
|
|
<th className="px-4 py-2 text-left font-medium">SMART</th>
|
|
<th className="px-4 py-2 w-8" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<VdevTree
|
|
vdevs={state.status.vdevs ?? []}
|
|
poolName={pool.name}
|
|
onDiskMenu={handleDiskMenuOpen}
|
|
/>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
No status data available
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
),
|
|
]
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create Filesystem Dialog */}
|
|
<Dialog
|
|
open={showCreateFilesystem}
|
|
onClose={() => setShowCreateFilesystem(false)}
|
|
title="Create Filesystem"
|
|
>
|
|
<p className="text-xs text-muted-foreground mb-3">
|
|
Pool: <span className="font-mono">{createFsPool || "—"}</span>
|
|
</p>
|
|
<input
|
|
type="text"
|
|
placeholder={`name (will be created as ${createFsPool}/name)`}
|
|
value={newFsName}
|
|
onChange={(e) => 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
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleCreateFilesystem} className="flex-1">Create</Button>
|
|
<Button onClick={() => setShowCreateFilesystem(false)} variant="outline" className="flex-1">Cancel</Button>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Rename Snapshot Dialog */}
|
|
<Dialog open={!!renameTarget} onClose={() => setRenameTarget(null)} title="Rename Snapshot">
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
New name for <span className="font-mono">{renameTarget?.name.split("@")[1]}</span>
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) => 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
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleRenameConfirm} className="flex-1">Rename</Button>
|
|
<Button onClick={() => setRenameTarget(null)} variant="outline" className="flex-1">Cancel</Button>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Clone Snapshot Dialog */}
|
|
<Dialog open={!!cloneTarget} onClose={() => setCloneTarget(null)} title="Clone Snapshot">
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
Clone <span className="font-mono">{cloneTarget?.name.split("@")[1]}</span> to:
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={cloneValue}
|
|
onChange={(e) => 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
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleCloneConfirm} className="flex-1">Clone</Button>
|
|
<Button onClick={() => setCloneTarget(null)} variant="outline" className="flex-1">Cancel</Button>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Disk Context Menu */}
|
|
{diskMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setDiskMenu(null)} />
|
|
<div
|
|
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[200px]"
|
|
style={{ top: diskMenu.y, left: diskMenu.x }}
|
|
>
|
|
<div className="px-4 py-1.5 text-xs text-muted-foreground font-mono border-b border-border mb-1">
|
|
{diskMenu.disk}
|
|
</div>
|
|
{[
|
|
{ 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 }) => (
|
|
<button
|
|
key={action}
|
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-muted ${danger ? "text-destructive" : ""}`}
|
|
onClick={() => handleDiskAction(action)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Pool Context Menu */}
|
|
{poolMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setPoolMenu(null)} />
|
|
<div
|
|
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[220px]"
|
|
style={{ top: poolMenu.y, left: poolMenu.x }}
|
|
>
|
|
{[
|
|
{ action: "scrub", label: "Scrub Storage Pool" },
|
|
{ action: "resilver", label: "Resilver Storage Pool" },
|
|
{ action: "clear", label: "Clear Storage Pool Errors" },
|
|
].map(({ action, label }) => (
|
|
<button
|
|
key={action}
|
|
className="w-full text-left px-4 py-2 text-sm hover:bg-muted"
|
|
onClick={() => handlePoolAction(action, poolMenu.pool)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Snapshot Context Menu */}
|
|
{snapContextMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setSnapContextMenu(null)} />
|
|
<div
|
|
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[200px]"
|
|
style={{ top: snapContextMenu.y, left: snapContextMenu.x }}
|
|
>
|
|
{[
|
|
{ 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 }) => (
|
|
<button
|
|
key={action}
|
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-muted ${danger ? "text-destructive" : ""}`}
|
|
onClick={() => {
|
|
const poolName = snapContextMenu.snap.name.split("@")[0].split("/")[0]
|
|
handleSnapAction(action, snapContextMenu.snap, poolName)
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|