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>
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { api } from "@/lib/api"
|
|
import { Header } from "@/components/Header"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { RefreshCw, Plus, Trash2, AlertCircle } from "lucide-react"
|
|
import CreateSambaDialog from "@/components/shares/CreateSambaDialog"
|
|
import CreateNfsDialog from "@/components/shares/CreateNfsDialog"
|
|
import DeleteConfirmDialog from "@/components/shares/DeleteConfirmDialog"
|
|
|
|
export default function SharesPage() {
|
|
const router = useRouter()
|
|
const [activeTab, setActiveTab] = useState<"samba" | "nfs">("samba")
|
|
const [sambaShares, setSambaShares] = useState<any[]>([])
|
|
const [nfsShares, setNfsShares] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = 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)
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem("access_token")
|
|
if (!token) {
|
|
router.push("/login")
|
|
return
|
|
}
|
|
loadShares()
|
|
}, [router])
|
|
|
|
const loadShares = async () => {
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
const [samba, nfs] = await Promise.all([
|
|
api.getSambaShares().catch(() => []),
|
|
api.getNfsShares().catch(() => []),
|
|
])
|
|
|
|
setSambaShares(samba)
|
|
setNfsShares(nfs)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to load shares")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteSamba = async (name: string) => {
|
|
try {
|
|
setDeleting(true)
|
|
await api.deleteSambaShare(name)
|
|
setSambaShares(sambaShares.filter((s) => s.name !== name))
|
|
setDeleteConfirm(null)
|
|
} catch (err) {
|
|
setError(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) {
|
|
setError(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)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">File Sharing</h1>
|
|
<p className="text-muted-foreground mt-1">Manage Samba (SMB) and NFS network shares</p>
|
|
</div>
|
|
<Button onClick={loadShares} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<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">{error}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-border mb-6">
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={() => setActiveTab("samba")}
|
|
className={`px-4 py-2 font-medium transition-colors ${
|
|
activeTab === "samba"
|
|
? "border-b-2 border-primary text-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
SMB/Samba
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("nfs")}
|
|
className={`px-4 py-2 font-medium transition-colors ${
|
|
activeTab === "nfs"
|
|
? "border-b-2 border-primary text-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
NFS
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SAMBA TAB */}
|
|
{activeTab === "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>
|
|
{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) => (
|
|
<tr key={share.name} className="border-b border-border/50 hover:bg-muted/30">
|
|
<td className="py-3 px-4 font-mono text-xs">{share.name}</td>
|
|
<td className="py-3 px-4 font-mono text-xs">{share.path}</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">{share.comment || "—"}</td>
|
|
<td className="py-3 px-4 text-right space-x-2">
|
|
<button
|
|
onClick={() => setDeleteConfirm({ type: "samba", name: share.name })}
|
|
className="text-red-600 hover:text-red-700 transition-colors"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4 inline" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* NFS TAB */}
|
|
{activeTab === "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>
|
|
{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 text-xs font-mono text-xs">{share.options || "—"}</td>
|
|
<td className="py-3 px-4 text-right space-x-2">
|
|
<button
|
|
onClick={() => setDeleteConfirm({ type: "nfs", name: share.path })}
|
|
className="text-red-600 hover:text-red-700 transition-colors"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4 inline" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</main>
|
|
|
|
{/* 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>
|
|
)
|
|
}
|