Files
zmb-webui/frontend/components/navigator/DirectoryTree.tsx
T
Claude Code 92bed208e0 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>
2026-04-22 00:43:05 +02:00

261 lines
7.0 KiB
TypeScript

"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>
)
}