diff --git a/frontend/app/datasets/page.tsx b/frontend/app/datasets/page.tsx index 0a12f24..e3b9da2 100644 --- a/frontend/app/datasets/page.tsx +++ b/frontend/app/datasets/page.tsx @@ -1,21 +1,26 @@ "use client" import { useEffect, useState } from "react" -import { api, Dataset, SambaShare, NfsShare, Pool } from "@/lib/api" +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 } from "lucide-react" +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 [sambaShares, setSambaShares] = useState([]) - const [nfsShares, setNfsShares] = useState([]) const [loading, setLoading] = useState(true) const [expandedDatasets, setExpandedDatasets] = useState>(new Set()) const [poolTabs, setPoolTabs] = useState>(new Map()) @@ -27,39 +32,46 @@ export default function DatasetsPage() { const [cloneTarget, setCloneTarget] = useState(null) const [cloneValue, setCloneValue] = useState("") - // Dialogs + // Dataset dialogs const [showCreateDataset, setShowCreateDataset] = useState(false) - const [showCreateSambaShare, setShowCreateSambaShare] = useState(false) - const [showCreateNfsShare, setShowCreateNfsShare] = useState(false) const [deleteDataset, setDeleteDataset] = useState(null) - const [deleteSambaShare, setDeleteSambaShare] = useState(null) - const [deleteNfsShare, setDeleteNfsShare] = useState(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") + + // ── 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, samba, nfs, poolData] = await Promise.all([ + const [ds, 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) @@ -68,6 +80,46 @@ export default function DatasetsPage() { } } + // ── 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 { @@ -119,10 +171,10 @@ export default function DatasetsPage() { setCloneTarget(null) } + // ── Dataset CRUD ───────────────────────────────────────────────────────────── const handleCreateDataset = async () => { if (!newDatasetName.trim()) return try { - // Create dataset via API await api.createDataset(newDatasetName, {}) setNewDatasetName("") setShowCreateDataset(false) @@ -142,62 +194,118 @@ export default function DatasetsPage() { } } - 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) => { + // ── Share CRUD ─────────────────────────────────────────────────────────────── + const handleDeleteSamba = async (name: string) => { try { + setDeleting(true) await api.deleteSambaShare(name) - setDeleteSambaShare(null) - loadData() + setSambaShares(sambaShares.filter((s) => s.name !== name)) + setDeleteConfirm(null) } catch (err) { - console.error("Failed to delete Samba share:", err) + setSharesError(err instanceof Error ? err.message : "Failed to delete share") + } finally { + setDeleting(false) } } - 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) => { + const handleDeleteNfs = async (path: string) => { try { + setDeleting(true) await api.deleteNfsShare(path) - setDeleteNfsShare(null) - loadData() + setNfsShares(nfsShares.filter((s) => s.path !== path)) + setDeleteConfirm(null) } catch (err) { - console.error("Failed to delete NFS share:", 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 @@ -206,82 +314,13 @@ export default function DatasetsPage() { 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 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) } - 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( - - -
- {children.length > 0 && ( - - )} - {children.length === 0 &&
} - {ds.name.split("/").pop()} -
- - {ds.type} - {formatBytes(ds.used || 0)} - {ds.mountpoint || "—"} - {ds.compression || "off"} - - - - - ) - - if (isExpanded && children.length > 0) { - items.push(...renderDatasetTree(ds.name)) - } - }) - - return items - } - if (loading) { return
Loading...
} @@ -291,13 +330,13 @@ export default function DatasetsPage() {
-

Datasets & Shares

-
- {/* Tab Navigation */} + {/* Top-level Tab Navigation */}
- {/* Datasets Tab */} + {/* ── DATASETS TAB ────────────────────────────────────────────────────── */} {tab === "datasets" && (
@@ -338,7 +377,6 @@ export default function DatasetsPage() { return ( - {/* Pool Header */}
@@ -353,7 +391,6 @@ export default function DatasetsPage() {
- {/* Pool Stats Grid */}

Size

@@ -377,7 +414,6 @@ export default function DatasetsPage() {
- {/* Usage Bar */} {(() => { const pct = poolInfo?.size ? (poolInfo.alloc / poolInfo.size) * 100 : 0 return ( @@ -394,7 +430,6 @@ export default function DatasetsPage() { })()} - {/* Tabs */}
@@ -431,7 +466,6 @@ export default function DatasetsPage() {
- {/* File Systems Tab */} {currentPoolTab === "filesystems" && (
@@ -471,7 +505,6 @@ export default function DatasetsPage() { )} - {/* Snapshots Tab */} {currentPoolTab === "snapshots" && (() => { const snaps = poolSnapshots.get(pool.name) || [] const isLoading = loadingSnaps.has(pool.name) @@ -495,7 +528,6 @@ export default function DatasetsPage() { ) : snaps.length === 0 ? (
No snapshots
) : (() => { - // Group snapshots by dataset const groups = new Map() snaps.forEach((s) => { const ds = s.dataset || s.name.split("@")[0] @@ -520,7 +552,6 @@ export default function DatasetsPage() { {Array.from(groups.entries()).map(([ds, dsSnaps]) => { const isGroupExpanded = expandedDatasets.has(`${expandKey}-${ds}`) return [ - // Group header row , - // Snapshot rows (expanded) ...(isGroupExpanded ? dsSnaps.map((snap) => ( @@ -571,7 +601,6 @@ export default function DatasetsPage() { ) })()} - {/* Status Tab */} {currentPoolTab === "status" && (
@@ -599,110 +628,313 @@ export default function DatasetsPage() {
)} - {/* Shares Tab */} + {/* ── SHARES & CONFIG TAB ─────────────────────────────────────────────── */} {tab === "shares" && (
-
-

Samba Shares

-
- -
+ {sharesError && ( + + + +
+

Error

+

{sharesError}

+
+
+
+ )} -
-
{snap.name.split("@")[1]}
- - - - - - - - - - {sambaShares.map((share) => ( - - - - - - - ))} - -
NamePathCommentAction
{share.name}{share.path}{share.comment || "—"} - -
+ {/* Sub-tab navigation */} +
+
+ + +
- - {sambaShares.length === 0 && ( -
- No Samba shares -
- )}
-
-

NFS Shares

-
- -
+ {/* 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 ( + + + + + + + + + ) + })} + +
NamePathUsersPermsCommentActions
+ {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 ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ )} +
+
+ )} -
- - - - - - - - - - - {nfsShares.map((share) => ( - - - - - - - ))} - -
PathClientsOptionsAction
{share.path}{share.clients}{share.options || "—"} - -
-
+ {/* NFS SUB-TAB */} + {sharesTab === "nfs" && ( + + +
+ NFS Shares + +
+
+ + {sharesLoading ? ( +

Loading…

+ ) : nfsShares.length === 0 ? ( +

+ No NFS shares configured. Create one to get started. +

+ ) : ( +
+ + + + + + + + + + + {nfsShares.map((share) => ( + + + + + + + ))} + +
PathClientsOptionsActions
{share.path}{share.clients}{share.options || "—"} + +
+
+ )} +
+
+ )} - {nfsShares.length === 0 && ( -
- No NFS shares -
- )} -
+ {/* 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 ? ( + + ) : ( +
+ + +
+ )} +
+
+ +