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>
261 lines
7.0 KiB
TypeScript
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>
|
|
)
|
|
}
|