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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user