Feature: VdevTree rekursiv + Disk-Aktionen (Offline/Online/Detach/Clear)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 00:51:34 +02:00
parent 6f6e8555af
commit 202fdfaaeb
3 changed files with 166 additions and 20 deletions
+39
View File
@@ -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)) 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") @router.post("/clear-cache")
async def clear_cache(current_user: str = Depends(get_current_user)): async def clear_cache(current_user: str = Depends(get_current_user)):
""" """
+92 -5
View File
@@ -64,11 +64,24 @@ 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 ( return (
<> <>
{vdevs.map((v, i) => ( {vdevs.map((v, i) => (
<tr key={`${depth}-${i}`} className="border-b border-border/40 hover:bg-muted/20"> <>
<tr key={`${depth}-${i}-${v.name}`} className="border-b border-border/40 hover:bg-muted/20">
<td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: `${depth * 20 + 16}px` }}> <td className="px-4 py-2 font-mono text-xs" style={{ paddingLeft: `${depth * 20 + 16}px` }}>
{v.name} {v.name}
</td> </td>
@@ -80,12 +93,26 @@ function VdevTree({ vdevs, depth = 0 }: { vdevs: any[]; depth?: number }) {
<td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td> <td className="px-4 py-2 text-xs text-center">{v.cksum ?? 0}</td>
<td className="px-4 py-2 text-xs text-muted-foreground">{v.message || ""}</td> <td className="px-4 py-2 text-xs text-muted-foreground">{v.message || ""}</td>
<td className="px-4 py-2 text-xs text-muted-foreground break-all">{v.product || ""}</td> <td className="px-4 py-2 text-xs text-muted-foreground break-all">{v.product || ""}</td>
<td className="px-4 py-2 text-xs"> <td className="px-4 py-2 text-xs text-right">
<button className="p-1 rounded hover:bg-muted"> {isDisk(v) && (
<button
className="p-1 rounded hover:bg-muted"
onClick={(e) => { e.stopPropagation(); onDiskMenu(e, v.name, poolName) }}
>
<MoreVertical className="w-3 h-3" /> <MoreVertical className="w-3 h-3" />
</button> </button>
)}
</td> </td>
</tr> </tr>
{v.children && v.children.length > 0 && (
<VdevTree
vdevs={v.children}
depth={depth + 1}
poolName={poolName}
onDiskMenu={onDiskMenu}
/>
)}
</>
))} ))}
</> </>
) )
@@ -99,6 +126,8 @@ export default function ZfsPage() {
// Pool context menu // Pool context menu
const [poolMenu, setPoolMenu] = useState<{ pool: Pool; x: number; y: number } | null>(null) 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 // Dialogs
const [showCreateFilesystem, setShowCreateFilesystem] = useState(false) const [showCreateFilesystem, setShowCreateFilesystem] = useState(false)
@@ -253,6 +282,31 @@ export default function ZfsPage() {
setCloneTarget(null) 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) => { const handlePoolAction = async (action: string, pool: Pool) => {
setPoolMenu(null) setPoolMenu(null)
if (action === "scrub") { if (action === "scrub") {
@@ -631,7 +685,11 @@ export default function ZfsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<VdevTree vdevs={state.status.vdevs ?? []} /> <VdevTree
vdevs={state.status.vdevs ?? []}
poolName={pool.name}
onDiskMenu={handleDiskMenuOpen}
/>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -716,6 +774,35 @@ export default function ZfsPage() {
</div> </div>
</Dialog> </Dialog>
{/* Disk Context Menu */}
{diskMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setDiskMenu(null)} />
<div
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[200px]"
style={{ top: diskMenu.y, left: diskMenu.x }}
>
<div className="px-4 py-1.5 text-xs text-muted-foreground font-mono border-b border-border mb-1">
{diskMenu.disk}
</div>
{[
{ 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 }) => (
<button
key={action}
className={`w-full text-left px-4 py-2 text-sm hover:bg-muted ${danger ? "text-destructive" : ""}`}
onClick={() => handleDiskAction(action)}
>
{label}
</button>
))}
</div>
</>
)}
{/* Pool Context Menu */} {/* Pool Context Menu */}
{poolMenu && ( {poolMenu && (
<> <>
+20
View File
@@ -233,6 +233,26 @@ export class ZFSManagerAPI {
return response.data 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 // Datasets
async getDatasets(pool: string = "tank"): Promise<Dataset[]> { async getDatasets(pool: string = "tank"): Promise<Dataset[]> {
const response = await this.client.get("/api/datasets/", { params: { pool } }) const response = await this.client.get("/api/datasets/", { params: { pool } })