d5b87028c5
- datasets/page.tsx: neuer Tab 'Shares & Config' mit vollem Inhalt aus shares/page.tsx (Samba, NFS, Samba Config mit MacOS-Toggle + Raw-Editor) - shares/page.tsx: redirect -> /datasets - Header.tsx: 'Shares'-Link zeigt auf /datasets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1041 lines
50 KiB
TypeScript
1041 lines
50 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { api, Dataset, 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, AlertCircle } from "lucide-react"
|
|
import { Snapshot } from "@/lib/api"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import CreateSambaDialog from "@/components/shares/CreateSambaDialog"
|
|
import CreateNfsDialog from "@/components/shares/CreateNfsDialog"
|
|
import DeleteConfirmDialog from "@/components/shares/DeleteConfirmDialog"
|
|
|
|
export default function DatasetsPage() {
|
|
const router = useRouter()
|
|
const [tab, setTab] = useState<"datasets" | "shares">("datasets")
|
|
|
|
// ── Datasets state ──────────────────────────────────────────────────────────
|
|
const [datasets, setDatasets] = useState<Dataset[]>([])
|
|
const [pools, setPools] = useState<Pool[]>([])
|
|
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("")
|
|
|
|
// Dataset dialogs
|
|
const [showCreateDataset, setShowCreateDataset] = useState(false)
|
|
const [deleteDataset, setDeleteDataset] = useState<string | null>(null)
|
|
const [newDatasetName, setNewDatasetName] = useState("")
|
|
|
|
// ── Shares state (from shares/page.tsx) ─────────────────────────────────────
|
|
const [sharesTab, setSharesTab] = useState<"samba" | "nfs" | "config">("samba")
|
|
const [sambaShares, setSambaShares] = useState<any[]>([])
|
|
const [nfsShares, setNfsShares] = useState<any[]>([])
|
|
const [sambaConfig, setSambaConfig] = useState<any[]>([])
|
|
const [sharesLoading, setSharesLoading] = useState(false)
|
|
const [sharesError, setSharesError] = useState<string | null>(null)
|
|
const [showSambaDialog, setShowSambaDialog] = useState(false)
|
|
const [showNfsDialog, setShowNfsDialog] = useState(false)
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ type: "samba" | "nfs"; name: string } | null>(null)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [editMode, setEditMode] = useState(false)
|
|
const [rawConfigText, setRawConfigText] = useState("")
|
|
const [saving, setSaving] = useState(false)
|
|
const [macosEnabled, setMacosEnabled] = useState(false)
|
|
const [editingShare, setEditingShare] = useState<any | null>(null)
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem("access_token")
|
|
if (!token) {
|
|
router.push("/login")
|
|
return
|
|
}
|
|
loadData()
|
|
}, [router])
|
|
|
|
// ── Load datasets + pools ───────────────────────────────────────────────────
|
|
const loadData = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [ds, poolData] = await Promise.all([
|
|
api.getDatasets(),
|
|
api.getPools().catch(() => []),
|
|
])
|
|
setDatasets(ds)
|
|
setPools(Array.isArray(poolData) ? poolData : [])
|
|
} catch (err) {
|
|
console.error("Failed to load data:", err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// ── Load shares ─────────────────────────────────────────────────────────────
|
|
const loadShares = async () => {
|
|
try {
|
|
setSharesLoading(true)
|
|
setSharesError(null)
|
|
|
|
const [samba, nfs, config] = await Promise.all([
|
|
api.getSambaShares().catch(() => []),
|
|
api.getNfsShares().catch(() => []),
|
|
api.getSambaConfig().catch(() => ({ parameters: [] })),
|
|
])
|
|
|
|
setSambaShares(samba)
|
|
setNfsShares(nfs)
|
|
const params: { key: string; value: string }[] = config.parameters || []
|
|
setSambaConfig(params)
|
|
const raw = params.map((p) => `${p.key} = ${p.value}`).join("\n")
|
|
setRawConfigText(raw)
|
|
const hasMacOS = ["fruit:encoding", "fruit:metadata", "fruit:zero_file_id", "fruit:nfs_aces"].every(
|
|
(k) => params.some((p) => p.key === k)
|
|
)
|
|
setMacosEnabled(hasMacOS)
|
|
} catch (err) {
|
|
setSharesError(err instanceof Error ? err.message : "Failed to load shares")
|
|
} finally {
|
|
setSharesLoading(false)
|
|
}
|
|
}
|
|
|
|
// Lazy-load shares when the tab is first activated
|
|
const [sharesLoaded, setSharesLoaded] = useState(false)
|
|
const handleSharesTab = () => {
|
|
setTab("shares")
|
|
if (!sharesLoaded) {
|
|
setSharesLoaded(true)
|
|
loadShares()
|
|
}
|
|
}
|
|
|
|
// ── Snapshot helpers ────────────────────────────────────────────────────────
|
|
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)
|
|
}
|
|
|
|
// ── Dataset CRUD ─────────────────────────────────────────────────────────────
|
|
const handleCreateDataset = async () => {
|
|
if (!newDatasetName.trim()) return
|
|
try {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ── Share CRUD ───────────────────────────────────────────────────────────────
|
|
const handleDeleteSamba = async (name: string) => {
|
|
try {
|
|
setDeleting(true)
|
|
await api.deleteSambaShare(name)
|
|
setSambaShares(sambaShares.filter((s) => s.name !== name))
|
|
setDeleteConfirm(null)
|
|
} catch (err) {
|
|
setSharesError(err instanceof Error ? err.message : "Failed to delete share")
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteNfs = async (path: string) => {
|
|
try {
|
|
setDeleting(true)
|
|
await api.deleteNfsShare(path)
|
|
setNfsShares(nfsShares.filter((s) => s.path !== path))
|
|
setDeleteConfirm(null)
|
|
} catch (err) {
|
|
setSharesError(err instanceof Error ? err.message : "Failed to delete share")
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
const handleSambaCreated = (newShare: any) => {
|
|
setSambaShares([...sambaShares, newShare])
|
|
setShowSambaDialog(false)
|
|
}
|
|
|
|
const handleNfsCreated = (newShare: any) => {
|
|
setNfsShares([...nfsShares, newShare])
|
|
setShowNfsDialog(false)
|
|
}
|
|
|
|
const handleSaveShare = async () => {
|
|
if (!editingShare) return
|
|
try {
|
|
setSaving(true)
|
|
await api.updateSambaShare(editingShare.oldName, {
|
|
name: editingShare.name,
|
|
path: editingShare.path,
|
|
comment: editingShare.comment,
|
|
})
|
|
setSambaShares(sambaShares.map((s) =>
|
|
s.name === editingShare.oldName
|
|
? { name: editingShare.name, path: editingShare.path, comment: editingShare.comment, ...s }
|
|
: s
|
|
))
|
|
setEditingShare(null)
|
|
setSharesError(null)
|
|
} catch (err) {
|
|
setSharesError(err instanceof Error ? err.message : "Failed to save share")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const MACOS_PARAMS: { [key: string]: string } = {
|
|
"fruit:encoding": "native",
|
|
"fruit:metadata": "stream",
|
|
"fruit:zero_file_id": "yes",
|
|
"fruit:nfs_aces": "no",
|
|
}
|
|
const MACOS_KEYS = Object.keys(MACOS_PARAMS)
|
|
|
|
const handleSaveRaw = async () => {
|
|
try {
|
|
setSaving(true)
|
|
const parsed: { [key: string]: string } = {}
|
|
rawConfigText.split("\n").forEach((line) => {
|
|
const eq = line.indexOf("=")
|
|
if (eq === -1) return
|
|
const key = line.slice(0, eq).trim()
|
|
const value = line.slice(eq + 1).trim()
|
|
if (key) parsed[key] = value
|
|
})
|
|
await api.setSambaConfig(parsed)
|
|
await loadShares()
|
|
setEditMode(false)
|
|
setSharesError(null)
|
|
} catch (err) {
|
|
setSharesError(err instanceof Error ? err.message : "Failed to save configuration")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleToggleMacOS = async (enable: boolean) => {
|
|
try {
|
|
setSaving(true)
|
|
const current = sambaConfig.reduce((acc, p) => { acc[p.key] = p.value; return acc }, {} as { [key: string]: string })
|
|
if (enable) {
|
|
await api.setSambaConfig({ ...current, ...MACOS_PARAMS } as { [key: string]: string })
|
|
} else {
|
|
const filtered: { [key: string]: string } = {}
|
|
Object.entries(current).forEach(([k, v]) => { if (!MACOS_KEYS.includes(k)) filtered[k] = v as string })
|
|
await api.setSambaConfig(filtered)
|
|
}
|
|
setMacosEnabled(enable)
|
|
await loadShares()
|
|
setSharesError(null)
|
|
} catch (err) {
|
|
setSharesError(err instanceof Error ? err.message : "Failed to update macOS settings")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
// ── Dataset tree helpers ─────────────────────────────────────────────────────
|
|
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 getTopLevelDatasets = (): Dataset[] => 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)
|
|
}
|
|
|
|
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={tab === "datasets" ? loadData : loadShares} variant="outline" size="sm">
|
|
<RefreshCw className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Top-level 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={handleSharesTab}
|
|
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 & Config
|
|
</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}>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{(() => {
|
|
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>
|
|
|
|
<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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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>
|
|
) : (() => {
|
|
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 [
|
|
<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>,
|
|
...(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>
|
|
)
|
|
})()}
|
|
|
|
{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 & CONFIG TAB ─────────────────────────────────────────────── */}
|
|
{tab === "shares" && (
|
|
<div>
|
|
{sharesError && (
|
|
<Card className="mb-6 border-red-200 bg-red-50">
|
|
<CardContent className="flex items-center gap-3 pt-6">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
|
<div>
|
|
<p className="font-medium text-red-900">Error</p>
|
|
<p className="text-sm text-red-800">{sharesError}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Sub-tab navigation */}
|
|
<div className="border-b border-border mb-6">
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={() => setSharesTab("samba")}
|
|
className={`px-4 py-2 font-medium transition-colors ${
|
|
sharesTab === "samba"
|
|
? "border-b-2 border-primary text-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
SMB/Samba
|
|
</button>
|
|
<button
|
|
onClick={() => setSharesTab("nfs")}
|
|
className={`px-4 py-2 font-medium transition-colors ${
|
|
sharesTab === "nfs"
|
|
? "border-b-2 border-primary text-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
NFS
|
|
</button>
|
|
<button
|
|
onClick={() => setSharesTab("config")}
|
|
className={`px-4 py-2 font-medium transition-colors ${
|
|
sharesTab === "config"
|
|
? "border-b-2 border-primary text-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Samba Config
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SAMBA SUB-TAB */}
|
|
{sharesTab === "samba" && (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Samba Shares</CardTitle>
|
|
<Button size="sm" onClick={() => setShowSambaDialog(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
New Share
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{sharesLoading ? (
|
|
<p className="text-muted-foreground text-center py-12">Loading…</p>
|
|
) : sambaShares.length === 0 ? (
|
|
<p className="text-muted-foreground text-center py-12">
|
|
No Samba shares configured. Create one to get started.
|
|
</p>
|
|
) : (
|
|
<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">Name</th>
|
|
<th className="text-left py-3 px-4 font-medium">Path</th>
|
|
<th className="text-left py-3 px-4 font-medium">Users</th>
|
|
<th className="text-left py-3 px-4 font-medium">Perms</th>
|
|
<th className="text-left py-3 px-4 font-medium">Comment</th>
|
|
<th className="text-right py-3 px-4 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sambaShares.map((share) => {
|
|
const isEditing = editingShare?.oldName === share.name
|
|
return (
|
|
<tr key={share.name} className="border-b border-border/50 hover:bg-muted/30">
|
|
<td className="py-3 px-4 text-xs">
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editingShare.name}
|
|
onChange={(e) => setEditingShare({ ...editingShare, name: e.target.value })}
|
|
className="px-2 py-1 rounded border border-border bg-background text-xs font-mono w-full"
|
|
disabled={saving}
|
|
/>
|
|
) : (
|
|
<span className="font-mono">{share.name}</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 text-xs">
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editingShare.path}
|
|
onChange={(e) => setEditingShare({ ...editingShare, path: e.target.value })}
|
|
className="px-2 py-1 rounded border border-border bg-background text-xs font-mono w-full"
|
|
disabled={saving}
|
|
/>
|
|
) : (
|
|
<span className="font-mono">{share.path}</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 text-xs">{share.valid_users || "—"}</td>
|
|
<td className="py-3 px-4 text-xs">
|
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
|
{share.read_only ? "RO" : "RW"}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-xs">
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editingShare.comment || ""}
|
|
onChange={(e) => setEditingShare({ ...editingShare, comment: e.target.value })}
|
|
className="px-2 py-1 rounded border border-border bg-background text-xs w-full"
|
|
disabled={saving}
|
|
placeholder="Optional comment"
|
|
/>
|
|
) : (
|
|
share.comment || "—"
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 text-right space-x-2">
|
|
{isEditing ? (
|
|
<>
|
|
<button
|
|
onClick={handleSaveShare}
|
|
className="text-green-600 hover:text-green-700 transition-colors text-xs font-medium"
|
|
disabled={saving}
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingShare(null)}
|
|
className="text-gray-600 hover:text-gray-700 transition-colors text-xs font-medium"
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => setEditingShare({ ...share, oldName: share.name })}
|
|
className="text-blue-600 hover:text-blue-700 transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteConfirm({ type: "samba", name: share.name })}
|
|
className="text-red-600 hover:text-red-700 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4 inline" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* NFS SUB-TAB */}
|
|
{sharesTab === "nfs" && (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>NFS Shares</CardTitle>
|
|
<Button size="sm" onClick={() => setShowNfsDialog(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
New Share
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{sharesLoading ? (
|
|
<p className="text-muted-foreground text-center py-12">Loading…</p>
|
|
) : nfsShares.length === 0 ? (
|
|
<p className="text-muted-foreground text-center py-12">
|
|
No NFS shares configured. Create one to get started.
|
|
</p>
|
|
) : (
|
|
<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">Path</th>
|
|
<th className="text-left py-3 px-4 font-medium">Clients</th>
|
|
<th className="text-left py-3 px-4 font-medium">Options</th>
|
|
<th className="text-right py-3 px-4 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{nfsShares.map((share) => (
|
|
<tr key={share.path} className="border-b border-border/50 hover:bg-muted/30">
|
|
<td className="py-3 px-4 font-mono text-xs">{share.path}</td>
|
|
<td className="py-3 px-4 text-xs">{share.clients}</td>
|
|
<td className="py-3 px-4 font-mono text-xs">{share.options || "—"}</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<button
|
|
onClick={() => setDeleteConfirm({ type: "nfs", name: share.path })}
|
|
className="text-red-600 hover:text-red-700 transition-colors"
|
|
>
|
|
<Trash2 className="w-4 h-4 inline" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* SAMBA CONFIG SUB-TAB */}
|
|
{sharesTab === "config" && (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardContent className="py-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium">Global MacOS Shares</p>
|
|
<p className="text-sm text-muted-foreground mt-0.5">Optimize all shares for MacOS</p>
|
|
{macosEnabled && (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{Object.entries(MACOS_PARAMS).map(([k, v]) => (
|
|
<span key={k} className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
|
{k} = {v}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={macosEnabled}
|
|
disabled={saving}
|
|
onClick={() => handleToggleMacOS(!macosEnabled)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${macosEnabled ? "bg-red-500" : "bg-muted-foreground/30"} disabled:opacity-50`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${macosEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
|
</button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Advanced</CardTitle>
|
|
{!editMode ? (
|
|
<Button size="sm" onClick={() => setEditMode(true)} variant="outline">
|
|
Edit
|
|
</Button>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<Button size="sm" onClick={handleSaveRaw} disabled={saving}>
|
|
{saving ? "Saving..." : "Apply"}
|
|
</Button>
|
|
<Button size="sm" onClick={() => setEditMode(false)} variant="outline" disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<textarea
|
|
readOnly={!editMode}
|
|
value={rawConfigText}
|
|
onChange={(e) => setRawConfigText(e.target.value)}
|
|
rows={20}
|
|
spellCheck={false}
|
|
className={`w-full font-mono text-xs rounded border px-3 py-2 bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring ${editMode ? "border-border" : "border-transparent text-muted-foreground"}`}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── DIALOGS ──────────────────────────────────────────────────────────── */}
|
|
|
|
{/* Create Dataset */}
|
|
<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 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>
|
|
|
|
{/* 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 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 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>
|
|
|
|
{/* Shares dialogs */}
|
|
<CreateSambaDialog open={showSambaDialog} onOpenChange={setShowSambaDialog} onCreated={handleSambaCreated} />
|
|
<CreateNfsDialog open={showNfsDialog} onOpenChange={setShowNfsDialog} onCreated={handleNfsCreated} />
|
|
<DeleteConfirmDialog
|
|
open={!!deleteConfirm}
|
|
onOpenChange={(open) => !open && setDeleteConfirm(null)}
|
|
type={deleteConfirm?.type === "samba" ? "Samba Share" : "NFS Share"}
|
|
name={deleteConfirm?.name || ""}
|
|
onConfirm={() => {
|
|
if (deleteConfirm?.type === "samba") {
|
|
handleDeleteSamba(deleteConfirm.name)
|
|
} else if (deleteConfirm?.type === "nfs") {
|
|
handleDeleteNfs(deleteConfirm.name)
|
|
}
|
|
}}
|
|
loading={deleting}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|