Feature: ZFS Pool-Aktionen (Scrub, Resilver, Clear Errors) + Product-Spalte fix
- Backend: neue Endpoints POST /api/pools/{name}/clear und /resilver
- Frontend: Pool ⋮-Menü mit Scrub, Resilver, Clear Errors
- Product-Spalte im Status-Tab bricht jetzt korrekt um statt abgeschnitten zu werden
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,38 @@ async def scrub_pool(pool_name: str, current_user: str = Depends(get_current_use
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{pool_name}/clear")
|
||||||
|
async def clear_pool_errors(pool_name: str, current_user: str = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Clear error counters on pool
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stdout, stderr, rc = zfs_runner.run_command(["zpool", "clear", pool_name])
|
||||||
|
if rc != 0:
|
||||||
|
raise HTTPException(status_code=400, detail=stderr.strip() or "Failed to clear errors")
|
||||||
|
return {"status": "success", "message": f"Errors cleared on {pool_name}"}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{pool_name}/resilver")
|
||||||
|
async def resilver_pool(pool_name: str, current_user: str = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Start resilver on pool
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stdout, stderr, rc = zfs_runner.run_command(["zpool", "resilver", pool_name])
|
||||||
|
if rc != 0:
|
||||||
|
raise HTTPException(status_code=400, detail=stderr.strip() or "Failed to start resilver")
|
||||||
|
return {"status": "success", "message": f"Resilver started on {pool_name}"}
|
||||||
|
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)):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function VdevTree({ vdevs, depth = 0 }: { vdevs: any[]; depth?: number }) {
|
|||||||
<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.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-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">{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">
|
||||||
<button className="p-1 rounded hover:bg-muted">
|
<button className="p-1 rounded hover:bg-muted">
|
||||||
<MoreVertical className="w-3 h-3" />
|
<MoreVertical className="w-3 h-3" />
|
||||||
@@ -97,6 +97,9 @@ export default function ZfsPage() {
|
|||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null)
|
||||||
const [poolStates, setPoolStates] = useState<Map<string, PoolRowState>>(new Map())
|
const [poolStates, setPoolStates] = useState<Map<string, PoolRowState>>(new Map())
|
||||||
|
|
||||||
|
// Pool context menu
|
||||||
|
const [poolMenu, setPoolMenu] = useState<{ pool: Pool; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
const [showCreateFilesystem, setShowCreateFilesystem] = useState(false)
|
const [showCreateFilesystem, setShowCreateFilesystem] = useState(false)
|
||||||
const [createFsPool, setCreateFsPool] = useState("")
|
const [createFsPool, setCreateFsPool] = useState("")
|
||||||
@@ -250,6 +253,20 @@ export default function ZfsPage() {
|
|||||||
setCloneTarget(null)
|
setCloneTarget(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePoolAction = async (action: string, pool: Pool) => {
|
||||||
|
setPoolMenu(null)
|
||||||
|
if (action === "scrub") {
|
||||||
|
await api.startScrub(pool.name)
|
||||||
|
refreshPoolStatus(pool.name)
|
||||||
|
} else if (action === "clear") {
|
||||||
|
await api.clearPoolErrors(pool.name)
|
||||||
|
refreshPoolStatus(pool.name)
|
||||||
|
} else if (action === "resilver") {
|
||||||
|
await api.resilverPool(pool.name)
|
||||||
|
refreshPoolStatus(pool.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateFilesystem = async () => {
|
const handleCreateFilesystem = async () => {
|
||||||
if (!newFsName.trim()) return
|
if (!newFsName.trim()) return
|
||||||
const fullName = createFsPool ? `${createFsPool}/${newFsName.trim()}` : newFsName.trim()
|
const fullName = createFsPool ? `${createFsPool}/${newFsName.trim()}` : newFsName.trim()
|
||||||
@@ -359,7 +376,10 @@ export default function ZfsPage() {
|
|||||||
<UsageBar alloc={pool.alloc} size={pool.size} />
|
<UsageBar alloc={pool.alloc} size={pool.size} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<button className="p-1 rounded hover:bg-muted">
|
<button
|
||||||
|
className="p-1 rounded hover:bg-muted"
|
||||||
|
onClick={(e) => setPoolMenu({ pool, x: e.clientX, y: e.clientY })}
|
||||||
|
>
|
||||||
<MoreVertical className="w-4 h-4" />
|
<MoreVertical className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -696,6 +716,31 @@ export default function ZfsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Pool Context Menu */}
|
||||||
|
{poolMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setPoolMenu(null)} />
|
||||||
|
<div
|
||||||
|
className="fixed z-50 bg-background border border-border rounded-md shadow-lg py-1 min-w-[220px]"
|
||||||
|
style={{ top: poolMenu.y, left: poolMenu.x }}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ action: "scrub", label: "Scrub Storage Pool" },
|
||||||
|
{ action: "resilver", label: "Resilver Storage Pool" },
|
||||||
|
{ action: "clear", label: "Clear Storage Pool Errors" },
|
||||||
|
].map(({ action, label }) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-muted"
|
||||||
|
onClick={() => handlePoolAction(action, poolMenu.pool)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Snapshot Context Menu */}
|
{/* Snapshot Context Menu */}
|
||||||
{snapContextMenu && (
|
{snapContextMenu && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -223,6 +223,16 @@ export class ZFSManagerAPI {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearPoolErrors(poolName: string): Promise<{ status: string }> {
|
||||||
|
const response = await this.client.post(`/api/pools/${poolName}/clear`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async resilverPool(poolName: string): Promise<{ status: string }> {
|
||||||
|
const response = await this.client.post(`/api/pools/${poolName}/resilver`)
|
||||||
|
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 } })
|
||||||
|
|||||||
Reference in New Issue
Block a user