92bed208e0
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
748 lines
28 KiB
TypeScript
748 lines
28 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { api, Dataset, SambaShare, NfsShare } 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 } from "lucide-react"
|
|
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 [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())
|
|
|
|
// 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] = await Promise.all([
|
|
api.getDatasets(),
|
|
api.getSambaShares(),
|
|
api.getNfsShares(),
|
|
])
|
|
setDatasets(ds)
|
|
setSambaShares(samba)
|
|
setNfsShares(nfs)
|
|
} catch (err) {
|
|
console.error("Failed to load data:", err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
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 getPoolStats = (poolName: string) => {
|
|
const poolDatasets = datasets.filter((ds) => ds.name === poolName || ds.name.startsWith(poolName + "/"))
|
|
const totalUsed = poolDatasets.reduce((sum, ds) => sum + (ds.used || 0), 0)
|
|
const totalAvail = poolDatasets[0]?.avail || 0
|
|
const totalSize = totalUsed + totalAvail
|
|
const usagePercent = totalSize > 0 ? (totalUsed / totalSize) * 100 : 0
|
|
|
|
return { totalUsed, totalAvail, totalSize, usagePercent }
|
|
}
|
|
|
|
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 stats = getPoolStats(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(stats.totalSize)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Allocated</p>
|
|
<p className="font-bold">{formatBytes(stats.totalUsed)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Free</p>
|
|
<p className="font-bold">{formatBytes(stats.totalAvail)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Fragmentation</p>
|
|
<p className="font-bold">0%</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Usage</p>
|
|
<p className="font-bold">{stats.usagePercent.toFixed(1)}%</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Usage Bar */}
|
|
<div className="space-y-1">
|
|
<div className="flex h-6 bg-muted rounded overflow-hidden">
|
|
<div
|
|
className="bg-blue-500"
|
|
style={{ width: `${stats.usagePercent}%` }}
|
|
/>
|
|
<div
|
|
className="bg-green-500"
|
|
style={{ width: `${100 - stats.usagePercent}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
{stats.usagePercent.toFixed(2)}% Allocated • {(100 - stats.usagePercent).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={() => setPoolTabs(new Map(poolTabs).set(pool.name, "snapshots"))}
|
|
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" && (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
See Snapshots page for detailed snapshot management
|
|
</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>
|
|
</div>
|
|
)
|
|
}
|