Files
zmb-webui/frontend/app/datasets/page.tsx
T
2026-06-04 23:16:29 +02:00

952 lines
40 KiB
TypeScript

"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>
)
}