mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
Merge pull request #561 from DrOwl/feature/zfs-pool-details
Show ZFS pool layout, scrub status and device health in Storage view
This commit is contained in:
@@ -121,12 +121,56 @@ interface DiskObservation {
|
|||||||
model: string
|
model: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool layout from `zpool status -j` (OpenZFS 2.3+). Only present when
|
||||||
|
// details_available is true; older ZFS gets the basic fields above.
|
||||||
|
interface ZFSDevice {
|
||||||
|
path: string
|
||||||
|
disk: string | null // base block device (e.g. "sdb"), null for file vdevs
|
||||||
|
state: string // ONLINE | DEGRADED | FAULTED | OFFLINE | UNAVAIL | REMOVED | AVAIL | INUSE
|
||||||
|
read_errors: number
|
||||||
|
write_errors: number
|
||||||
|
checksum_errors: number
|
||||||
|
slow_ios: number
|
||||||
|
via: string | null // "spare-0" / "replacing-0" while a device is being swapped
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZFSVdev {
|
||||||
|
name: string
|
||||||
|
type: string // mirror | raidz | draid | disk
|
||||||
|
parity: number | null
|
||||||
|
class: "normal" | "special" | "dedup" | "log" | "cache" | "spare"
|
||||||
|
state: string
|
||||||
|
read_errors: number
|
||||||
|
write_errors: number
|
||||||
|
checksum_errors: number
|
||||||
|
devices: ZFSDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZFSScan {
|
||||||
|
function: "SCRUB" | "RESILVER" | string
|
||||||
|
state: "SCANNING" | "FINISHED" | "CANCELED" | string
|
||||||
|
start_time: number | null
|
||||||
|
end_time: number | null
|
||||||
|
errors: number
|
||||||
|
repaired_bytes: number
|
||||||
|
percent: number | null // only while SCANNING
|
||||||
|
}
|
||||||
|
|
||||||
interface ZFSPool {
|
interface ZFSPool {
|
||||||
name: string
|
name: string
|
||||||
size: string
|
size: string
|
||||||
allocated: string
|
allocated: string
|
||||||
free: string
|
free: string
|
||||||
health: string
|
health: string
|
||||||
|
fragmentation?: number | null
|
||||||
|
capacity?: number | null
|
||||||
|
dedup_ratio?: number | null
|
||||||
|
details_available?: boolean
|
||||||
|
status?: string | null
|
||||||
|
action?: string | null
|
||||||
|
error_count?: number
|
||||||
|
scan?: ZFSScan | null
|
||||||
|
vdevs?: ZFSVdev[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StorageData {
|
interface StorageData {
|
||||||
@@ -250,6 +294,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
const [mountDetail, setMountDetail] = useState<RemoteMount | null>(null)
|
const [mountDetail, setMountDetail] = useState<RemoteMount | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
|
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
|
||||||
|
const [selectedPool, setSelectedPool] = useState<ZFSPool | null>(null)
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||||
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
|
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
|
||||||
const [loadingObservations, setLoadingObservations] = useState(false)
|
const [loadingObservations, setLoadingObservations] = useState(false)
|
||||||
@@ -397,6 +442,57 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZFS device/vdev states are shown verbatim, as `zpool status` prints them.
|
||||||
|
const getZfsStateBadge = (state: string) => {
|
||||||
|
const style =
|
||||||
|
state === "ONLINE"
|
||||||
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
|
: state === "AVAIL" || state === "INUSE"
|
||||||
|
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||||
|
: state === "DEGRADED"
|
||||||
|
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||||
|
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
|
return <Badge className={`font-mono text-xs ${style}`}>{state}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getZfsVdevLabel = (vdev: ZFSVdev) => {
|
||||||
|
if (vdev.type === "mirror") return t("storage.zfs.mirror")
|
||||||
|
if (vdev.type === "raidz") return t("storage.zfs.raidz", { parity: vdev.parity ?? 1 })
|
||||||
|
if (vdev.type === "draid") return t("storage.zfs.draid", { parity: vdev.parity ?? 1 })
|
||||||
|
return t("storage.zfs.singleDisk")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-line pool shape for the card, e.g. "RAIDZ2", "2 x Mirror", "Stripe".
|
||||||
|
const getZfsLayoutSummary = (pool: ZFSPool) => {
|
||||||
|
const data = (pool.vdevs ?? []).filter((v) => v.class === "normal")
|
||||||
|
if (data.length === 0) return null
|
||||||
|
if (data.every((v) => v.type === "disk")) {
|
||||||
|
return data.length === 1 ? t("storage.zfs.singleDisk") : t("storage.zfs.stripe")
|
||||||
|
}
|
||||||
|
const labels = [...new Set(data.map(getZfsVdevLabel))]
|
||||||
|
if (labels.length === 1 && data.length > 1) return `${data.length} x ${labels[0]}`
|
||||||
|
return labels.join(" + ")
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatZfsTime = (epoch: number | null | undefined) =>
|
||||||
|
epoch ? new Date(epoch * 1000).toLocaleString() : t("common.notAvailable")
|
||||||
|
|
||||||
|
const getZfsScanSummary = (scan: ZFSScan | null | undefined) => {
|
||||||
|
if (!scan) return t("storage.zfs.neverScrubbed")
|
||||||
|
const kind = scan.function === "RESILVER" ? t("storage.zfs.resilver") : t("storage.zfs.scrub")
|
||||||
|
if (scan.state === "SCANNING") {
|
||||||
|
return t("storage.zfs.scanRunning", { kind, percent: scan.percent ?? 0 })
|
||||||
|
}
|
||||||
|
if (scan.state === "CANCELED") return t("storage.zfs.scanCanceled", { kind })
|
||||||
|
return t("storage.zfs.scanFinished", {
|
||||||
|
kind,
|
||||||
|
date: scan.end_time ? new Date(scan.end_time * 1000).toLocaleDateString() : "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const zfsDeviceErrors = (d: { read_errors: number; write_errors: number; checksum_errors: number }) =>
|
||||||
|
d.read_errors + d.write_errors + d.checksum_errors
|
||||||
|
|
||||||
// Tiny coloured dot that prefixes status / counter values. Adds
|
// Tiny coloured dot that prefixes status / counter values. Adds
|
||||||
// accessibility-friendly redundancy (colour + position) to fields
|
// accessibility-friendly redundancy (colour + position) to fields
|
||||||
// that today rely on colour alone, so an "all OK" disk reads as
|
// that today rely on colour alone, so an "all OK" disk reads as
|
||||||
@@ -1769,7 +1865,11 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{storageData.zfs_pools.map((pool) => (
|
{storageData.zfs_pools.map((pool) => (
|
||||||
<div key={pool.name} className="border rounded-lg p-4">
|
<div
|
||||||
|
key={pool.name}
|
||||||
|
className="border rounded-lg p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => setSelectedPool(pool)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="font-semibold text-lg">{pool.name}</h3>
|
<h3 className="font-semibold text-lg">{pool.name}</h3>
|
||||||
@@ -1777,6 +1877,31 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
</div>
|
</div>
|
||||||
{getHealthIcon(pool.health)}
|
{getHealthIcon(pool.health)}
|
||||||
</div>
|
</div>
|
||||||
|
{pool.details_available && (() => {
|
||||||
|
const dataDevices = (pool.vdevs ?? [])
|
||||||
|
.filter((v) => v.class === "normal")
|
||||||
|
.reduce((n, v) => n + v.devices.length, 0)
|
||||||
|
const problems = (pool.vdevs ?? []).some(
|
||||||
|
(v) => v.devices.some((d) => zfsDeviceErrors(d) > 0) || zfsDeviceErrors(v) > 0,
|
||||||
|
) || (pool.error_count ?? 0) > 0
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground mb-3 flex flex-wrap items-center gap-x-2">
|
||||||
|
<span className="font-medium text-foreground">{getZfsLayoutSummary(pool)}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>
|
||||||
|
{t(dataDevices === 1 ? "storage.zfs.diskCountOne" : "storage.zfs.diskCountMany", { count: dataDevices })}
|
||||||
|
</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>{getZfsScanSummary(pool.scan)}</span>
|
||||||
|
{problems && (
|
||||||
|
<span className="flex items-center gap-1 text-red-500">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
{t("storage.zfs.errorsReported")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">{t("storage.size")}</p>
|
<p className="text-sm text-muted-foreground">{t("storage.size")}</p>
|
||||||
@@ -1798,6 +1923,197 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ZFS Pool Details Dialog: re-resolved from storageData so it follows
|
||||||
|
the background refresh while open (e.g. resilver progress). */}
|
||||||
|
<Dialog open={selectedPool !== null} onOpenChange={(open) => { if (!open) setSelectedPool(null) }}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||||
|
{selectedPool && (() => {
|
||||||
|
const pool = storageData.zfs_pools.find((p) => p.name === selectedPool.name) ?? selectedPool
|
||||||
|
const classOrder: ZFSVdev["class"][] = ["normal", "special", "dedup", "log", "cache", "spare"]
|
||||||
|
const vdevs = [...(pool.vdevs ?? [])].sort(
|
||||||
|
(a, b) => classOrder.indexOf(a.class) - classOrder.indexOf(b.class),
|
||||||
|
)
|
||||||
|
const showSlowIos = vdevs.some((v) => v.devices.some((d) => d.slow_ios > 0))
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-3">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
{pool.name}
|
||||||
|
{getHealthBadge(pool.health)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("storage.zfs.poolDetails")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{(pool.status || pool.action) && (
|
||||||
|
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm space-y-1">
|
||||||
|
{pool.status && <p><span className="font-medium">{t("storage.zfs.status")}:</span> {pool.status}</p>}
|
||||||
|
{pool.action && <p><span className="font-medium">{t("storage.zfs.action")}:</span> {pool.action}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.size")}</p>
|
||||||
|
<p className="font-medium">{pool.size}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.allocated")}</p>
|
||||||
|
<p className="font-medium">{pool.allocated}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.free")}</p>
|
||||||
|
<p className="font-medium">{pool.free}</p>
|
||||||
|
</div>
|
||||||
|
{pool.capacity != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.capacity")}</p>
|
||||||
|
<p className="font-medium">{pool.capacity}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pool.fragmentation != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.fragmentation")}</p>
|
||||||
|
<p className="font-medium">{pool.fragmentation}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pool.dedup_ratio != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.dedupRatio")}</p>
|
||||||
|
<p className="font-medium">{pool.dedup_ratio.toFixed(2)}x</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!pool.details_available ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">{t("storage.zfs.detailsUnavailable")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Scrub / resilver */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
{t("storage.zfs.scanTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm">{getZfsScanSummary(pool.scan)}</p>
|
||||||
|
{pool.scan?.state === "SCANNING" && <Progress value={pool.scan.percent ?? 0} className="h-2" />}
|
||||||
|
{pool.scan && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.started")}</p>
|
||||||
|
<p className="font-medium">{formatZfsTime(pool.scan.start_time)}</p>
|
||||||
|
</div>
|
||||||
|
{pool.scan.state !== "SCANNING" && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.ended")}</p>
|
||||||
|
<p className="font-medium">{formatZfsTime(pool.scan.end_time)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.repaired")}</p>
|
||||||
|
<p className="font-medium">{formatStorage(pool.scan.repaired_bytes / 1024 ** 3)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.scanErrors")}</p>
|
||||||
|
<p className={`font-medium ${pool.scan.errors > 0 ? "text-red-500" : ""}`}>{pool.scan.errors}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={`text-sm ${(pool.error_count ?? 0) > 0 ? "text-red-500" : "text-green-500"}`}>
|
||||||
|
{(pool.error_count ?? 0) > 0
|
||||||
|
? t("storage.zfs.dataErrors", { count: pool.error_count ?? 0 })
|
||||||
|
: t("storage.zfs.noDataErrors")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout: one block per top-level vdev */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
{t("storage.zfs.layout")}
|
||||||
|
{getZfsLayoutSummary(pool) && (
|
||||||
|
<span className="ml-2 normal-case font-normal text-foreground">{getZfsLayoutSummary(pool)}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{vdevs.map((vdev, i) => (
|
||||||
|
<div key={`${vdev.class}-${vdev.name}-${i}`} className="border rounded-lg">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b bg-muted/30">
|
||||||
|
<Badge variant="outline" className="text-xs">{t(`storage.zfs.classes.${vdev.class}`)}</Badge>
|
||||||
|
<span className="text-sm font-medium">{getZfsVdevLabel(vdev)}</span>
|
||||||
|
{vdev.type !== "disk" && (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{vdev.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto">{getZfsStateBadge(vdev.state)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-muted-foreground">
|
||||||
|
<th className="text-left font-normal px-3 py-1.5">{t("storage.zfs.device")}</th>
|
||||||
|
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.disk")}</th>
|
||||||
|
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.state")}</th>
|
||||||
|
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.readErrors")}>{t("storage.zfs.readShort")}</th>
|
||||||
|
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.writeErrors")}>{t("storage.zfs.writeShort")}</th>
|
||||||
|
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.checksumErrors")}>{t("storage.zfs.checksumShort")}</th>
|
||||||
|
{showSlowIos && (
|
||||||
|
<th className="text-right font-normal px-3 py-1.5">{t("storage.zfs.slowIos")}</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{vdev.devices.map((dev) => {
|
||||||
|
const diskInfo = dev.disk ? storageData.disks.find((d) => d.name === dev.disk) : undefined
|
||||||
|
const errCls = (n: number) => `text-right px-2 py-1.5 font-mono ${n > 0 ? "text-red-500 font-semibold" : "text-muted-foreground"}`
|
||||||
|
return (
|
||||||
|
<tr key={`${dev.path}-${dev.via ?? ""}`} className="border-t border-border/50">
|
||||||
|
<td className="px-3 py-1.5 font-mono text-xs break-all" title={dev.path}>
|
||||||
|
{dev.path.split("/").pop()}
|
||||||
|
{dev.via && (
|
||||||
|
<span className="ml-2 text-muted-foreground">({t("storage.zfs.via", { name: dev.via })})</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
{diskInfo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={diskInfo.model}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPool(null)
|
||||||
|
handleDiskClick(diskInfo)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="font-mono hover:bg-accent">{diskInfo.name}</Badge>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{dev.disk ?? "-"}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5">{getZfsStateBadge(dev.state)}</td>
|
||||||
|
<td className={errCls(dev.read_errors)}>{dev.read_errors}</td>
|
||||||
|
<td className={errCls(dev.write_errors)}>{dev.write_errors}</td>
|
||||||
|
<td className={errCls(dev.checksum_errors)}>{dev.checksum_errors}</td>
|
||||||
|
{showSlowIos && (
|
||||||
|
<td className={`text-right px-3 py-1.5 font-mono ${dev.slow_ios > 0 ? "text-yellow-500" : "text-muted-foreground"}`}>
|
||||||
|
{dev.slow_ios}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Physical Disks (internal only) */}
|
{/* Physical Disks (internal only) */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -309,6 +309,56 @@
|
|||||||
"externalStorageUsb": "External Storage (USB)",
|
"externalStorageUsb": "External Storage (USB)",
|
||||||
"physicalDisksSmart": "Physical Disks & SMART Status",
|
"physicalDisksSmart": "Physical Disks & SMART Status",
|
||||||
"zfsPools": "ZFS Pools",
|
"zfsPools": "ZFS Pools",
|
||||||
|
"zfs": {
|
||||||
|
"poolDetails": "Pool layout, scrub status and device health",
|
||||||
|
"detailsUnavailable": "Detailed pool layout requires OpenZFS 2.3 or newer.",
|
||||||
|
"status": "Status",
|
||||||
|
"action": "Action",
|
||||||
|
"capacity": "Capacity",
|
||||||
|
"fragmentation": "Fragmentation",
|
||||||
|
"dedupRatio": "Dedup Ratio",
|
||||||
|
"layout": "Layout",
|
||||||
|
"singleDisk": "Single disk",
|
||||||
|
"stripe": "Stripe",
|
||||||
|
"mirror": "Mirror",
|
||||||
|
"raidz": "RAIDZ{parity}",
|
||||||
|
"draid": "dRAID{parity}",
|
||||||
|
"diskCountOne": "{count} disk",
|
||||||
|
"diskCountMany": "{count} disks",
|
||||||
|
"errorsReported": "Errors reported",
|
||||||
|
"scanTitle": "Scrub / Resilver",
|
||||||
|
"scrub": "Scrub",
|
||||||
|
"resilver": "Resilver",
|
||||||
|
"neverScrubbed": "Never scrubbed",
|
||||||
|
"scanRunning": "{kind} in progress ({percent}%)",
|
||||||
|
"scanCanceled": "{kind} canceled",
|
||||||
|
"scanFinished": "{kind} finished {date}",
|
||||||
|
"started": "Started",
|
||||||
|
"ended": "Finished",
|
||||||
|
"repaired": "Repaired",
|
||||||
|
"scanErrors": "Scan Errors",
|
||||||
|
"dataErrors": "{count} data errors",
|
||||||
|
"noDataErrors": "No known data errors",
|
||||||
|
"device": "Device",
|
||||||
|
"disk": "Disk",
|
||||||
|
"state": "State",
|
||||||
|
"readErrors": "Read errors",
|
||||||
|
"writeErrors": "Write errors",
|
||||||
|
"checksumErrors": "Checksum errors",
|
||||||
|
"readShort": "R",
|
||||||
|
"writeShort": "W",
|
||||||
|
"checksumShort": "CK",
|
||||||
|
"slowIos": "Slow I/Os",
|
||||||
|
"via": "via {name}",
|
||||||
|
"classes": {
|
||||||
|
"normal": "Data",
|
||||||
|
"special": "Special",
|
||||||
|
"dedup": "Dedup",
|
||||||
|
"log": "Log",
|
||||||
|
"cache": "Cache",
|
||||||
|
"spare": "Spare"
|
||||||
|
}
|
||||||
|
},
|
||||||
"local": "Local",
|
"local": "Local",
|
||||||
"remote": "Remote",
|
"remote": "Remote",
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
|
|||||||
@@ -4031,6 +4031,149 @@ def _resolve_zfs_entry(entry):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_zpool_status_details():
|
||||||
|
"""Return `zpool status` for every pool as parsed JSON, keyed by pool name.
|
||||||
|
|
||||||
|
Needs OpenZFS 2.3+ for `-j`. Returns None when the JSON output isn't
|
||||||
|
available (older ZFS, no zpool binary) so callers can tell "no details"
|
||||||
|
apart from "no pools". --json-int gives raw byte counts and epoch
|
||||||
|
timestamps; -P gives full device paths.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['zpool', 'status', '-j', '--json-int', '-P'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return json.loads(result.stdout).get('pools') or {}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _zfs_member_disk(path):
|
||||||
|
"""Map a pool member path (by-id, by-partlabel, /dev/sdX3...) to its base disk.
|
||||||
|
|
||||||
|
Pure sysfs (no lsblk subprocess) since /api/storage is polled and a
|
||||||
|
pool can have dozens of members. Non-/dev members (file vdevs) give None.
|
||||||
|
"""
|
||||||
|
if not path.startswith('/dev/'):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
name = os.path.basename(os.path.realpath(path))
|
||||||
|
if os.path.exists(f'/sys/class/block/{name}/partition'):
|
||||||
|
return os.path.basename(os.path.dirname(os.path.realpath(f'/sys/class/block/{name}')))
|
||||||
|
if os.path.exists(f'/sys/class/block/{name}'):
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _zfs_errors(node):
|
||||||
|
return {
|
||||||
|
'read_errors': node.get('read_errors', 0),
|
||||||
|
'write_errors': node.get('write_errors', 0),
|
||||||
|
'checksum_errors': node.get('checksum_errors', 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _zfs_device(node, via=None):
|
||||||
|
path = node.get('path') or node.get('name', '')
|
||||||
|
return {
|
||||||
|
'path': path,
|
||||||
|
'disk': _zfs_member_disk(path),
|
||||||
|
'state': node.get('state', 'UNKNOWN'),
|
||||||
|
'slow_ios': node.get('slow_ios', 0),
|
||||||
|
'via': via,
|
||||||
|
**_zfs_errors(node),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_ZFS_PARITY_RE = re.compile(r'^(?:raidz|draid)(\d)')
|
||||||
|
|
||||||
|
|
||||||
|
def _zfs_vdev(node, vdev_class):
|
||||||
|
"""Flatten one top-level vdev into {type, parity, devices[...]}.
|
||||||
|
|
||||||
|
A leaf at the top level (single disk, stripe member, log/cache/spare
|
||||||
|
device) becomes a vdev of type 'disk' holding just itself. spare-N and
|
||||||
|
replacing-N wrappers inside a mirror/raidz are unwrapped, with each
|
||||||
|
device tagged `via` so the UI can show what's being swapped.
|
||||||
|
"""
|
||||||
|
children = node.get('vdevs') or {}
|
||||||
|
if not children:
|
||||||
|
device = _zfs_device(node)
|
||||||
|
return {
|
||||||
|
'name': device['path'],
|
||||||
|
'type': 'disk',
|
||||||
|
'parity': None,
|
||||||
|
'class': vdev_class,
|
||||||
|
'state': device['state'],
|
||||||
|
'devices': [device],
|
||||||
|
**_zfs_errors(node),
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
for child in children.values():
|
||||||
|
if child.get('vdevs'):
|
||||||
|
devices.extend(_zfs_device(leaf, via=child.get('name')) for leaf in child['vdevs'].values())
|
||||||
|
else:
|
||||||
|
devices.append(_zfs_device(child))
|
||||||
|
|
||||||
|
parity = _ZFS_PARITY_RE.match(node.get('name', ''))
|
||||||
|
return {
|
||||||
|
'name': node.get('name', ''),
|
||||||
|
'type': node.get('vdev_type', ''),
|
||||||
|
'parity': int(parity.group(1)) if parity else None,
|
||||||
|
'class': vdev_class,
|
||||||
|
'state': node.get('state', 'UNKNOWN'),
|
||||||
|
'devices': devices,
|
||||||
|
**_zfs_errors(node),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _zfs_pool_details(pool):
|
||||||
|
"""Turn one pool from `zpool status -j` into the shape the Storage view uses.
|
||||||
|
|
||||||
|
Data/special/dedup vdevs hang off the root vdev; log, cache and spare
|
||||||
|
devices live in separate top-level maps. GUIDs are dropped; they don't
|
||||||
|
fit in a JS number and the UI doesn't need them.
|
||||||
|
"""
|
||||||
|
root = (pool.get('vdevs') or {}).get(pool.get('name'), {})
|
||||||
|
vdevs = [_zfs_vdev(child, child.get('class', 'normal')) for child in (root.get('vdevs') or {}).values()]
|
||||||
|
for key, vdev_class in (('logs', 'log'), ('l2cache', 'cache'), ('spares', 'spare')):
|
||||||
|
vdevs.extend(_zfs_vdev(child, vdev_class) for child in (pool.get(key) or {}).values())
|
||||||
|
|
||||||
|
scan = None
|
||||||
|
stats = pool.get('scan_stats')
|
||||||
|
if stats:
|
||||||
|
# issued/to_examine drifts either side of 100 once finished (skipped
|
||||||
|
# blocks), so progress is only meaningful while the scan is running.
|
||||||
|
to_examine = stats.get('to_examine') or 0
|
||||||
|
running = stats.get('state') == 'SCANNING' and to_examine
|
||||||
|
scan = {
|
||||||
|
'function': stats.get('function'), # SCRUB | RESILVER
|
||||||
|
'state': stats.get('state'), # SCANNING | FINISHED | CANCELED
|
||||||
|
'start_time': stats.get('start_time'),
|
||||||
|
'end_time': stats.get('end_time'),
|
||||||
|
'errors': stats.get('errors', 0),
|
||||||
|
'repaired_bytes': stats.get('processed', 0),
|
||||||
|
'percent': min(100.0, round(100 * (stats.get('issued') or 0) / to_examine, 1)) if running else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _tidy(text):
|
||||||
|
return ' '.join(text.split()) if text else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': _tidy(pool.get('status')),
|
||||||
|
'action': _tidy(pool.get('action')),
|
||||||
|
'error_count': pool.get('error_count', 0),
|
||||||
|
'scan': scan,
|
||||||
|
'vdevs': vdevs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_system_disks():
|
def get_system_disks():
|
||||||
"""
|
"""
|
||||||
Detect which physical disks are used by the system (Proxmox).
|
Detect which physical disks are used by the system (Proxmox).
|
||||||
@@ -4484,8 +4627,6 @@ def get_storage_info():
|
|||||||
total_used = 0
|
total_used = 0
|
||||||
total_available = 0
|
total_available = 0
|
||||||
|
|
||||||
zfs_disks = set()
|
|
||||||
|
|
||||||
for partition in disk_partitions:
|
for partition in disk_partitions:
|
||||||
try:
|
try:
|
||||||
# Skip special filesystems
|
# Skip special filesystems
|
||||||
@@ -4531,9 +4672,11 @@ def get_storage_info():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['zpool', 'list', '-H', '-p', '-o', 'name,size,alloc,free,health'],
|
result = subprocess.run(['zpool', 'list', '-H', '-p', '-o', 'name,size,alloc,free,health,frag,cap,dedup'],
|
||||||
capture_output=True, text=True, timeout=5)
|
capture_output=True, text=True, timeout=5)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
# One `zpool status` for all pools; None on ZFS < 2.3 (no -j)
|
||||||
|
zpool_details = get_zpool_status_details()
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
if line:
|
if line:
|
||||||
parts = line.split('\t')
|
parts = line.split('\t')
|
||||||
@@ -4543,6 +4686,14 @@ def get_storage_info():
|
|||||||
pool_alloc_bytes = int(parts[2])
|
pool_alloc_bytes = int(parts[2])
|
||||||
pool_free_bytes = int(parts[3])
|
pool_free_bytes = int(parts[3])
|
||||||
pool_health = parts[4]
|
pool_health = parts[4]
|
||||||
|
# frag is '-' on pools without spacemap histograms
|
||||||
|
extra = parts[5:8] + ['-'] * (8 - len(parts))
|
||||||
|
fragmentation = int(extra[0]) if extra[0].isdigit() else None
|
||||||
|
capacity = int(extra[1]) if extra[1].isdigit() else None
|
||||||
|
try:
|
||||||
|
dedup_ratio = float(extra[2])
|
||||||
|
except ValueError:
|
||||||
|
dedup_ratio = None
|
||||||
|
|
||||||
total_used += pool_alloc_bytes
|
total_used += pool_alloc_bytes
|
||||||
total_available += pool_free_bytes
|
total_available += pool_free_bytes
|
||||||
@@ -4563,22 +4714,20 @@ def get_storage_info():
|
|||||||
'size': format_zfs_size(pool_size_bytes),
|
'size': format_zfs_size(pool_size_bytes),
|
||||||
'allocated': format_zfs_size(pool_alloc_bytes),
|
'allocated': format_zfs_size(pool_alloc_bytes),
|
||||||
'free': format_zfs_size(pool_free_bytes),
|
'free': format_zfs_size(pool_free_bytes),
|
||||||
'health': pool_health
|
'health': pool_health,
|
||||||
|
'fragmentation': fragmentation,
|
||||||
|
'capacity': capacity,
|
||||||
|
'dedup_ratio': dedup_ratio,
|
||||||
|
'details_available': False,
|
||||||
}
|
}
|
||||||
|
if zpool_details and pool_name in zpool_details:
|
||||||
|
try:
|
||||||
|
pool_info.update(_zfs_pool_details(zpool_details[pool_name]))
|
||||||
|
pool_info['details_available'] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
storage_data['zfs_pools'].append(pool_info)
|
storage_data['zfs_pools'].append(pool_info)
|
||||||
|
|
||||||
try:
|
|
||||||
pool_status = subprocess.run(['zpool', 'status', pool_name],
|
|
||||||
capture_output=True, text=True, timeout=5)
|
|
||||||
if pool_status.returncode == 0:
|
|
||||||
for status_line in pool_status.stdout.split('\n'):
|
|
||||||
for disk_name in physical_disks.keys():
|
|
||||||
if disk_name in status_line:
|
|
||||||
zfs_disks.add(disk_name)
|
|
||||||
except Exception as e:
|
|
||||||
# print(f"Error getting ZFS pool status for {pool_name}: {e}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# print("[v0] Note: ZFS not installed")
|
# print("[v0] Note: ZFS not installed")
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"output_version": {
|
||||||
|
"command": "zpool status",
|
||||||
|
"vers_major": 0,
|
||||||
|
"vers_minor": 1
|
||||||
|
},
|
||||||
|
"pools": {
|
||||||
|
"pmx-test": {
|
||||||
|
"name": "pmx-test",
|
||||||
|
"state": "DEGRADED",
|
||||||
|
"txg": 8,
|
||||||
|
"spa_version": 5000,
|
||||||
|
"zpl_version": 5,
|
||||||
|
"status": "One or more devices has been taken offline by the administrator.\n\tSufficient replicas exist for the pool to continue functioning in a\n\tdegraded state.\n",
|
||||||
|
"action": "Online the device using 'zpool online' or replace the device with\n\t'zpool replace'.\n",
|
||||||
|
"vdevs": {
|
||||||
|
"pmx-test": {
|
||||||
|
"name": "pmx-test",
|
||||||
|
"vdev_type": "root",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "DEGRADED",
|
||||||
|
"alloc_space": 265216,
|
||||||
|
"total_space": 738197504,
|
||||||
|
"def_space": 491651072,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"raidz1-0": {
|
||||||
|
"name": "raidz1-0",
|
||||||
|
"vdev_type": "raidz",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "DEGRADED",
|
||||||
|
"alloc_space": 265216,
|
||||||
|
"total_space": 738197504,
|
||||||
|
"def_space": 491651072,
|
||||||
|
"rep_dev_size": 738197504,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"/root/zfstest/d1.img": {
|
||||||
|
"name": "/root/zfstest/d1.img",
|
||||||
|
"vdev_type": "file",
|
||||||
|
"path": "/root/zfstest/d1.img",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 250784427,
|
||||||
|
"phys_space": 268435456,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"/root/zfstest/d2.img": {
|
||||||
|
"name": "/root/zfstest/d2.img",
|
||||||
|
"vdev_type": "file",
|
||||||
|
"path": "/root/zfstest/d2.img",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "OFFLINE",
|
||||||
|
"rep_dev_size": 250784427,
|
||||||
|
"phys_space": 268435456,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"/root/zfstest/d3.img": {
|
||||||
|
"name": "/root/zfstest/d3.img",
|
||||||
|
"vdev_type": "file",
|
||||||
|
"path": "/root/zfstest/d3.img",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 250784427,
|
||||||
|
"phys_space": 268435456,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"/root/zfstest/d4.img": {
|
||||||
|
"name": "/root/zfstest/d4.img",
|
||||||
|
"vdev_type": "file",
|
||||||
|
"path": "/root/zfstest/d4.img",
|
||||||
|
"class": "log",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"total_space": 251658240,
|
||||||
|
"def_space": 251658240,
|
||||||
|
"rep_dev_size": 256376832,
|
||||||
|
"phys_space": 268435456,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"l2cache": {
|
||||||
|
"/root/zfstest/d5.img": {
|
||||||
|
"name": "/root/zfstest/d5.img",
|
||||||
|
"vdev_type": "file",
|
||||||
|
"path": "/root/zfstest/d5.img",
|
||||||
|
"class": "l2cache",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"total_space": 263716352,
|
||||||
|
"rep_dev_size": 268435456,
|
||||||
|
"phys_space": 268435456,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spares": {
|
||||||
|
"/root/zfstest/d6.img": {
|
||||||
|
"name": "/root/zfstest/d6.img",
|
||||||
|
"vdev_type": "file",
|
||||||
|
"path": "/root/zfstest/d6.img",
|
||||||
|
"class": "spare",
|
||||||
|
"state": "AVAIL",
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_count": 0,
|
||||||
|
"pool_guid": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
{
|
||||||
|
"output_version": {
|
||||||
|
"command": "zpool status",
|
||||||
|
"vers_major": 0,
|
||||||
|
"vers_minor": 1
|
||||||
|
},
|
||||||
|
"pools": {
|
||||||
|
"rpool": {
|
||||||
|
"name": "rpool",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"txg": 25710203,
|
||||||
|
"spa_version": 5000,
|
||||||
|
"zpl_version": 5,
|
||||||
|
"scan_stats": {
|
||||||
|
"function": "SCRUB",
|
||||||
|
"state": "FINISHED",
|
||||||
|
"start_time": 1791674641,
|
||||||
|
"end_time": 1791675294,
|
||||||
|
"to_examine": 421980536832,
|
||||||
|
"examined": 421980536832,
|
||||||
|
"skipped": 22963814,
|
||||||
|
"processed": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes_per_scan": 0,
|
||||||
|
"pass_start": 1791674660,
|
||||||
|
"scrub_pause": 0,
|
||||||
|
"scrub_spent_paused": 0,
|
||||||
|
"issued_bytes_per_scan": 421980536832,
|
||||||
|
"issued": 421980536832
|
||||||
|
},
|
||||||
|
"vdevs": {
|
||||||
|
"rpool": {
|
||||||
|
"name": "rpool",
|
||||||
|
"vdev_type": "root",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 411243118592,
|
||||||
|
"total_space": 996432412672,
|
||||||
|
"def_space": 996432412672,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"mirror-0": {
|
||||||
|
"name": "mirror-0",
|
||||||
|
"vdev_type": "mirror",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 411243118592,
|
||||||
|
"total_space": 996432412672,
|
||||||
|
"def_space": 996432412672,
|
||||||
|
"rep_dev_size": 996432412672,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"nvme-CT1000P5PSSD8_2231000000A1-part3": {
|
||||||
|
"name": "nvme-CT1000P5PSSD8_2231000000A1-part3",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-id/nvme-CT1000P5PSSD8_2231000000A1-part3",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 996432412672,
|
||||||
|
"phys_space": 999653638144,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"nvme-s0-1tb-open": {
|
||||||
|
"name": "nvme-s0-1tb-open",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/nvme-s0-1tb-open",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 996432412672,
|
||||||
|
"phys_space": 999653638144,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_count": 0,
|
||||||
|
"pool_guid": 1000
|
||||||
|
},
|
||||||
|
"tank-m1": {
|
||||||
|
"name": "tank-m1",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"txg": 32213752,
|
||||||
|
"spa_version": 5000,
|
||||||
|
"zpl_version": 5,
|
||||||
|
"scan_stats": {
|
||||||
|
"function": "SCRUB",
|
||||||
|
"state": "FINISHED",
|
||||||
|
"start_time": 1791674641,
|
||||||
|
"end_time": 1791675294,
|
||||||
|
"to_examine": 528280977408,
|
||||||
|
"examined": 529354719232,
|
||||||
|
"skipped": 56623104,
|
||||||
|
"processed": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes_per_scan": 0,
|
||||||
|
"pass_start": 1791674757,
|
||||||
|
"scrub_pause": 0,
|
||||||
|
"scrub_spent_paused": 0,
|
||||||
|
"issued_bytes_per_scan": 507879882752,
|
||||||
|
"issued": 528280977408
|
||||||
|
},
|
||||||
|
"vdevs": {
|
||||||
|
"tank-m1": {
|
||||||
|
"name": "tank-m1",
|
||||||
|
"vdev_type": "root",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 525059751936,
|
||||||
|
"total_space": 996432412672,
|
||||||
|
"def_space": 996432412672,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"mirror-0": {
|
||||||
|
"name": "mirror-0",
|
||||||
|
"vdev_type": "mirror",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 525059751936,
|
||||||
|
"total_space": 996432412672,
|
||||||
|
"def_space": 996432412672,
|
||||||
|
"rep_dev_size": 996432412672,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"ssd-t1-1Tb-mx500": {
|
||||||
|
"name": "ssd-t1-1Tb-mx500",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/ssd-t1-1Tb-mx500",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 996432412672,
|
||||||
|
"phys_space": 1000727379968,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"ssd-t2-1Tb-mx500": {
|
||||||
|
"name": "ssd-t2-1Tb-mx500",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/ssd-t2-1Tb-mx500",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 996432412672,
|
||||||
|
"phys_space": 1000727379968,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 69,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_count": 0,
|
||||||
|
"pool_guid": 1000
|
||||||
|
},
|
||||||
|
"tank-rz2": {
|
||||||
|
"name": "tank-rz2",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"txg": 7867809,
|
||||||
|
"spa_version": 5000,
|
||||||
|
"zpl_version": 5,
|
||||||
|
"scan_stats": {
|
||||||
|
"function": "SCRUB",
|
||||||
|
"state": "FINISHED",
|
||||||
|
"start_time": 1791674641,
|
||||||
|
"end_time": 1791675294,
|
||||||
|
"to_examine": 10511331161538,
|
||||||
|
"examined": 10522326277816,
|
||||||
|
"skipped": 10297184092,
|
||||||
|
"processed": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes_per_scan": 0,
|
||||||
|
"pass_start": 1791674783,
|
||||||
|
"scrub_pause": 0,
|
||||||
|
"scrub_spent_paused": 0,
|
||||||
|
"issued_bytes_per_scan": 10500336045260,
|
||||||
|
"issued": 10500336045260
|
||||||
|
},
|
||||||
|
"vdevs": {
|
||||||
|
"tank-rz2": {
|
||||||
|
"name": "tank-rz2",
|
||||||
|
"vdev_type": "root",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 12094627905536,
|
||||||
|
"total_space": 19241453486080,
|
||||||
|
"def_space": 11324969766092,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"raidz2-0": {
|
||||||
|
"name": "raidz2-0",
|
||||||
|
"vdev_type": "raidz",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 12094627905536,
|
||||||
|
"total_space": 19241453486080,
|
||||||
|
"def_space": 11324969766092,
|
||||||
|
"rep_dev_size": 19241453486080,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"ssd-3T5-Tosh-B001": {
|
||||||
|
"name": "ssd-3T5-Tosh-B001",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/ssd-3T5-Tosh-B001",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 3837295580938,
|
||||||
|
"phys_space": 3837295580938,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"ssd-3T5-Tosh-B002": {
|
||||||
|
"name": "ssd-3T5-Tosh-B002",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/ssd-3T5-Tosh-B002",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 3837295580938,
|
||||||
|
"phys_space": 3837295580938,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"ssd-3T5-Tosh-B003": {
|
||||||
|
"name": "ssd-3T5-Tosh-B003",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/ssd-3T5-Tosh-B003",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 3837295580938,
|
||||||
|
"phys_space": 3837295580938,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"ssd-3T5-Tosh-B004": {
|
||||||
|
"name": "ssd-3T5-Tosh-B004",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/ssd-3T5-Tosh-B004",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 3837295580938,
|
||||||
|
"phys_space": 3837295580938,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"ssd-3T5-Tosh-B005": {
|
||||||
|
"name": "ssd-3T5-Tosh-B005",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-partlabel/ssd-3T5-Tosh-B005",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 3837295580938,
|
||||||
|
"phys_space": 3837295580938,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_count": 0,
|
||||||
|
"pool_guid": 1000
|
||||||
|
},
|
||||||
|
"tank-spin-1": {
|
||||||
|
"name": "tank-spin-1",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"txg": 23263182,
|
||||||
|
"spa_version": 5000,
|
||||||
|
"zpl_version": 5,
|
||||||
|
"scan_stats": {
|
||||||
|
"function": "SCRUB",
|
||||||
|
"state": "FINISHED",
|
||||||
|
"start_time": 1791674641,
|
||||||
|
"end_time": 1791675294,
|
||||||
|
"to_examine": 5761440929546,
|
||||||
|
"examined": 5761440929546,
|
||||||
|
"skipped": 358629769216,
|
||||||
|
"processed": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes_per_scan": 0,
|
||||||
|
"pass_start": 1791674909,
|
||||||
|
"scrub_pause": 0,
|
||||||
|
"scrub_spent_paused": 0,
|
||||||
|
"issued_bytes_per_scan": 5398602092380,
|
||||||
|
"issued": 5398602092380
|
||||||
|
},
|
||||||
|
"vdevs": {
|
||||||
|
"tank-spin-1": {
|
||||||
|
"name": "tank-spin-1",
|
||||||
|
"vdev_type": "root",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 5662484883046,
|
||||||
|
"total_space": 7850513022320,
|
||||||
|
"def_space": 7850513022320,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"mirror-0": {
|
||||||
|
"name": "mirror-0",
|
||||||
|
"vdev_type": "mirror",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 5662484883046,
|
||||||
|
"total_space": 7850513022320,
|
||||||
|
"def_space": 7850513022320,
|
||||||
|
"rep_dev_size": 7850513022320,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"wwn-0x5000c50000000001-part1": {
|
||||||
|
"name": "wwn-0x5000c50000000001-part1",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-id/wwn-0x5000c50000000001-part1",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 7850513022320,
|
||||||
|
"phys_space": 7861508138598,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
},
|
||||||
|
"wwn-0x5000c50000000002-part1": {
|
||||||
|
"name": "wwn-0x5000c50000000002-part1",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-id/wwn-0x5000c50000000002-part1",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"rep_dev_size": 7850513022320,
|
||||||
|
"phys_space": 7861508138598,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_count": 0,
|
||||||
|
"pool_guid": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"output_version": {
|
||||||
|
"command": "zpool status",
|
||||||
|
"vers_major": 0,
|
||||||
|
"vers_minor": 1
|
||||||
|
},
|
||||||
|
"pools": {
|
||||||
|
"rpool": {
|
||||||
|
"name": "rpool",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"txg": 38071404,
|
||||||
|
"spa_version": 5000,
|
||||||
|
"zpl_version": 5,
|
||||||
|
"scan_stats": {
|
||||||
|
"function": "SCRUB",
|
||||||
|
"state": "FINISHED",
|
||||||
|
"start_time": 1791674641,
|
||||||
|
"end_time": 1791674884,
|
||||||
|
"to_examine": 36167286784,
|
||||||
|
"examined": 36193234944,
|
||||||
|
"skipped": 6365184,
|
||||||
|
"processed": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes_per_scan": 0,
|
||||||
|
"pass_start": 1791909484,
|
||||||
|
"scrub_pause": 0,
|
||||||
|
"scrub_spent_paused": 0,
|
||||||
|
"issued_bytes_per_scan": 0,
|
||||||
|
"issued": 36186869760
|
||||||
|
},
|
||||||
|
"vdevs": {
|
||||||
|
"rpool": {
|
||||||
|
"name": "rpool",
|
||||||
|
"vdev_type": "root",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 37495803904,
|
||||||
|
"total_space": 476741369856,
|
||||||
|
"def_space": 476741369856,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"vdevs": {
|
||||||
|
"/dev/disk/by-id/ata-Patriot_Burst_00000000000000000001-part3": {
|
||||||
|
"name": "/dev/disk/by-id/ata-Patriot_Burst_00000000000000000001-part3",
|
||||||
|
"vdev_type": "disk",
|
||||||
|
"path": "/dev/disk/by-id/ata-Patriot_Burst_00000000000000000001-part3",
|
||||||
|
"class": "normal",
|
||||||
|
"state": "ONLINE",
|
||||||
|
"alloc_space": 37495803904,
|
||||||
|
"total_space": 476741369856,
|
||||||
|
"def_space": 476741369856,
|
||||||
|
"rep_dev_size": 476746088448,
|
||||||
|
"phys_space": 479565971456,
|
||||||
|
"read_errors": 0,
|
||||||
|
"write_errors": 0,
|
||||||
|
"checksum_errors": 0,
|
||||||
|
"slow_ios": 0,
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guid": 1001
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_count": 0,
|
||||||
|
"pool_guid": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""ZFS pool layout parsing for the Storage view; never imports flask_server.
|
||||||
|
|
||||||
|
Fixtures are real `zpool status -j --json-int -P` output from PVE 9 /
|
||||||
|
OpenZFS 2.4.4 hosts, with disk serials and WWNs replaced:
|
||||||
|
- zpool_status_multi_pool.json: mirror, mirror, raidz2, mirror (one member with
|
||||||
|
slow I/Os). Captured without --json-int and normalised to integers.
|
||||||
|
- zpool_status_single_rpool.json: single disk directly under the root vdev.
|
||||||
|
- zpool_status_degraded_raidz1.json: file-backed raidz1 with one member
|
||||||
|
offlined, plus log, cache and spare devices; never scrubbed.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s AppImage/scripts/tests -p test_zfs_pool_details.py -v
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[1] / "flask_server.py"
|
||||||
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
NAMES = {"_zfs_member_disk", "_zfs_errors", "_zfs_device", "_ZFS_PARITY_RE",
|
||||||
|
"_zfs_vdev", "_zfs_pool_details"}
|
||||||
|
|
||||||
|
|
||||||
|
def fake_os(links=None, existing=()):
|
||||||
|
"""Minimal `os` stand-in: realpath via a symlink map, exists via a set."""
|
||||||
|
links = links or {}
|
||||||
|
return SimpleNamespace(path=SimpleNamespace(
|
||||||
|
realpath=lambda p: links.get(p, p),
|
||||||
|
exists=lambda p: p in existing,
|
||||||
|
basename=posixpath.basename,
|
||||||
|
dirname=posixpath.dirname,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def load(os_module=None):
|
||||||
|
tree = ast.parse(SOURCE.read_text())
|
||||||
|
nodes = [n for n in tree.body
|
||||||
|
if (isinstance(n, ast.FunctionDef) and n.name in NAMES)
|
||||||
|
or (isinstance(n, ast.Assign) and any(getattr(t, "id", None) in NAMES for t in n.targets))]
|
||||||
|
found = {n.name if isinstance(n, ast.FunctionDef) else n.targets[0].id for n in nodes}
|
||||||
|
assert found == NAMES, NAMES - found
|
||||||
|
env = {"os": os_module or fake_os(), "re": re}
|
||||||
|
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SOURCE), "exec"), env)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def pools(name):
|
||||||
|
return json.loads((FIXTURES / f"{name}.json").read_text())["pools"]
|
||||||
|
|
||||||
|
|
||||||
|
class ZfsPoolDetailsTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.details = load()["_zfs_pool_details"]
|
||||||
|
|
||||||
|
def test_degraded_raidz1_with_log_cache_and_spare(self):
|
||||||
|
pool = self.details(pools("zpool_status_degraded_raidz1")["pmx-test"])
|
||||||
|
|
||||||
|
self.assertEqual([v["class"] for v in pool["vdevs"]], ["normal", "log", "cache", "spare"])
|
||||||
|
raidz = pool["vdevs"][0]
|
||||||
|
self.assertEqual((raidz["type"], raidz["parity"], raidz["state"]), ("raidz", 1, "DEGRADED"))
|
||||||
|
self.assertEqual([d["state"] for d in raidz["devices"]], ["ONLINE", "OFFLINE", "ONLINE"])
|
||||||
|
self.assertEqual(pool["vdevs"][3]["state"], "AVAIL")
|
||||||
|
self.assertIsNone(pool["scan"])
|
||||||
|
self.assertTrue(pool["status"].startswith("One or more devices has been taken offline"))
|
||||||
|
self.assertNotIn("\t", pool["status"])
|
||||||
|
self.assertNotIn("\n", pool["action"])
|
||||||
|
# File vdevs have no block device behind them
|
||||||
|
self.assertIsNone(raidz["devices"][0]["disk"])
|
||||||
|
|
||||||
|
def test_single_disk_pool_is_a_disk_vdev(self):
|
||||||
|
pool = self.details(pools("zpool_status_single_rpool")["rpool"])
|
||||||
|
|
||||||
|
self.assertEqual(len(pool["vdevs"]), 1)
|
||||||
|
vdev = pool["vdevs"][0]
|
||||||
|
self.assertEqual((vdev["type"], vdev["class"], vdev["parity"]), ("disk", "normal", None))
|
||||||
|
self.assertEqual(len(vdev["devices"]), 1)
|
||||||
|
self.assertEqual(vdev["name"], vdev["devices"][0]["path"])
|
||||||
|
|
||||||
|
def test_finished_scrub_has_no_progress_percentage(self):
|
||||||
|
pool = self.details(pools("zpool_status_single_rpool")["rpool"])
|
||||||
|
|
||||||
|
self.assertEqual((pool["scan"]["function"], pool["scan"]["state"]), ("SCRUB", "FINISHED"))
|
||||||
|
self.assertEqual(pool["scan"]["end_time"], 1791674884)
|
||||||
|
# issued/to_examine is 100.1% for this real scrub; only meaningful while running
|
||||||
|
self.assertIsNone(pool["scan"]["percent"])
|
||||||
|
|
||||||
|
def test_mirror_raidz2_and_slow_ios(self):
|
||||||
|
multi = pools("zpool_status_multi_pool")
|
||||||
|
parsed = {name: self.details(pool) for name, pool in multi.items()}
|
||||||
|
|
||||||
|
self.assertEqual(parsed["tank-rz2"]["vdevs"][0]["parity"], 2)
|
||||||
|
self.assertEqual(len(parsed["tank-rz2"]["vdevs"][0]["devices"]), 5)
|
||||||
|
for name in ("rpool", "tank-m1", "tank-spin-1"):
|
||||||
|
self.assertEqual(parsed[name]["vdevs"][0]["type"], "mirror", name)
|
||||||
|
slow = [d["slow_ios"] for d in parsed["tank-m1"]["vdevs"][0]["devices"]]
|
||||||
|
self.assertEqual(slow, [0, 69])
|
||||||
|
# Member paths come from `path`, not the JSON key (which may drop -partN)
|
||||||
|
paths = [d["path"] for p in parsed.values() for v in p["vdevs"] for d in v["devices"]]
|
||||||
|
self.assertTrue(all(p.startswith("/dev/disk/") for p in paths), paths)
|
||||||
|
|
||||||
|
def test_resilver_with_spare_swapping_in(self):
|
||||||
|
# Synthetic: the degraded fixture with d2 FAULTED and the spare
|
||||||
|
# attached under spare-1, as `zpool status` shows during a resilver.
|
||||||
|
raw = copy.deepcopy(pools("zpool_status_degraded_raidz1")["pmx-test"])
|
||||||
|
members = raw["vdevs"]["pmx-test"]["vdevs"]["raidz1-0"]["vdevs"]
|
||||||
|
failed = members.pop("/root/zfstest/d2.img")
|
||||||
|
failed.update(state="FAULTED", read_errors=12)
|
||||||
|
spare = dict(raw["spares"]["/root/zfstest/d6.img"], state="ONLINE")
|
||||||
|
members["spare-1"] = {"name": "spare-1", "vdev_type": "spare", "state": "DEGRADED",
|
||||||
|
"vdevs": {"d2": failed, "d6": spare}}
|
||||||
|
raw["spares"]["/root/zfstest/d6.img"]["state"] = "INUSE"
|
||||||
|
raw["scan_stats"] = {"function": "RESILVER", "state": "SCANNING", "start_time": 1,
|
||||||
|
"end_time": 0, "to_examine": 1000, "issued": 425, "errors": 0,
|
||||||
|
"processed": 0}
|
||||||
|
|
||||||
|
pool = self.details(raw)
|
||||||
|
|
||||||
|
devices = pool["vdevs"][0]["devices"]
|
||||||
|
self.assertEqual([d["via"] for d in devices], [None, None, "spare-1", "spare-1"])
|
||||||
|
self.assertEqual(devices[2]["read_errors"], 12)
|
||||||
|
self.assertEqual(pool["scan"]["percent"], 42.5)
|
||||||
|
self.assertEqual(pool["vdevs"][-1]["state"], "INUSE")
|
||||||
|
|
||||||
|
def test_output_is_json_serialisable_without_guids(self):
|
||||||
|
for name, pool in pools("zpool_status_multi_pool").items():
|
||||||
|
text = json.dumps(self.details(pool))
|
||||||
|
self.assertNotIn("guid", text, name)
|
||||||
|
|
||||||
|
|
||||||
|
class ZfsMemberDiskTests(unittest.TestCase):
|
||||||
|
def test_partition_resolves_to_parent_disk(self):
|
||||||
|
member_disk = load(fake_os(
|
||||||
|
links={"/dev/disk/by-partlabel/ssd-t1": "/dev/sdf1",
|
||||||
|
"/sys/class/block/sdf1": "/sys/devices/pci0000:00/0000:02:00.1/ata1/host0/target0:0:0/0:0:0:0/block/sdf/sdf1"},
|
||||||
|
existing={"/sys/class/block/sdf1/partition"},
|
||||||
|
))["_zfs_member_disk"]
|
||||||
|
self.assertEqual(member_disk("/dev/disk/by-partlabel/ssd-t1"), "sdf")
|
||||||
|
|
||||||
|
def test_nvme_partition_resolves_to_namespace(self):
|
||||||
|
member_disk = load(fake_os(
|
||||||
|
links={"/dev/disk/by-id/nvme-X-part3": "/dev/nvme0n1p3",
|
||||||
|
"/sys/class/block/nvme0n1p3": "/sys/devices/pci0000:00/0000:08:00.0/nvme/nvme0/nvme0n1/nvme0n1p3"},
|
||||||
|
existing={"/sys/class/block/nvme0n1p3/partition"},
|
||||||
|
))["_zfs_member_disk"]
|
||||||
|
self.assertEqual(member_disk("/dev/disk/by-id/nvme-X-part3"), "nvme0n1")
|
||||||
|
|
||||||
|
def test_whole_disk_member(self):
|
||||||
|
member_disk = load(fake_os(
|
||||||
|
links={"/dev/disk/by-id/wwn-0x1": "/dev/sdj"},
|
||||||
|
existing={"/sys/class/block/sdj"},
|
||||||
|
))["_zfs_member_disk"]
|
||||||
|
self.assertEqual(member_disk("/dev/disk/by-id/wwn-0x1"), "sdj")
|
||||||
|
|
||||||
|
def test_missing_device_and_file_vdev_give_none(self):
|
||||||
|
member_disk = load(fake_os())["_zfs_member_disk"]
|
||||||
|
self.assertIsNone(member_disk("/dev/disk/by-id/gone"))
|
||||||
|
self.assertIsNone(member_disk("/root/zfstest/d1.img"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user