"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([]) const [pools, setPools] = useState([]) const [loading, setLoading] = useState(true) const [expandedDatasets, setExpandedDatasets] = useState>(new Set()) const [poolTabs, setPoolTabs] = useState>(new Map()) const [poolSnapshots, setPoolSnapshots] = useState>(new Map()) const [loadingSnaps, setLoadingSnaps] = useState>(new Set()) const [snapContextMenu, setSnapContextMenu] = useState<{ snap: Snapshot; x: number; y: number } | null>(null) const [renameTarget, setRenameTarget] = useState(null) const [renameValue, setRenameValue] = useState("") const [cloneTarget, setCloneTarget] = useState(null) const [cloneValue, setCloneValue] = useState("") // Dataset dialogs const [showCreateDataset, setShowCreateDataset] = useState(false) const [deleteDataset, setDeleteDataset] = useState(null) const [newDatasetName, setNewDatasetName] = useState("") // ── Shares state (from shares/page.tsx) ───────────────────────────────────── const [sharesTab, setSharesTab] = useState<"samba" | "nfs" | "config">("samba") const [sambaShares, setSambaShares] = useState([]) const [nfsShares, setNfsShares] = useState([]) const [sambaConfig, setSambaConfig] = useState([]) const [sharesLoading, setSharesLoading] = useState(false) const [sharesError, setSharesError] = useState(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(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
Loading...
} return (

Datasets & Shares

{/* Top-level Tab Navigation */}
{/* ── DATASETS TAB ────────────────────────────────────────────────────── */} {tab === "datasets" && (
{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 (
{pool.name} ONLINE

Size

{formatBytes(poolInfo?.size || 0)}

Allocated

{formatBytes(poolInfo?.alloc || 0)}

Free

{formatBytes(poolInfo?.free || 0)}

Fragmentation

{poolInfo?.fragmentation || "0%"}

Usage

{poolInfo?.capacity || (poolInfo?.size ? ((poolInfo.alloc / poolInfo.size) * 100).toFixed(1) + "%" : "—")}

{(() => { const pct = poolInfo?.size ? (poolInfo.alloc / poolInfo.size) * 100 : 0 return (

{pct.toFixed(2)}% Allocated • {(100 - pct).toFixed(2)}% Free

) })()}
{currentPoolTab === "filesystems" && (
{childDatasets.map((ds) => ( ))}
Name Type Used Available Mountpoint Compression
{pool.name} {pool.type} {formatBytes(pool.used || 0)} {formatBytes(pool.avail || 0)} {pool.mountpoint || "—"} {pool.compression || "off"}
{ds.name.split("/").pop()} {ds.type} {formatBytes(ds.used || 0)} {formatBytes(ds.avail || 0)} {ds.mountpoint || "—"} {ds.compression || "off"}
)} {currentPoolTab === "snapshots" && (() => { const snaps = poolSnapshots.get(pool.name) || [] const isLoading = loadingSnaps.has(pool.name) return (
{snaps.length} snapshots
{isLoading ? (
Loading…
) : snaps.length === 0 ? (
No snapshots
) : (() => { const groups = new Map() 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 (
{Array.from(groups.entries()).map(([ds, dsSnaps]) => { const isGroupExpanded = expandedDatasets.has(`${expandKey}-${ds}`) return [ { const next = new Set(expandedDatasets) if (isGroupExpanded) next.delete(`${expandKey}-${ds}`) else next.add(`${expandKey}-${ds}`) setExpandedDatasets(next) }} > , ...(isGroupExpanded ? dsSnaps.map((snap) => ( )) : []) ] })}
Name Created Used Referenced Clones
{isGroupExpanded ? : } {ds} {dsSnaps.length}
{snap.name.split("@")[1]} {snap.creation_datetime ? new Date(snap.creation_datetime).toLocaleString() : "—"} {formatBytes(snap.used)} {formatBytes(snap.referenced)}
) })()}
) })()} {currentPoolTab === "status" && (

Health

ONLINE

Mounted

Yes

Record Size

128 KiB

)}
) })} {datasets.length === 0 && (
No datasets found
)}
)} {/* ── SHARES & CONFIG TAB ─────────────────────────────────────────────── */} {tab === "shares" && (
{sharesError && (

Error

{sharesError}

)} {/* Sub-tab navigation */}
{/* SAMBA SUB-TAB */} {sharesTab === "samba" && (
Samba Shares
{sharesLoading ? (

Loading…

) : sambaShares.length === 0 ? (

No Samba shares configured. Create one to get started.

) : (
{sambaShares.map((share) => { const isEditing = editingShare?.oldName === share.name return ( ) })}
Name Path Users Perms Comment Actions
{isEditing ? ( 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} /> ) : ( {share.name} )} {isEditing ? ( 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} /> ) : ( {share.path} )} {share.valid_users || "—"} {share.read_only ? "RO" : "RW"} {isEditing ? ( 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 || "—" )} {isEditing ? ( <> ) : ( <> )}
)}
)} {/* NFS SUB-TAB */} {sharesTab === "nfs" && (
NFS Shares
{sharesLoading ? (

Loading…

) : nfsShares.length === 0 ? (

No NFS shares configured. Create one to get started.

) : (
{nfsShares.map((share) => ( ))}
Path Clients Options Actions
{share.path} {share.clients} {share.options || "—"}
)}
)} {/* SAMBA CONFIG SUB-TAB */} {sharesTab === "config" && (

Global MacOS Shares

Optimize all shares for MacOS

{macosEnabled && (
{Object.entries(MACOS_PARAMS).map(([k, v]) => ( {k} = {v} ))}
)}
Advanced {!editMode ? ( ) : (
)}