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:
@@ -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)):
|
||||
"""
|
||||
|
||||
+107
-20
@@ -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) => (
|
||||
<tr key={`${depth}-${i}`} 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` }}>
|
||||
{v.name}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<HealthBadge health={v.state || "—"} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.write ?? 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 break-all">{v.product || ""}</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
<button className="p-1 rounded hover:bg-muted">
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<>
|
||||
<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` }}>
|
||||
{v.name}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<HealthBadge health={v.state || "—"} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.read ?? 0}</td>
|
||||
<td className="px-4 py-2 text-xs text-center">{v.write ?? 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 break-all">{v.product || ""}</td>
|
||||
<td className="px-4 py-2 text-xs text-right">
|
||||
{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" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</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
|
||||
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() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<VdevTree vdevs={state.status.vdevs ?? []} />
|
||||
<VdevTree
|
||||
vdevs={state.status.vdevs ?? []}
|
||||
poolName={pool.name}
|
||||
onDiskMenu={handleDiskMenuOpen}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -716,6 +774,35 @@ export default function ZfsPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
{poolMenu && (
|
||||
<>
|
||||
|
||||
@@ -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<Dataset[]> {
|
||||
const response = await this.client.get("/api/datasets/", { params: { pool } })
|
||||
|
||||
Reference in New Issue
Block a user