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