Refactor: /datasets und /snapshots entfernt, neue /zfs-Seite nach Cockpit-Vorbild
- Neue Seite /zfs: Pools-Tabelle mit aufklappbaren Zeilen, Tabs File Systems / Snapshots / Status, vdev-Baum im Status-Tab - /datasets und /snapshots gelöscht (Funktionalität konsolidiert) - Header bereinigt: ZFS-Link ersetzt Datasets/Snapshots, navLink-Hilfsfunktion reduziert Redundanz Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,951 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { api, Dataset, SambaShare, NfsShare, Pool } from "@/lib/api"
|
|
||||||
import { Header } from "@/components/Header"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Dialog } from "@/components/ui/dialog"
|
|
||||||
import { Plus, Trash2, RefreshCw, ChevronRight, ChevronDown, MoreVertical, Camera } from "lucide-react"
|
|
||||||
import { Snapshot } from "@/lib/api"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
|
|
||||||
export default function DatasetsPage() {
|
|
||||||
const [tab, setTab] = useState<"datasets" | "shares">("datasets")
|
|
||||||
const [datasets, setDatasets] = useState<Dataset[]>([])
|
|
||||||
const [pools, setPools] = useState<Pool[]>([])
|
|
||||||
const [sambaShares, setSambaShares] = useState<SambaShare[]>([])
|
|
||||||
const [nfsShares, setNfsShares] = useState<NfsShare[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [expandedDatasets, setExpandedDatasets] = useState<Set<string>>(new Set())
|
|
||||||
const [poolTabs, setPoolTabs] = useState<Map<string, string>>(new Map())
|
|
||||||
const [poolSnapshots, setPoolSnapshots] = useState<Map<string, Snapshot[]>>(new Map())
|
|
||||||
const [loadingSnaps, setLoadingSnaps] = useState<Set<string>>(new Set())
|
|
||||||
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("")
|
|
||||||
|
|
||||||
// Dialogs
|
|
||||||
const [showCreateDataset, setShowCreateDataset] = useState(false)
|
|
||||||
const [showCreateSambaShare, setShowCreateSambaShare] = useState(false)
|
|
||||||
const [showCreateNfsShare, setShowCreateNfsShare] = useState(false)
|
|
||||||
const [deleteDataset, setDeleteDataset] = useState<string | null>(null)
|
|
||||||
const [deleteSambaShare, setDeleteSambaShare] = useState<string | null>(null)
|
|
||||||
const [deleteNfsShare, setDeleteNfsShare] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Form states
|
|
||||||
const [newDatasetName, setNewDatasetName] = useState("")
|
|
||||||
const [newSambaName, setNewSambaName] = useState("")
|
|
||||||
const [newSambaPath, setNewSambaPath] = useState("")
|
|
||||||
const [newSambaComment, setNewSambaComment] = useState("")
|
|
||||||
const [newNfsPath, setNewNfsPath] = useState("")
|
|
||||||
const [newNfsClients, setNewNfsClients] = useState("")
|
|
||||||
const [newNfsOptions, setNewNfsOptions] = useState("ro,sync,no_subtree_check")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const [ds, samba, nfs, poolData] = await Promise.all([
|
|
||||||
api.getDatasets(),
|
|
||||||
api.getSambaShares(),
|
|
||||||
api.getNfsShares(),
|
|
||||||
api.getPools().catch(() => []),
|
|
||||||
])
|
|
||||||
setDatasets(ds)
|
|
||||||
setSambaShares(samba)
|
|
||||||
setNfsShares(nfs)
|
|
||||||
setPools(Array.isArray(poolData) ? poolData : [])
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load data:", err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadPoolSnapshots = async (poolName: string) => {
|
|
||||||
setLoadingSnaps((s) => new Set(s).add(poolName))
|
|
||||||
try {
|
|
||||||
const snaps = await api.getSnapshots(poolName, 200)
|
|
||||||
setPoolSnapshots((m) => new Map(m).set(poolName, snaps))
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load snapshots:", err)
|
|
||||||
} finally {
|
|
||||||
setLoadingSnaps((s) => { const n = new Set(s); n.delete(poolName); return n })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSnapTab = (poolName: string) => {
|
|
||||||
setPoolTabs(new Map(poolTabs).set(poolName, "snapshots"))
|
|
||||||
if (!poolSnapshots.has(poolName)) loadPoolSnapshots(poolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSnapAction = async (action: string, snap: Snapshot) => {
|
|
||||||
setSnapContextMenu(null)
|
|
||||||
if (action === "rollback") {
|
|
||||||
if (!confirm(`Roll back ${snap.dataset} to ${snap.name.split("@")[1]}? This destroys data after this snapshot!`)) return
|
|
||||||
await api.rollbackSnapshot(snap.name)
|
|
||||||
loadPoolSnapshots(snap.name.split("@")[0].split("/")[0])
|
|
||||||
} else if (action === "destroy") {
|
|
||||||
if (!confirm(`Destroy snapshot ${snap.name.split("@")[1]}?`)) return
|
|
||||||
await api.deleteSnapshot(snap.name)
|
|
||||||
const pool = snap.name.split("@")[0].split("/")[0]
|
|
||||||
loadPoolSnapshots(pool)
|
|
||||||
} 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())
|
|
||||||
loadPoolSnapshots(renameTarget.name.split("@")[0].split("/")[0])
|
|
||||||
setRenameTarget(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloneConfirm = async () => {
|
|
||||||
if (!cloneTarget || !cloneValue.trim()) return
|
|
||||||
await api.cloneSnapshot(cloneTarget.name, cloneValue.trim())
|
|
||||||
loadData()
|
|
||||||
setCloneTarget(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateDataset = async () => {
|
|
||||||
if (!newDatasetName.trim()) return
|
|
||||||
try {
|
|
||||||
// Create dataset via API
|
|
||||||
await api.createDataset(newDatasetName, {})
|
|
||||||
setNewDatasetName("")
|
|
||||||
setShowCreateDataset(false)
|
|
||||||
loadData()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create dataset:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteDataset = async (name: string) => {
|
|
||||||
try {
|
|
||||||
await api.deleteDataset(name)
|
|
||||||
setDeleteDataset(null)
|
|
||||||
loadData()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete dataset:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateSambaShare = async () => {
|
|
||||||
if (!newSambaName.trim() || !newSambaPath.trim()) return
|
|
||||||
try {
|
|
||||||
await api.createSambaShare({
|
|
||||||
name: newSambaName,
|
|
||||||
path: newSambaPath,
|
|
||||||
comment: newSambaComment || undefined,
|
|
||||||
})
|
|
||||||
setNewSambaName("")
|
|
||||||
setNewSambaPath("")
|
|
||||||
setNewSambaComment("")
|
|
||||||
setShowCreateSambaShare(false)
|
|
||||||
loadData()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create Samba share:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteSambaShare = async (name: string) => {
|
|
||||||
try {
|
|
||||||
await api.deleteSambaShare(name)
|
|
||||||
setDeleteSambaShare(null)
|
|
||||||
loadData()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete Samba share:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateNfsShare = async () => {
|
|
||||||
if (!newNfsPath.trim() || !newNfsClients.trim()) return
|
|
||||||
try {
|
|
||||||
await api.createNfsShare({
|
|
||||||
path: newNfsPath,
|
|
||||||
clients: newNfsClients,
|
|
||||||
options: newNfsOptions || undefined,
|
|
||||||
})
|
|
||||||
setNewNfsPath("")
|
|
||||||
setNewNfsClients("")
|
|
||||||
setNewNfsOptions("ro,sync,no_subtree_check")
|
|
||||||
setShowCreateNfsShare(false)
|
|
||||||
loadData()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create NFS share:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteNfsShare = async (path: string) => {
|
|
||||||
try {
|
|
||||||
await api.deleteNfsShare(path)
|
|
||||||
setDeleteNfsShare(null)
|
|
||||||
loadData()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete NFS share:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
|
||||||
if (bytes === 0) return "0 B"
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDatasetDepth = (name: string): number => {
|
|
||||||
return name.split("/").length - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTopLevelDatasets = (): Dataset[] => {
|
|
||||||
return datasets.filter((ds) => ds.name.split("/").length === 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const getChildDatasets = (parent: string): Dataset[] => {
|
|
||||||
const prefix = parent + "/"
|
|
||||||
return datasets.filter((ds) => ds.name.startsWith(prefix) && ds.name !== parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleExpand = (name: string) => {
|
|
||||||
const newExpanded = new Set(expandedDatasets)
|
|
||||||
if (newExpanded.has(name)) {
|
|
||||||
newExpanded.delete(name)
|
|
||||||
} else {
|
|
||||||
newExpanded.add(name)
|
|
||||||
}
|
|
||||||
setExpandedDatasets(newExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderDatasetTree = (parent?: string): React.ReactNode[] => {
|
|
||||||
const items: React.ReactNode[] = []
|
|
||||||
const datasetList = parent ? getChildDatasets(parent) : getTopLevelDatasets()
|
|
||||||
|
|
||||||
datasetList.forEach((ds) => {
|
|
||||||
const children = getChildDatasets(ds.name)
|
|
||||||
const isExpanded = expandedDatasets.has(ds.name)
|
|
||||||
const depth = getDatasetDepth(ds.name)
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
<tr key={ds.name} className="border-b border-border hover:bg-muted/50">
|
|
||||||
<td className="px-4 py-3 font-mono text-xs" style={{ paddingLeft: `${depth * 24 + 16}px` }}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{children.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleExpand(ds.name)}
|
|
||||||
className="p-0 hover:bg-muted rounded"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{children.length === 0 && <div className="w-4" />}
|
|
||||||
<span>{ds.name.split("/").pop()}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{ds.type}</td>
|
|
||||||
<td className="px-4 py-3">{formatBytes(ds.used || 0)}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{ds.mountpoint || "—"}</td>
|
|
||||||
<td className="px-4 py-3">{ds.compression || "off"}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteDataset(ds.name)}
|
|
||||||
className="text-destructive hover:underline"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isExpanded && children.length > 0) {
|
|
||||||
items.push(...renderDatasetTree(ds.name))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="p-8">Loading...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h1 className="text-3xl font-bold">Datasets & Shares</h1>
|
|
||||||
<Button onClick={loadData} variant="outline" size="sm">
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="flex gap-2 mb-6 border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("datasets")}
|
|
||||||
className={`px-4 py-2 font-medium transition-colors ${
|
|
||||||
tab === "datasets"
|
|
||||||
? "text-foreground border-b-2 border-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Datasets
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTab("shares")}
|
|
||||||
className={`px-4 py-2 font-medium transition-colors ${
|
|
||||||
tab === "shares"
|
|
||||||
? "text-foreground border-b-2 border-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Shares (Samba & NFS)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Datasets Tab */}
|
|
||||||
{tab === "datasets" && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<Button onClick={() => setShowCreateDataset(true)} size="sm">
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Dataset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{getTopLevelDatasets().map((pool) => {
|
|
||||||
const poolInfo = pools.find((p) => p.name === pool.name)
|
|
||||||
const currentPoolTab = poolTabs.get(pool.name) || "filesystems"
|
|
||||||
const childDatasets = getChildDatasets(pool.name)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={pool.name}>
|
|
||||||
{/* Pool Header */}
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<CardTitle className="text-xl">{pool.name}</CardTitle>
|
|
||||||
<Badge variant="outline">ONLINE</Badge>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteDataset(pool.name)}
|
|
||||||
className="text-destructive hover:underline"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pool Stats Grid */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Size</p>
|
|
||||||
<p className="font-bold">{formatBytes(poolInfo?.size || 0)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Allocated</p>
|
|
||||||
<p className="font-bold">{formatBytes(poolInfo?.alloc || 0)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Free</p>
|
|
||||||
<p className="font-bold">{formatBytes(poolInfo?.free || 0)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Fragmentation</p>
|
|
||||||
<p className="font-bold">{poolInfo?.fragmentation || "0%"}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Usage</p>
|
|
||||||
<p className="font-bold">{poolInfo?.capacity || (poolInfo?.size ? ((poolInfo.alloc / poolInfo.size) * 100).toFixed(1) + "%" : "—")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage Bar */}
|
|
||||||
{(() => {
|
|
||||||
const pct = poolInfo?.size ? (poolInfo.alloc / poolInfo.size) * 100 : 0
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex h-6 bg-muted rounded overflow-hidden">
|
|
||||||
<div className="bg-blue-500" style={{ width: `${pct}%` }} />
|
|
||||||
<div className="bg-green-500" style={{ width: `${100 - pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
{pct.toFixed(2)}% Allocated • {(100 - pct).toFixed(2)}% Free
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<CardContent>
|
|
||||||
<div className="border-b border-border mb-4">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setPoolTabs(new Map(poolTabs).set(pool.name, "filesystems"))}
|
|
||||||
className={`px-4 py-2 font-medium transition-colors ${
|
|
||||||
currentPoolTab === "filesystems"
|
|
||||||
? "border-b-2 border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
File Systems ({childDatasets.length + 1})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSnapTab(pool.name)}
|
|
||||||
className={`px-4 py-2 font-medium transition-colors ${
|
|
||||||
currentPoolTab === "snapshots"
|
|
||||||
? "border-b-2 border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Snapshots
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPoolTabs(new Map(poolTabs).set(pool.name, "status"))}
|
|
||||||
className={`px-4 py-2 font-medium transition-colors ${
|
|
||||||
currentPoolTab === "status"
|
|
||||||
? "border-b-2 border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Systems Tab */}
|
|
||||||
{currentPoolTab === "filesystems" && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs">Name</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs">Type</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs">Used</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs">Available</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs">Mountpoint</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium text-xs">Compression</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="border-b border-border/50 hover:bg-muted/30">
|
|
||||||
<td className="px-4 py-2 font-mono text-xs">{pool.name}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{pool.type}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{formatBytes(pool.used || 0)}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{formatBytes(pool.avail || 0)}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{pool.mountpoint || "—"}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{pool.compression || "off"}</td>
|
|
||||||
</tr>
|
|
||||||
{childDatasets.map((ds) => (
|
|
||||||
<tr key={ds.name} className="border-b border-border/50 hover:bg-muted/30">
|
|
||||||
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: "32px" }}>
|
|
||||||
{ds.name.split("/").pop()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{ds.type}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{formatBytes(ds.used || 0)}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{formatBytes(ds.avail || 0)}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{ds.mountpoint || "—"}</td>
|
|
||||||
<td className="px-4 py-2 text-xs">{ds.compression || "off"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snapshots Tab */}
|
|
||||||
{currentPoolTab === "snapshots" && (() => {
|
|
||||||
const snaps = poolSnapshots.get(pool.name) || []
|
|
||||||
const isLoading = loadingSnaps.has(pool.name)
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<span className="text-xs text-muted-foreground">{snaps.length} snapshots</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => loadPoolSnapshots(pool.name)} disabled={isLoading}>
|
|
||||||
<RefreshCw className={`w-3 h-3 mr-1 ${isLoading ? "animate-spin" : ""}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={async () => { await api.createSnapshot(pool.name); loadPoolSnapshots(pool.name) }}>
|
|
||||||
<Camera className="w-3 h-3 mr-1" />
|
|
||||||
Create Snapshot
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-6 text-muted-foreground text-xs">Loading…</div>
|
|
||||||
) : snaps.length === 0 ? (
|
|
||||||
<div className="text-center py-6 text-muted-foreground text-xs">No snapshots</div>
|
|
||||||
) : (() => {
|
|
||||||
// Group snapshots by dataset
|
|
||||||
const groups = new Map<string, Snapshot[]>()
|
|
||||||
snaps.forEach((s) => {
|
|
||||||
const ds = s.dataset || s.name.split("@")[0]
|
|
||||||
if (!groups.has(ds)) groups.set(ds, [])
|
|
||||||
groups.get(ds)!.push(s)
|
|
||||||
})
|
|
||||||
const expandKey = `snapgroup-${pool.name}`
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-muted">
|
|
||||||
<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"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{Array.from(groups.entries()).map(([ds, dsSnaps]) => {
|
|
||||||
const isGroupExpanded = expandedDatasets.has(`${expandKey}-${ds}`)
|
|
||||||
return [
|
|
||||||
// Group header row
|
|
||||||
<tr
|
|
||||||
key={`group-${ds}`}
|
|
||||||
className="border-b border-border bg-muted/20 hover:bg-muted/40 cursor-pointer select-none"
|
|
||||||
onClick={() => {
|
|
||||||
const next = new Set(expandedDatasets)
|
|
||||||
if (isGroupExpanded) next.delete(`${expandKey}-${ds}`)
|
|
||||||
else next.add(`${expandKey}-${ds}`)
|
|
||||||
setExpandedDatasets(next)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 font-mono font-medium" colSpan={5}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isGroupExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
||||||
<span>{ds}</span>
|
|
||||||
<span className="text-muted-foreground font-normal">{dsSnaps.length}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
</tr>,
|
|
||||||
// Snapshot rows (expanded)
|
|
||||||
...(isGroupExpanded ? dsSnaps.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>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Status Tab */}
|
|
||||||
{currentPoolTab === "status" && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Health</p>
|
|
||||||
<p className="font-bold">ONLINE</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Mounted</p>
|
|
||||||
<p className="font-bold">Yes</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Record Size</p>
|
|
||||||
<p className="font-bold">128 KiB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{datasets.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">No datasets found</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Shares Tab */}
|
|
||||||
{tab === "shares" && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xl font-bold mb-4">Samba Shares</h2>
|
|
||||||
<div className="mb-4">
|
|
||||||
<Button onClick={() => setShowCreateSambaShare(true)} size="sm">
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Samba Share
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-border rounded-lg overflow-x-auto">
|
|
||||||
<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">Name</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Path</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Comment</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sambaShares.map((share) => (
|
|
||||||
<tr key={share.name} className="border-b border-border hover:bg-muted/50">
|
|
||||||
<td className="px-4 py-3 font-mono text-xs">{share.name}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{share.path}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{share.comment || "—"}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteSambaShare(share.name)}
|
|
||||||
className="text-destructive hover:underline"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sambaShares.length === 0 && (
|
|
||||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
|
||||||
No Samba shares
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold mb-4">NFS Shares</h2>
|
|
||||||
<div className="mb-4">
|
|
||||||
<Button onClick={() => setShowCreateNfsShare(true)} size="sm">
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create NFS Share
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-border rounded-lg overflow-x-auto">
|
|
||||||
<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">Path</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Clients</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Options</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{nfsShares.map((share) => (
|
|
||||||
<tr key={share.path} className="border-b border-border hover:bg-muted/50">
|
|
||||||
<td className="px-4 py-3 text-xs font-mono">{share.path}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{share.clients}</td>
|
|
||||||
<td className="px-4 py-3 text-xs">{share.options || "—"}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteNfsShare(share.path)}
|
|
||||||
className="text-destructive hover:underline"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{nfsShares.length === 0 && (
|
|
||||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
|
||||||
No NFS shares
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Dataset Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={showCreateDataset}
|
|
||||||
onClose={() => setShowCreateDataset(false)}
|
|
||||||
title="Create Dataset"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="dataset name (e.g., tank/data)"
|
|
||||||
value={newDatasetName}
|
|
||||||
onChange={(e) => setNewDatasetName(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleCreateDataset} className="flex-1">
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowCreateDataset(false)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Dataset Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={!!deleteDataset}
|
|
||||||
onClose={() => setDeleteDataset(null)}
|
|
||||||
title="Delete Dataset"
|
|
||||||
>
|
|
||||||
<p className="mb-4 text-sm">
|
|
||||||
Are you sure you want to delete <span className="font-mono">{deleteDataset}</span>?
|
|
||||||
This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => deleteDataset && handleDeleteDataset(deleteDataset)}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDeleteDataset(null)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Create Samba Share Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={showCreateSambaShare}
|
|
||||||
onClose={() => setShowCreateSambaShare(false)}
|
|
||||||
title="Create Samba Share"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="share name"
|
|
||||||
value={newSambaName}
|
|
||||||
onChange={(e) => setNewSambaName(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="path (e.g., /mnt/tank/share)"
|
|
||||||
value={newSambaPath}
|
|
||||||
onChange={(e) => setNewSambaPath(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="comment (optional)"
|
|
||||||
value={newSambaComment}
|
|
||||||
onChange={(e) => setNewSambaComment(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground text-sm"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleCreateSambaShare} className="flex-1">
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowCreateSambaShare(false)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Samba Share Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={!!deleteSambaShare}
|
|
||||||
onClose={() => setDeleteSambaShare(null)}
|
|
||||||
title="Delete Samba Share"
|
|
||||||
>
|
|
||||||
<p className="mb-4 text-sm">
|
|
||||||
Are you sure you want to delete <span className="font-mono">{deleteSambaShare}</span>?
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => deleteSambaShare && handleDeleteSambaShare(deleteSambaShare)}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDeleteSambaShare(null)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Create NFS Share Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={showCreateNfsShare}
|
|
||||||
onClose={() => setShowCreateNfsShare(false)}
|
|
||||||
title="Create NFS Share"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="path (e.g., /mnt/tank/share)"
|
|
||||||
value={newNfsPath}
|
|
||||||
onChange={(e) => setNewNfsPath(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="clients (e.g., 192.168.1.0/24 or *)"
|
|
||||||
value={newNfsClients}
|
|
||||||
onChange={(e) => setNewNfsClients(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-3 bg-background text-foreground text-sm"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="options"
|
|
||||||
value={newNfsOptions}
|
|
||||||
onChange={(e) => setNewNfsOptions(e.target.value)}
|
|
||||||
className="w-full border border-input rounded px-3 py-2 mb-4 bg-background text-foreground text-sm"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleCreateNfsShare} className="flex-1">
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowCreateNfsShare(false)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete NFS Share Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={!!deleteNfsShare}
|
|
||||||
onClose={() => setDeleteNfsShare(null)}
|
|
||||||
title="Delete NFS Share"
|
|
||||||
>
|
|
||||||
<p className="mb-4 text-sm">
|
|
||||||
Are you sure you want to delete <span className="font-mono">{deleteNfsShare}</span>?
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => deleteNfsShare && handleDeleteNfsShare(deleteNfsShare)}
|
|
||||||
variant="destructive"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDeleteNfsShare(null)}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 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-[180px]"
|
|
||||||
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={() => handleSnapAction(action, snapContextMenu.snap)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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()}
|
|
||||||
/>
|
|
||||||
<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()}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { api, Snapshot, Dataset } from "@/lib/api"
|
|
||||||
import { Header } from "@/components/Header"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Dialog } from "@/components/ui/dialog"
|
|
||||||
import { RefreshCw, AlertCircle, Trash2, Plus, RotateCcw, ChevronRight, ChevronDown } from "lucide-react"
|
|
||||||
import { formatBytes } from "@/lib/utils"
|
|
||||||
|
|
||||||
function formatUnix(ts: number) {
|
|
||||||
return new Date(ts * 1000).toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SnapshotsPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
|
||||||
const [datasets, setDatasets] = useState<Dataset[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [deleting, setDeleting] = useState<string | null>(null)
|
|
||||||
const [filterDataset, setFilterDataset] = useState("")
|
|
||||||
|
|
||||||
// Create dialog
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
const [createDataset, setCreateDataset] = useState("")
|
|
||||||
const [createName, setCreateName] = useState("")
|
|
||||||
const [creating, setCreating] = useState(false)
|
|
||||||
|
|
||||||
// Rollback dialog
|
|
||||||
const [rollbackTarget, setRollbackTarget] = useState<string | null>(null)
|
|
||||||
const [rollingBack, setRollingBack] = useState(false)
|
|
||||||
const [expandedDatasets, setExpandedDatasets] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const fetchSnapshots = useCallback(async (dataset?: string) => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const data = await api.getSnapshots(dataset || undefined)
|
|
||||||
setSnapshots(data)
|
|
||||||
// Auto-expand all top-level datasets so rows are visible by default
|
|
||||||
const topDs = new Set(data.map((s) => s.name.split("@")[0].split("/")[0]))
|
|
||||||
setExpandedDatasets(topDs)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch snapshots")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const token = localStorage.getItem("access_token")
|
|
||||||
if (!token) { router.push("/login"); return }
|
|
||||||
|
|
||||||
fetchSnapshots()
|
|
||||||
api.getDatasets().then(setDatasets).catch(() => {})
|
|
||||||
const iv = setInterval(() => fetchSnapshots(filterDataset || undefined), 60000)
|
|
||||||
return () => clearInterval(iv)
|
|
||||||
}, [router, fetchSnapshots, filterDataset])
|
|
||||||
|
|
||||||
const handleFilterChange = (ds: string) => {
|
|
||||||
setFilterDataset(ds)
|
|
||||||
fetchSnapshots(ds || undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (name: string) => {
|
|
||||||
if (!confirm(`Delete snapshot "${name}"?\nThis cannot be undone.`)) return
|
|
||||||
try {
|
|
||||||
setDeleting(name)
|
|
||||||
await api.deleteSnapshot(name)
|
|
||||||
setSnapshots((prev) => prev.filter((s) => s.name !== name))
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to delete snapshot")
|
|
||||||
} finally {
|
|
||||||
setDeleting(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!createDataset) return
|
|
||||||
setCreating(true)
|
|
||||||
try {
|
|
||||||
await api.createSnapshot(createDataset, createName || undefined)
|
|
||||||
setCreateOpen(false)
|
|
||||||
setCreateDataset("")
|
|
||||||
setCreateName("")
|
|
||||||
fetchSnapshots(filterDataset || undefined)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to create snapshot")
|
|
||||||
} finally {
|
|
||||||
setCreating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRollback = async () => {
|
|
||||||
if (!rollbackTarget) return
|
|
||||||
setRollingBack(true)
|
|
||||||
try {
|
|
||||||
await api.rollbackSnapshot(rollbackTarget)
|
|
||||||
setRollbackTarget(null)
|
|
||||||
fetchSnapshots(filterDataset || undefined)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Rollback failed")
|
|
||||||
} finally {
|
|
||||||
setRollingBack(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unique dataset names for filter dropdown
|
|
||||||
const datasetNames = Array.from(
|
|
||||||
new Set(snapshots.map((s) => s.name.split("@")[0]).filter(Boolean))
|
|
||||||
).sort()
|
|
||||||
|
|
||||||
const getDatasetDepth = (name: string): number => {
|
|
||||||
return name.split("/").length - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSnapshotsByDataset = (dsName: string): Snapshot[] => {
|
|
||||||
return snapshots.filter((s) => s.name.split("@")[0] === dsName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTopLevelDatasets = (): string[] => {
|
|
||||||
const topLevel = new Set<string>()
|
|
||||||
snapshots.forEach((snap) => {
|
|
||||||
const dsName = snap.name.split("@")[0]
|
|
||||||
const topDsName = dsName.split("/")[0]
|
|
||||||
topLevel.add(topDsName)
|
|
||||||
})
|
|
||||||
return Array.from(topLevel).sort()
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllDatasetsByPrefix = (prefix?: string): string[] => {
|
|
||||||
const allDs = new Set<string>()
|
|
||||||
snapshots.forEach((snap) => {
|
|
||||||
const dsName = snap.name.split("@")[0]
|
|
||||||
if (!prefix) {
|
|
||||||
if (dsName.split("/").length === 1) allDs.add(dsName)
|
|
||||||
} else {
|
|
||||||
const dsPrefix = prefix + "/"
|
|
||||||
if (dsName.startsWith(dsPrefix) && dsName !== prefix) {
|
|
||||||
const remaining = dsName.slice(dsPrefix.length)
|
|
||||||
if (remaining.split("/").length === 1) {
|
|
||||||
allDs.add(dsName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Array.from(allDs).sort()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleExpand = (name: string) => {
|
|
||||||
const newExpanded = new Set(expandedDatasets)
|
|
||||||
if (newExpanded.has(name)) {
|
|
||||||
newExpanded.delete(name)
|
|
||||||
} else {
|
|
||||||
newExpanded.add(name)
|
|
||||||
}
|
|
||||||
setExpandedDatasets(newExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderSnapshotTree = (parentDs?: string): React.ReactNode[] => {
|
|
||||||
const items: React.ReactNode[] = []
|
|
||||||
const datasets = parentDs ? getAllDatasetsByPrefix(parentDs) : getTopLevelDatasets()
|
|
||||||
|
|
||||||
datasets.forEach((dsName) => {
|
|
||||||
const childDatasets = getAllDatasetsByPrefix(dsName)
|
|
||||||
const isExpanded = expandedDatasets.has(dsName)
|
|
||||||
const depth = getDatasetDepth(dsName)
|
|
||||||
const snapshotsForDs = getSnapshotsByDataset(dsName)
|
|
||||||
|
|
||||||
snapshotsForDs.forEach((snap, idx) => {
|
|
||||||
const [, tag] = snap.name.split("@")
|
|
||||||
items.push(
|
|
||||||
<tr key={snap.name} className="border-b border-border/50 hover:bg-muted/30">
|
|
||||||
<td className="py-3 px-4 text-muted-foreground font-mono text-xs" style={{ paddingLeft: `${depth * 24 + 16}px` }}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{idx === 0 && childDatasets.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleExpand(dsName)}
|
|
||||||
className="p-0 hover:bg-muted rounded"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(idx > 0 || childDatasets.length === 0) && <div className="w-4" />}
|
|
||||||
<span>{idx === 0 ? dsName.split("/").pop() : ""}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 font-mono text-xs font-medium">{tag}</td>
|
|
||||||
<td className="py-3 px-4 text-muted-foreground">{formatUnix(snap.created)}</td>
|
|
||||||
<td className="py-3 px-4">{formatBytes(snap.used)}</td>
|
|
||||||
<td className="py-3 px-4">{formatBytes(snap.referenced)}</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
title="Rollback to this snapshot"
|
|
||||||
onClick={() => setRollbackTarget(snap.name)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
title="Delete snapshot"
|
|
||||||
onClick={() => handleDelete(snap.name)}
|
|
||||||
disabled={deleting === snap.name}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isExpanded && childDatasets.length > 0) {
|
|
||||||
items.push(...renderSnapshotTree(dsName))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
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-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Snapshots</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">Manage ZFS snapshots</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => fetchSnapshots(filterDataset || undefined)} disabled={loading}>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Snapshot
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter */}
|
|
||||||
<div className="mb-6 flex items-center gap-3">
|
|
||||||
<label className="text-sm text-muted-foreground">Dataset:</label>
|
|
||||||
<select
|
|
||||||
value={filterDataset}
|
|
||||||
onChange={(e) => handleFilterChange(e.target.value)}
|
|
||||||
className="text-sm bg-background border border-border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="">All datasets</option>
|
|
||||||
{datasetNames.map((ds) => (
|
|
||||||
<option key={ds} value={ds}>{ds}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="text-sm text-muted-foreground">{snapshots.length} snapshots</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 flex items-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
||||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{loading && snapshots.length === 0 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<RefreshCw className="w-8 h-8 text-muted-foreground animate-spin mx-auto" />
|
|
||||||
<p className="mt-4 text-muted-foreground">Loading snapshots…</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{snapshots.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Snapshots</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<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 text-muted-foreground">Dataset</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Snapshot</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Created</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Used</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Referenced</th>
|
|
||||||
<th className="text-right py-3 px-4 font-medium text-muted-foreground">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{renderSnapshotTree()}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && snapshots.length === 0 && !error && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
No snapshots found.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Create Snapshot Dialog */}
|
|
||||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="Create Snapshot">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Dataset *</label>
|
|
||||||
<select
|
|
||||||
value={createDataset}
|
|
||||||
onChange={(e) => setCreateDataset(e.target.value)}
|
|
||||||
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="">Select dataset…</option>
|
|
||||||
{datasets.filter((d) => d.type === "filesystem").map((d) => (
|
|
||||||
<option key={d.name} value={d.name}>{d.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">
|
|
||||||
Name <span className="text-muted-foreground font-normal">(optional, auto-generated if empty)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={createName}
|
|
||||||
onChange={(e) => setCreateName(e.target.value)}
|
|
||||||
placeholder="e.g. before-upgrade"
|
|
||||||
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={!createDataset || creating}>
|
|
||||||
{creating ? "Creating…" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Rollback Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={!!rollbackTarget}
|
|
||||||
onClose={() => setRollbackTarget(null)}
|
|
||||||
title="Rollback to Snapshot"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
|
||||||
<strong>Warning:</strong> Rollback will permanently destroy all data written after this snapshot.
|
|
||||||
This cannot be undone.
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">
|
|
||||||
Roll back to: <span className="font-mono font-medium">{rollbackTarget}</span>?
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="outline" onClick={() => setRollbackTarget(null)}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleRollback}
|
|
||||||
disabled={rollingBack}
|
|
||||||
>
|
|
||||||
{rollingBack ? "Rolling back…" : "Rollback"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VdevTree({ vdevs, depth = 0 }: { vdevs: any[]; depth?: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{vdevs.map((v, i) => (
|
||||||
|
<tr key={`${depth}-${i}`} 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` }}>
|
||||||
|
{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 className="px-4 py-2 text-xs text-muted-foreground">{v.message || ""}</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-muted-foreground">{v.product || ""}</td>
|
||||||
|
<td className="px-4 py-2 text-xs">
|
||||||
|
<button className="p-1 rounded hover:bg-muted">
|
||||||
|
<MoreVertical className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
// 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 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-full 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">
|
||||||
|
<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">Message</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Product</th>
|
||||||
|
<th className="px-4 py-2 w-8" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<VdevTree vdevs={state.status.vdevs ?? []} />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+30
-197
@@ -28,6 +28,20 @@ export function Header() {
|
|||||||
|
|
||||||
const isActive = (path: string) => pathname === path
|
const isActive = (path: string) => pathname === path
|
||||||
|
|
||||||
|
const navLink = (href: string, label: string, mobile = false) => (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`${mobile ? "block " : ""}px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
isActive(href)
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={mobile ? () => setIsMenuOpen(false) : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
@@ -40,100 +54,14 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<nav className="hidden md:flex items-center gap-1">
|
||||||
<Link
|
{navLink("/", "Dashboard")}
|
||||||
href="/"
|
{zfsAvailable && navLink("/zfs", "ZFS")}
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
{navLink("/navigator", "Navigator")}
|
||||||
isActive("/")
|
{navLink("/shares", "Shares")}
|
||||||
? "bg-accent text-accent-foreground"
|
{navLink("/file-sharing", "File Sharing")}
|
||||||
: "text-muted-foreground hover:text-foreground"
|
{navLink("/identities", "Identities")}
|
||||||
}`}
|
{navLink("/logs", "Logs")}
|
||||||
>
|
{navLink("/services", "Services")}
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
{zfsAvailable && (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href="/snapshots"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/snapshots")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Snapshots
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/datasets"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/datasets")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Datasets
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href="/navigator"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/navigator")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Navigator
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/shares"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/shares")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Shares
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/file-sharing"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/file-sharing")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
File Sharing
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/identities"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/identities")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Identities
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/logs"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/logs")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Logs
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive("/services")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Services
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Logout Button */}
|
{/* Logout Button */}
|
||||||
@@ -156,109 +84,14 @@ export function Header() {
|
|||||||
{/* Mobile Navigation */}
|
{/* Mobile Navigation */}
|
||||||
{isMenuOpen && (
|
{isMenuOpen && (
|
||||||
<nav className="md:hidden pb-4 space-y-1">
|
<nav className="md:hidden pb-4 space-y-1">
|
||||||
<Link
|
{navLink("/", "Dashboard", true)}
|
||||||
href="/"
|
{zfsAvailable && navLink("/zfs", "ZFS", true)}
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
{navLink("/navigator", "Navigator", true)}
|
||||||
isActive("/")
|
{navLink("/shares", "Shares", true)}
|
||||||
? "bg-accent text-accent-foreground"
|
{navLink("/file-sharing", "File Sharing", true)}
|
||||||
: "text-muted-foreground hover:text-foreground"
|
{navLink("/identities", "Identities", true)}
|
||||||
}`}
|
{navLink("/logs", "Logs", true)}
|
||||||
onClick={() => setIsMenuOpen(false)}
|
{navLink("/services", "Services", true)}
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
{zfsAvailable && (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href="/snapshots"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/snapshots")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Snapshots
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/datasets"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/datasets")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Datasets
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href="/files"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/files")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Files
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/shares"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/shares")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Shares
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/file-sharing"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/file-sharing")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
File Sharing
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/identities"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/identities")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Identities
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/logs"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/logs")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Logs
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services"
|
|
||||||
className={`block px-3 py-2 rounded-md text-sm font-medium ${
|
|
||||||
isActive("/services")
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Services
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user