From 202fdfaaeb7e4af78779a55348b8463be91defc6 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 5 Jun 2026 00:51:34 +0200 Subject: [PATCH] Feature: VdevTree rekursiv + Disk-Aktionen (Offline/Online/Detach/Clear) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VdevTree rendert jetzt alle Ebenen (pool → mirror → sda/sdb) - Disk-⋮-Menü: Clear Disk Errors, Offline, Online, Detach - Backend: neue Endpoints /disk/offline, /disk/online, /disk/detach Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/pools.py | 39 ++++++++++++ frontend/app/zfs/page.tsx | 127 ++++++++++++++++++++++++++++++++------ frontend/lib/api.ts | 20 ++++++ 3 files changed, 166 insertions(+), 20 deletions(-) diff --git a/backend/routers/pools.py b/backend/routers/pools.py index f785413..9780e34 100644 --- a/backend/routers/pools.py +++ b/backend/routers/pools.py @@ -125,6 +125,45 @@ async def resilver_pool(pool_name: str, current_user: str = Depends(get_current_ raise HTTPException(status_code=500, detail=str(e)) +@router.post("/{pool_name}/disk/offline") +async def disk_offline(pool_name: str, disk: str, current_user: str = Depends(get_current_user)): + try: + stdout, stderr, rc = zfs_runner.run_command(["zpool", "offline", pool_name, disk]) + if rc != 0: + raise HTTPException(status_code=400, detail=stderr.strip() or "Failed to offline disk") + return {"status": "success", "message": f"{disk} offlined"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{pool_name}/disk/online") +async def disk_online(pool_name: str, disk: str, current_user: str = Depends(get_current_user)): + try: + stdout, stderr, rc = zfs_runner.run_command(["zpool", "online", pool_name, disk]) + if rc != 0: + raise HTTPException(status_code=400, detail=stderr.strip() or "Failed to online disk") + return {"status": "success", "message": f"{disk} onlined"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{pool_name}/disk/detach") +async def disk_detach(pool_name: str, disk: str, current_user: str = Depends(get_current_user)): + try: + stdout, stderr, rc = zfs_runner.run_command(["zpool", "detach", pool_name, disk]) + if rc != 0: + raise HTTPException(status_code=400, detail=stderr.strip() or "Failed to detach disk") + return {"status": "success", "message": f"{disk} detached"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/clear-cache") async def clear_cache(current_user: str = Depends(get_current_user)): """ diff --git a/frontend/app/zfs/page.tsx b/frontend/app/zfs/page.tsx index 89bc34d..a07cff0 100644 --- a/frontend/app/zfs/page.tsx +++ b/frontend/app/zfs/page.tsx @@ -64,28 +64,55 @@ function UsageBar({ alloc, size }: { alloc: number; size: number }) { ) } -function VdevTree({ vdevs, depth = 0 }: { vdevs: any[]; depth?: number }) { +function VdevTree({ + vdevs, + depth = 0, + poolName, + onDiskMenu, +}: { + vdevs: any[] + depth?: number + poolName: string + onDiskMenu: (e: React.MouseEvent, disk: string, poolName: string) => void +}) { + const isDisk = (v: any) => !v.children || v.children.length === 0 + return ( <> {vdevs.map((v, i) => ( - - - {v.name} - - - - - {v.read ?? 0} - {v.write ?? 0} - {v.cksum ?? 0} - {v.message || ""} - {v.product || ""} - - - - + <> + + + {v.name} + + + + + {v.read ?? 0} + {v.write ?? 0} + {v.cksum ?? 0} + {v.message || ""} + {v.product || ""} + + {isDisk(v) && ( + + )} + + + {v.children && v.children.length > 0 && ( + + )} + ))} ) @@ -99,6 +126,8 @@ export default function ZfsPage() { // Pool context menu const [poolMenu, setPoolMenu] = useState<{ pool: Pool; x: number; y: number } | null>(null) + // Disk context menu + const [diskMenu, setDiskMenu] = useState<{ disk: string; poolName: string; x: number; y: number } | null>(null) // Dialogs const [showCreateFilesystem, setShowCreateFilesystem] = useState(false) @@ -253,6 +282,31 @@ export default function ZfsPage() { setCloneTarget(null) } + const handleDiskMenuOpen = (e: React.MouseEvent, disk: string, poolName: string) => { + setDiskMenu({ disk, poolName, x: e.clientX, y: e.clientY }) + } + + const handleDiskAction = async (action: string) => { + if (!diskMenu) return + const { disk, poolName } = diskMenu + setDiskMenu(null) + if (action === "offline") { + await api.diskOffline(poolName, disk) + refreshPoolStatus(poolName) + } else if (action === "online") { + await api.diskOnline(poolName, disk) + refreshPoolStatus(poolName) + } else if (action === "detach") { + if (!confirm(`Detach ${disk} from ${poolName}?`)) return + await api.diskDetach(poolName, disk) + refreshPoolStatus(poolName) + loadPools() + } else if (action === "clear") { + await api.clearDiskErrors(poolName, disk) + refreshPoolStatus(poolName) + } + } + const handlePoolAction = async (action: string, pool: Pool) => { setPoolMenu(null) if (action === "scrub") { @@ -631,7 +685,11 @@ export default function ZfsPage() { - + @@ -716,6 +774,35 @@ export default function ZfsPage() { + {/* Disk Context Menu */} + {diskMenu && ( + <> +
setDiskMenu(null)} /> +
+
+ {diskMenu.disk} +
+ {[ + { action: "clear", label: "Clear Disk Errors" }, + { action: "offline", label: "Offline Disk" }, + { action: "online", label: "Online Disk" }, + { action: "detach", label: "Detach Disk", danger: true }, + ].map(({ action, label, danger }) => ( + + ))} +
+ + )} + {/* Pool Context Menu */} {poolMenu && ( <> diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 00f1daf..1aa954f 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -233,6 +233,26 @@ export class ZFSManagerAPI { return response.data } + async diskOffline(poolName: string, disk: string): Promise<{ status: string }> { + const response = await this.client.post(`/api/pools/${poolName}/disk/offline`, null, { params: { disk } }) + return response.data + } + + async diskOnline(poolName: string, disk: string): Promise<{ status: string }> { + const response = await this.client.post(`/api/pools/${poolName}/disk/online`, null, { params: { disk } }) + return response.data + } + + async diskDetach(poolName: string, disk: string): Promise<{ status: string }> { + const response = await this.client.post(`/api/pools/${poolName}/disk/detach`, null, { params: { disk } }) + return response.data + } + + async clearDiskErrors(poolName: string, disk: string): Promise<{ status: string }> { + const response = await this.client.post(`/api/pools/${poolName}/clear`, null, { params: { disk } }) + return response.data + } + // Datasets async getDatasets(pool: string = "tank"): Promise { const response = await this.client.get("/api/datasets/", { params: { pool } })