ZMB Webui: Complete Project – Rebrand & Initial Clean Commit

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>
This commit is contained in:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 6d74d874b6
104 changed files with 28836 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
"use client"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { HardDrive, Menu, LogOut } from "lucide-react"
import { useState, useEffect } from "react"
import { api } from "@/lib/api"
export function Header() {
const pathname = usePathname()
const router = useRouter()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [zfsAvailable, setZfsAvailable] = useState(false)
useEffect(() => {
const checkZfsAvailability = async () => {
const status = await api.getSystemStatus()
setZfsAvailable(status.zfs_available)
}
checkZfsAvailability()
}, [])
const handleLogout = async () => {
await api.logout()
router.push("/login")
}
const isActive = (path: string) => pathname === path
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
<HardDrive className="w-6 h-6" />
<span>ZMB Webui</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-1">
<Link
href="/"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Dashboard
</Link>
{zfsAvailable && (
<>
<Link
href="/snapshots"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/snapshots")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Snapshots
</Link>
<Link
href="/datasets"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/datasets")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Datasets
</Link>
</>
)}
<Link
href="/navigator"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/navigator")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Navigator
</Link>
<Link
href="/shares"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/shares")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Shares
</Link>
<Link
href="/file-sharing"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/file-sharing")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
File Sharing
</Link>
<Link
href="/identities"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/identities")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Identities
</Link>
<Link
href="/logs"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/logs")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Logs
</Link>
<Link
href="/services"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive("/services")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Services
</Link>
</nav>
{/* Logout Button */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
<span className="hidden sm:inline">Logout</span>
</Button>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<Menu className="w-6 h-6" />
</button>
</div>
</div>
{/* Mobile Navigation */}
{isMenuOpen && (
<nav className="md:hidden pb-4 space-y-1">
<Link
href="/"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Dashboard
</Link>
{zfsAvailable && (
<>
<Link
href="/snapshots"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/snapshots")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Snapshots
</Link>
<Link
href="/datasets"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/datasets")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Datasets
</Link>
</>
)}
<Link
href="/files"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/files")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Files
</Link>
<Link
href="/shares"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/shares")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Shares
</Link>
<Link
href="/file-sharing"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/file-sharing")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
File Sharing
</Link>
<Link
href="/identities"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/identities")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Identities
</Link>
<Link
href="/logs"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/logs")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Logs
</Link>
<Link
href="/services"
className={`block px-3 py-2 rounded-md text-sm font-medium ${
isActive("/services")
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setIsMenuOpen(false)}
>
Services
</Link>
</nav>
)}
</div>
</header>
)
}
+69
View File
@@ -0,0 +1,69 @@
"use client"
import { Pool } from "@/lib/api"
import { formatBytes } from "@/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
interface PoolCardProps {
pool: Pool
onClick?: () => void
}
export function PoolCard({ pool, onClick }: PoolCardProps) {
const usedBytes = pool.alloc
const freeBytes = pool.free
const totalBytes = pool.size
const capacityPercent = parseInt(pool.capacity)
let badgeVariant: "success" | "warning" | "destructive" = "success"
if (pool.health === "DEGRADED") badgeVariant = "warning"
else if (pool.health !== "ONLINE") badgeVariant = "destructive"
return (
<Card
onClick={onClick}
className={`cursor-pointer hover:shadow-lg transition-shadow ${onClick ? "cursor-pointer" : ""}`}
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-xl">{pool.name}</CardTitle>
<Badge variant={badgeVariant}>{pool.health}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Capacity Bar */}
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-muted-foreground">Capacity</span>
<span className="font-medium">{pool.capacity}</span>
</div>
<Progress value={capacityPercent} max={100} />
</div>
{/* Size Information */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-muted-foreground text-xs">Total</div>
<div className="font-medium">{formatBytes(totalBytes)}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Used</div>
<div className="font-medium">{formatBytes(usedBytes)}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Free</div>
<div className="font-medium">{formatBytes(freeBytes)}</div>
</div>
</div>
{/* Fragmentation */}
<div className="flex justify-between items-center text-sm pt-2 border-t border-border">
<span className="text-muted-foreground">Fragmentation</span>
<span className="font-medium">{pool.fragmentation}</span>
</div>
</CardContent>
</Card>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import { Vdev } from "@/lib/api"
function stateColor(state: string) {
switch (state?.toUpperCase()) {
case "ONLINE": return "text-green-500"
case "DEGRADED": return "text-yellow-500"
case "FAULTED":
case "OFFLINE":
case "UNAVAIL": return "text-red-500"
default: return "text-muted-foreground"
}
}
function stateIcon(state: string) {
switch (state?.toUpperCase()) {
case "ONLINE": return "●"
case "DEGRADED": return "◑"
default: return "○"
}
}
interface VdevRowProps {
vdev: Vdev
depth?: number
}
function VdevRow({ vdev, depth = 0 }: VdevRowProps) {
const hasErrors =
vdev.read !== 0 || vdev.write !== 0 || vdev.cksum !== 0
return (
<>
<tr className="border-b border-border/50 last:border-0 hover:bg-muted/30">
<td className="py-2 pr-4">
<span style={{ paddingLeft: `${depth * 20}px` }} className="flex items-center gap-2">
<span className={`text-sm ${stateColor(vdev.state)}`}>{stateIcon(vdev.state)}</span>
<span className={`font-mono text-sm ${depth === 0 ? "font-semibold" : ""}`}>
{vdev.name}
</span>
</span>
</td>
<td className={`py-2 px-3 text-sm font-medium ${stateColor(vdev.state)}`}>
{vdev.state}
</td>
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
{vdev.read}
</td>
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
{vdev.write}
</td>
<td className={`py-2 px-3 text-sm font-mono text-center ${hasErrors ? "text-red-500 font-bold" : "text-muted-foreground"}`}>
{vdev.cksum}
</td>
</tr>
{vdev.children?.map((child) => (
<VdevRow key={child.name} vdev={child} depth={depth + 1} />
))}
</>
)
}
interface VdevTreeProps {
vdevs: Vdev[]
}
export function VdevTree({ vdevs }: VdevTreeProps) {
if (!vdevs || vdevs.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4">
No VDEV information available.
</p>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground uppercase tracking-wide">
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 px-3">State</th>
<th className="pb-2 px-3 text-center">Read</th>
<th className="pb-2 px-3 text-center">Write</th>
<th className="pb-2 px-3 text-center">CkSum</th>
</tr>
</thead>
<tbody>
{vdevs.map((vdev) => (
<VdevRow key={vdev.name} vdev={vdev} depth={0} />
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,260 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { ChevronRight, ChevronDown, Folder, Loader2 } from "lucide-react"
interface DirNode {
name: string
path: string
has_children: boolean
}
interface DirectoryTreeProps {
currentPath: string
onNavigate: (path: string) => void
}
interface TreeNodeProps {
node: DirNode
depth: number
isActive: boolean
isExpanded: boolean
isLoading: boolean
subdirs: DirNode[]
basePath: string
onExpand: (path: string) => void
onNavigate: (path: string) => void
}
const TreeNode = ({
node,
depth,
isActive,
isExpanded,
isLoading,
subdirs,
basePath,
onExpand,
onNavigate,
}: TreeNodeProps) => {
return (
<div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded cursor-pointer transition-colors ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{node.has_children ? (
<button
onClick={() => onExpand(node.path)}
className="p-0 w-4 h-4 flex items-center justify-center"
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
) : (
<div className="w-4" />
)}
<Folder className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
<button
onClick={() => {
const fullPath = basePath === "/" ? "/" + node.path : basePath + "/" + node.path
onNavigate(fullPath)
}}
className="text-sm truncate text-left"
>
{node.name}
</button>
</div>
{isExpanded && subdirs.length > 0 && (
<div>
{subdirs.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
isActive={isActive}
isExpanded={false}
isLoading={false}
subdirs={[]}
basePath={basePath}
onExpand={onExpand}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}
const BookmarkButton = ({
label,
path,
onNavigate,
}: {
label: string
path: string
onNavigate: (path: string) => void
}) => (
<button
onClick={() => onNavigate(path)}
className="w-full text-left px-2 py-1.5 rounded text-xs hover:bg-muted/50 transition-colors"
>
{label}
</button>
)
export function DirectoryTree({
currentPath,
onNavigate,
}: DirectoryTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [childrenMap, setChildrenMap] = useState<Map<string, DirNode[]>>(
new Map()
)
const [loading, setLoading] = useState<Set<string>>(new Set())
const getAuthHeader = () => {
const token = localStorage.getItem("access_token")
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
}
const getApiUrl = (path: string) => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || ""
return baseUrl + path
}
const fetchChildren = useCallback(
async (path: string) => {
// If already loaded, just toggle expand
if (childrenMap.has(path)) {
setExpanded((prev) => {
const newSet = new Set(prev)
if (newSet.has(path)) {
newSet.delete(path)
} else {
newSet.add(path)
}
return newSet
})
return
}
// Fetch children
setLoading((prev) => new Set(prev).add(path))
try {
const qs = `path=${encodeURIComponent(path)}&admin=true`
const res = await fetch(getApiUrl(`/api/navigator/dirs?${qs}`), {
headers: getAuthHeader(),
})
const data = await res.json()
setChildrenMap((prev) => new Map(prev).set(path, data.dirs || []))
setExpanded((prev) => new Set(prev).add(path))
} catch (err) {
console.error("Failed to fetch subdirectories:", err)
} finally {
setLoading((prev) => {
const newSet = new Set(prev)
newSet.delete(path)
return newSet
})
}
},
[childrenMap]
)
// Auto-expand ancestors when currentPath changes
useEffect(() => {
if (!currentPath || currentPath === "/") return
const parts = currentPath.replace(/^\//, "").split("/").filter(Boolean)
for (let i = 0; i < parts.length; i++) {
const ancestorPath = parts.slice(0, i).join("/")
if (!childrenMap.has(ancestorPath)) {
fetchChildren(ancestorPath)
} else {
setExpanded((prev) => new Set(prev).add(ancestorPath))
}
}
}, [currentPath, childrenMap, fetchChildren])
// Load root on mount
useEffect(() => {
fetchChildren("")
}, [fetchChildren])
const basePath = "/"
const rootPath = ""
const rootChildren = childrenMap.get(rootPath) || []
return (
<div className="space-y-3">
{/* Bookmarks Section */}
<div className="border-b border-border pb-2">
<p className="text-xs font-semibold text-muted-foreground px-2 mb-1">
Favoriten
</p>
<div className="space-y-0.5">
<BookmarkButton label="Wurzel" path="/" onNavigate={onNavigate} />
<BookmarkButton label="Home" path="/home" onNavigate={onNavigate} />
<BookmarkButton label="Root" path="/root" onNavigate={onNavigate} />
<BookmarkButton label="Tank" path="/tank" onNavigate={onNavigate} />
<BookmarkButton
label="Var/Log"
path="/var/log"
onNavigate={onNavigate}
/>
</div>
</div>
{/* Directory Tree */}
<div className="space-y-0.5">
<p className="text-xs font-semibold text-muted-foreground px-2">
Verzeichnisse
</p>
<div
className="space-y-0.5"
style={{ maxHeight: "calc(100vh - 14rem)", overflowY: "auto" }}
>
{rootChildren.length > 0 ? (
rootChildren.map((node) => {
const fullPath = basePath === "/" ? "/" + node.path : basePath + "/" + node.path
return (
<TreeNode
key={node.path}
node={node}
depth={0}
isActive={currentPath === fullPath}
isExpanded={expanded.has(node.path)}
isLoading={loading.has(node.path)}
subdirs={childrenMap.get(node.path) || []}
basePath={basePath}
onExpand={fetchChildren}
onNavigate={onNavigate}
/>
)
})
) : (
<p className="text-xs text-muted-foreground px-2 py-1">
{loading.has(rootPath) ? "Laden..." : "Keine Verzeichnisse"}
</p>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,166 @@
"use client"
import { useState } from "react"
import { api } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle } from "lucide-react"
interface CreateNfsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (share: any) => void
}
export default function CreateNfsDialog({
open,
onOpenChange,
onCreated,
}: CreateNfsDialogProps) {
const [path, setPath] = useState("")
const [clients, setClients] = useState("")
const [readonly, setReadonly] = useState(false)
const [sync, setSync] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!path.trim()) {
setError("Path is required")
return
}
if (!clients.trim()) {
setError("Clients are required")
return
}
// Build options
const opts = []
opts.push(readonly ? "ro" : "rw")
opts.push(sync ? "sync" : "async")
opts.push("no_subtree_check")
const options = opts.join(",")
try {
setLoading(true)
await api.createNfsShare({ path, clients, options })
onCreated({
path,
clients,
options,
})
setPath("")
setClients("")
setReadonly(false)
setSync(true)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share")
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create NFS Share</CardTitle>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3 flex gap-2">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Path</label>
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="e.g., /tank/share"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Must be an existing filesystem path
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Clients</label>
<textarea
value={clients}
onChange={(e) => setClients(e.target.value)}
placeholder="e.g., 192.168.1.0/24 10.0.0.0/8"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm min-h-[80px] font-mono text-xs"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Space-separated CIDR ranges or IPs (e.g., 192.168.1.0/24, 10.0.0.5)
</p>
</div>
<div className="space-y-3 border-t border-border pt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={readonly}
onChange={(e) => setReadonly(e.target.checked)}
disabled={loading}
className="rounded border-border"
/>
<span className="text-sm font-medium">Read-Only</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={sync}
onChange={(e) => setSync(e.target.checked)}
disabled={loading}
className="rounded border-border"
/>
<span className="text-sm font-medium">Sync Mode</span>
<span className="text-xs text-muted-foreground">(safer than async)</span>
</label>
</div>
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<p className="text-xs text-blue-800">
<strong>Generated options:</strong>
<br />
{readonly ? "ro" : "rw"},{sync ? "sync" : "async"},no_subtree_check
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Share"}
</Button>
</div>
</CardContent>
</form>
</Card>
</div>
)
}
@@ -0,0 +1,147 @@
"use client"
import { useState } from "react"
import { api } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle } from "lucide-react"
interface CreateSambaDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (share: any) => void
}
export default function CreateSambaDialog({
open,
onOpenChange,
onCreated,
}: CreateSambaDialogProps) {
const [name, setName] = useState("")
const [path, setPath] = useState("")
const [comment, setComment] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!name.trim()) {
setError("Share name is required")
return
}
if (!path.trim()) {
setError("Path is required")
return
}
try {
setLoading(true)
await api.createSambaShare({ name, path, comment: comment || undefined })
// Return the created share
onCreated({
name,
path,
comment: comment || null,
valid_users: null,
read_only: false,
})
// Reset form
setName("")
setPath("")
setComment("")
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share")
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create Samba Share</CardTitle>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3 flex gap-2">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Share Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., share, media, backup"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Alphanumeric, max 15 characters
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Path</label>
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="e.g., /tank/share"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
<p className="text-xs text-muted-foreground mt-1">
Must be an existing filesystem path
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description (optional)</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share description"
className="w-full px-3 py-2 border border-border rounded bg-background text-sm"
disabled={loading}
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded p-3">
<p className="text-xs text-blue-800">
<strong>Default permissions:</strong> Read/Write, authenticated users only
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Share"}
</Button>
</div>
</CardContent>
</form>
</Card>
</div>
)
}
@@ -0,0 +1,66 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle } from "lucide-react"
interface DeleteConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
type: string
name: string
onConfirm: () => void
loading?: boolean
}
export default function DeleteConfirmDialog({
open,
onOpenChange,
type,
name,
onConfirm,
loading = false,
}: DeleteConfirmDialogProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Delete {type}?
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<p className="text-sm text-muted-foreground mb-2">
Are you sure you want to delete this {type.toLowerCase()}?
</p>
<div className="bg-muted p-3 rounded border border-border">
<p className="font-mono text-sm break-all">{name}</p>
</div>
<p className="text-xs text-red-600 mt-3">
This action cannot be undone.
</p>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={loading}
>
{loading ? "Deleting..." : "Delete"}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "default" | "secondary" | "destructive" | "outline" | "success" | "warning"
}
const variantStyles = {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
success: "border-transparent bg-green-100 text-green-800",
warning: "border-transparent bg-yellow-100 text-yellow-800",
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
const baseStyles =
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
const variantStyle = variantStyles[variant]
return (
<div className={`${baseStyles} ${variantStyle} ${className}`} {...props} />
)
}
+44
View File
@@ -0,0 +1,44 @@
import React from "react"
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost"
size?: "default" | "sm" | "lg"
}
const variantStyles = {
default:
"bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary/70",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
}
const sizeStyles = {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3 text-sm",
lg: "h-11 rounded-md px-8",
}
export function Button({
className = "",
variant = "default",
size = "default",
...props
}: ButtonProps) {
const baseStyles =
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
const variantStyle = variantStyles[variant]
const sizeStyle = sizeStyles[size]
return (
<button
className={`${baseStyles} ${variantStyle} ${sizeStyle} ${className}`}
{...props}
/>
)
}
+68
View File
@@ -0,0 +1,68 @@
export function Card({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`rounded-lg border border-border bg-card text-card-foreground shadow-sm ${className}`}
{...props}
/>
)
}
export function CardHeader({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`flex flex-col space-y-1.5 p-6 ${className}`}
{...props}
/>
)
}
export function CardTitle({
className = "",
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={`text-2xl font-semibold leading-none tracking-tight ${className}`}
{...props}
/>
)
}
export function CardDescription({
className = "",
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={`text-sm text-muted-foreground ${className}`}
{...props}
/>
)
}
export function CardContent({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={`p-6 pt-0 ${className}`} {...props} />
)
}
export function CardFooter({
className = "",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`flex items-center p-6 pt-0 ${className}`}
{...props}
/>
)
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useEffect } from "react"
interface DialogProps {
open: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export function Dialog({ open, onClose, title, children }: DialogProps) {
// Close on Escape key
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
document.addEventListener("keydown", handler)
return () => document.removeEventListener("keydown", handler)
}, [open, onClose])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" />
{/* Panel */}
<div
className="relative z-10 w-full max-w-md mx-4 rounded-lg border border-border bg-background shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{title}</h2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
{/* Body */}
<div className="px-6 py-4">{children}</div>
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value: number
max?: number
color?: "default" | "success" | "warning" | "danger"
}
const colorStyles = {
default: "bg-primary",
success: "bg-green-600",
warning: "bg-yellow-600",
danger: "bg-red-600",
}
export function Progress({
value,
max = 100,
color = "default",
className = "",
...props
}: ProgressProps) {
const percentage = Math.min((value / max) * 100, 100)
let colorClass = colorStyles[color]
// Auto-select color based on percentage
if (color === "default") {
if (percentage >= 90) {
colorClass = colorStyles.danger
} else if (percentage >= 75) {
colorClass = colorStyles.warning
} else {
colorClass = colorStyles.success
}
}
return (
<div
className={`relative w-full h-2 rounded-full bg-secondary overflow-hidden ${className}`}
{...props}
>
<div
className={`h-full ${colorClass} transition-all`}
style={{ width: `${percentage}%` }}
/>
</div>
)
}