mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
fix(monitor): place the Secure Gateway template on an active storage
This commit is contained in:
@@ -18,7 +18,6 @@ interface DiskEntry {
|
|||||||
rotational: boolean
|
rotational: boolean
|
||||||
excluded: boolean
|
excluded: boolean
|
||||||
excluded_at: string | null
|
excluded_at: string | null
|
||||||
idle: boolean
|
|
||||||
present: boolean
|
present: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +205,6 @@ export function DiskExclusions() {
|
|||||||
{t("settings.diskExclusions.excluded")}
|
{t("settings.diskExclusions.excluded")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!excluded && disk.idle && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
|
|
||||||
title={t("storage.idleTitle")}
|
|
||||||
>
|
|
||||||
{t("storage.idle")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!disk.present && (
|
{!disk.present && (
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
|
||||||
{t("settings.diskExclusions.notConnected")}
|
{t("settings.diskExclusions.notConnected")}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, Moon, EyeOff } from "lucide-react"
|
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, EyeOff } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
@@ -71,7 +71,6 @@ interface DiskInfo {
|
|||||||
// badge AND to suppress the (stale) temperature value, so the
|
// badge AND to suppress the (stale) temperature value, so the
|
||||||
// operator understands the graph is frozen on purpose — issue #232.
|
// operator understands the graph is frozen on purpose — issue #232.
|
||||||
standby?: boolean
|
standby?: boolean
|
||||||
idle?: boolean
|
|
||||||
excluded?: boolean
|
excluded?: boolean
|
||||||
health: string
|
health: string
|
||||||
power_on_hours?: number
|
power_on_hours?: number
|
||||||
@@ -303,6 +302,24 @@ function groupDisksBySlot(disks: DiskInfo[]): [string | null, DiskInfo[]][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
|
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
|
||||||
|
// Same ring as the disk wear gauge, with a faint track so 0% still reads.
|
||||||
|
// Used for the fragmentation of a ZFS pool.
|
||||||
|
function PercentRing({ value, color, label }: { value: number; color: string; label: string }) {
|
||||||
|
const pct = Math.max(0, Math.min(100, value))
|
||||||
|
return (
|
||||||
|
<svg width="88" height="88" viewBox="0 0 88 88" className="flex-shrink-0">
|
||||||
|
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeOpacity="0.15" strokeWidth="6" />
|
||||||
|
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeWidth="6"
|
||||||
|
strokeDasharray={`${pct * 2.199} 219.9`}
|
||||||
|
strokeLinecap="round" transform="rotate(-90 44 44)" />
|
||||||
|
<text x="44" y="40" textAnchor="middle" fill={color} fontSize="20" fontWeight="700">{pct}%</text>
|
||||||
|
<text x="44" y="56" textAnchor="middle" fill="currentColor" fontSize="12" className="text-muted-foreground">{label}</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zfsFragmentationColor = (pct: number) => (pct > 80 ? "#ef4444" : pct > 50 ? "#eab308" : "#06b6d4")
|
||||||
|
|
||||||
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
|
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
|
|
||||||
@@ -332,6 +349,12 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
// 85 vh capped by the visual viewport otherwise). Keeps every
|
// 85 vh capped by the visual viewport otherwise). Keeps every
|
||||||
// detail modal at a matching size across the app.
|
// detail modal at a matching size across the app.
|
||||||
const [isStandalone, setIsStandalone] = useState(false)
|
const [isStandalone, setIsStandalone] = useState(false)
|
||||||
|
// Disk, ZFS pool and remote mount details share one size.
|
||||||
|
const detailDialogClass = `max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||||
|
isStandalone
|
||||||
|
? "h-[95vh] sm:h-[90vh]"
|
||||||
|
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||||
|
}`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkStandalone = () => {
|
const checkStandalone = () => {
|
||||||
@@ -561,7 +584,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
// in 4 different disk-list views (system / data / pool / other) and we
|
// in 4 different disk-list views (system / data / pool / other) and we
|
||||||
// want them all to behave identically — issue #232 fix.
|
// want them all to behave identically — issue #232 fix.
|
||||||
// Why a disk shows no live temperature, when it doesn't: excluded by the
|
// Why a disk shows no live temperature, when it doesn't: excluded by the
|
||||||
// user, parked, or idle and deliberately not read. Shared by every view
|
// user, or parked. Shared by every view
|
||||||
// that paints a disk's temperature, so they cannot disagree.
|
// that paints a disk's temperature, so they cannot disagree.
|
||||||
const renderNoReadingBadge = (disk: DiskInfo) => {
|
const renderNoReadingBadge = (disk: DiskInfo) => {
|
||||||
if (disk.excluded) {
|
if (disk.excluded) {
|
||||||
@@ -586,17 +609,6 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (disk.idle) {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className="bg-muted text-muted-foreground border-border gap-1"
|
|
||||||
title={t("storage.idleTitle")}
|
|
||||||
>
|
|
||||||
<Moon className="h-3 w-3" />
|
|
||||||
{t("storage.idle")}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,7 +1112,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
|
|
||||||
storageData.disks.forEach((disk) => {
|
storageData.disks.forEach((disk) => {
|
||||||
// Match disk rows: policy-suppressed cached readings are not live temperatures.
|
// Match disk rows: policy-suppressed cached readings are not live temperatures.
|
||||||
if (disk.standby || disk.idle || disk.excluded || typeof disk.temperature !== "number" ||
|
if (disk.standby || disk.excluded || typeof disk.temperature !== "number" ||
|
||||||
!Number.isFinite(disk.temperature) || disk.temperature <= 0) {
|
!Number.isFinite(disk.temperature) || disk.temperature <= 0) {
|
||||||
unavailable++
|
unavailable++
|
||||||
return
|
return
|
||||||
@@ -1735,7 +1747,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
overlay with text-xs/text-[10px] all over and looked
|
overlay with text-xs/text-[10px] all over and looked
|
||||||
shrunken next to the rest of the modals. */}
|
shrunken next to the rest of the modals. */}
|
||||||
<Dialog open={!!mountDetail} onOpenChange={(open) => { if (!open) setMountDetail(null) }}>
|
<Dialog open={!!mountDetail} onOpenChange={(open) => { if (!open) setMountDetail(null) }}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
|
<DialogContent className={detailDialogClass}>
|
||||||
{mountDetail && (() => {
|
{mountDetail && (() => {
|
||||||
const m = mountDetail
|
const m = mountDetail
|
||||||
const isStale = m.status === "stale"
|
const isStale = m.status === "stale"
|
||||||
@@ -1782,7 +1794,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="px-6 pb-6 overflow-auto space-y-5">
|
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto space-y-5">
|
||||||
{/* Type + tags */}
|
{/* Type + tags */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
|
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
|
||||||
@@ -1895,7 +1907,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
{storageData.zfs_pools.map((pool) => (
|
{storageData.zfs_pools.map((pool) => (
|
||||||
<div
|
<div
|
||||||
key={pool.name}
|
key={pool.name}
|
||||||
className="border rounded-lg p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
className="border border-white/10 rounded-lg p-4 cursor-pointer bg-card hover:bg-white/5 transition-colors"
|
||||||
onClick={() => setSelectedPool(pool)}
|
onClick={() => setSelectedPool(pool)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -1903,7 +1915,10 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
<h3 className="font-semibold text-lg">{pool.name}</h3>
|
<h3 className="font-semibold text-lg">{pool.name}</h3>
|
||||||
{getHealthBadge(pool.health)}
|
{getHealthBadge(pool.health)}
|
||||||
</div>
|
</div>
|
||||||
{getHealthIcon(pool.health)}
|
<div className="flex items-center gap-2">
|
||||||
|
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
|
||||||
|
{getHealthIcon(pool.health)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{pool.details_available && (() => {
|
{pool.details_available && (() => {
|
||||||
const dataDevices = (pool.vdevs ?? [])
|
const dataDevices = (pool.vdevs ?? [])
|
||||||
@@ -1930,6 +1945,18 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
{pool.capacity != null && (
|
||||||
|
<Progress
|
||||||
|
value={pool.capacity}
|
||||||
|
className={`h-2 mb-2 ${
|
||||||
|
pool.capacity > 90
|
||||||
|
? "[&>div]:bg-red-500"
|
||||||
|
: pool.capacity > 75
|
||||||
|
? "[&>div]:bg-yellow-500"
|
||||||
|
: "[&>div]:bg-blue-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<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>
|
||||||
@@ -1954,7 +1981,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
{/* ZFS Pool Details Dialog: re-resolved from storageData so it follows
|
{/* ZFS Pool Details Dialog: re-resolved from storageData so it follows
|
||||||
the background refresh while open (e.g. resilver progress). */}
|
the background refresh while open (e.g. resilver progress). */}
|
||||||
<Dialog open={selectedPool !== null} onOpenChange={(open) => { if (!open) setSelectedPool(null) }}>
|
<Dialog open={selectedPool !== null} onOpenChange={(open) => { if (!open) setSelectedPool(null) }}>
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
<DialogContent className={detailDialogClass}>
|
||||||
{selectedPool && (() => {
|
{selectedPool && (() => {
|
||||||
const pool = storageData.zfs_pools.find((p) => p.name === selectedPool.name) ?? selectedPool
|
const pool = storageData.zfs_pools.find((p) => p.name === selectedPool.name) ?? selectedPool
|
||||||
const classOrder: ZFSVdev["class"][] = ["normal", "special", "dedup", "log", "cache", "spare"]
|
const classOrder: ZFSVdev["class"][] = ["normal", "special", "dedup", "log", "cache", "spare"]
|
||||||
@@ -1964,7 +1991,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
const showSlowIos = vdevs.some((v) => v.devices.some((d) => d.slow_ios > 0))
|
const showSlowIos = vdevs.some((v) => v.devices.some((d) => d.slow_ios > 0))
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader className="px-6 pt-6 pb-2">
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<DialogTitle className="flex items-center gap-3">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5" />
|
||||||
{pool.name}
|
{pool.name}
|
||||||
@@ -1973,7 +2000,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
<DialogDescription>{t("storage.zfs.poolDetails")}</DialogDescription>
|
<DialogDescription>{t("storage.zfs.poolDetails")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto flex flex-col gap-5">
|
||||||
{(pool.status || pool.action) && (
|
{(pool.status || pool.action) && (
|
||||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm space-y-1">
|
<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.status && <p><span className="font-medium">{t("storage.zfs.status")}:</span> {pool.status}</p>}
|
||||||
@@ -1981,35 +2008,57 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div className="rounded-lg border border-border p-4 flex flex-col justify-center gap-3">
|
||||||
<p className="text-muted-foreground">{t("storage.size")}</p>
|
<div className="flex items-end justify-between gap-3">
|
||||||
<p className="font-medium">{pool.size}</p>
|
<div>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">{t("storage.free")}</p>
|
||||||
<div>
|
<p className="text-lg font-semibold">{pool.free}</p>
|
||||||
<p className="text-muted-foreground">{t("storage.allocated")}</p>
|
</div>
|
||||||
<p className="font-medium">{pool.allocated}</p>
|
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
|
||||||
</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>
|
</div>
|
||||||
)}
|
{pool.capacity != null && (
|
||||||
{pool.fragmentation != null && (
|
<Progress
|
||||||
<div>
|
value={pool.capacity}
|
||||||
<p className="text-muted-foreground">{t("storage.zfs.fragmentation")}</p>
|
className={`h-2 ${
|
||||||
<p className="font-medium">{pool.fragmentation}%</p>
|
pool.capacity > 90
|
||||||
|
? "[&>div]:bg-red-500"
|
||||||
|
: pool.capacity > 75
|
||||||
|
? "[&>div]:bg-yellow-500"
|
||||||
|
: "[&>div]:bg-blue-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("storage.allocated")}</p>
|
||||||
|
<p className="font-medium">{pool.allocated}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-muted-foreground">{t("storage.zfs.totalSize")}</p>
|
||||||
|
<p className="font-medium">{pool.size}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{pool.dedup_ratio != null && (
|
{(pool.fragmentation != null || pool.dedup_ratio != null) && (
|
||||||
<div>
|
<div className="rounded-lg border border-border p-4 flex items-center gap-5">
|
||||||
<p className="text-muted-foreground">{t("storage.zfs.dedupRatio")}</p>
|
{pool.fragmentation != null && (
|
||||||
<p className="font-medium">{pool.dedup_ratio.toFixed(2)}x</p>
|
<PercentRing value={pool.fragmentation} color={zfsFragmentationColor(pool.fragmentation)} label={t("storage.zfs.fragShort")} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1 space-y-3 text-sm">
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2019,7 +2068,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Scrub / resilver */}
|
{/* Scrub / resilver */}
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
{t("storage.zfs.scanTitle")}
|
{t("storage.zfs.scanTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -2054,16 +2103,14 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Layout: one block per top-level vdev */}
|
{/* Layout: one block per top-level vdev; the last one
|
||||||
<div className="space-y-3">
|
reaches the bottom of the dialog. */}
|
||||||
|
<div className="flex-1 flex flex-col gap-3">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
{t("storage.zfs.layout")}
|
{t("storage.zfs.layout")}
|
||||||
{getZfsLayoutSummary(pool) && (
|
|
||||||
<span className="ml-2 normal-case font-normal text-foreground">{getZfsLayoutSummary(pool)}</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
</h3>
|
||||||
{vdevs.map((vdev, i) => (
|
{vdevs.map((vdev, i) => (
|
||||||
<div key={`${vdev.class}-${vdev.name}-${i}`} className="border rounded-lg">
|
<div key={`${vdev.class}-${vdev.name}-${i}`} className={`border rounded-lg ${i === vdevs.length - 1 ? "flex-1" : ""}`}>
|
||||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b bg-muted/30">
|
<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>
|
<Badge variant="outline" className="text-xs">{t(`storage.zfs.classes.${vdev.class}`)}</Badge>
|
||||||
<span className="text-sm font-medium">{getZfsVdevLabel(vdev)}</span>
|
<span className="text-sm font-medium">{getZfsVdevLabel(vdev)}</span>
|
||||||
@@ -2219,13 +2266,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
|||||||
setSmartJsonData(null)
|
setSmartJsonData(null)
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<DialogContent
|
<DialogContent className={detailDialogClass}>
|
||||||
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
|
|
||||||
isStandalone
|
|
||||||
? "h-[95vh] sm:h-[90vh]"
|
|
||||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<DialogHeader className="px-6 pt-6 pb-0">
|
<DialogHeader className="px-6 pt-6 pb-0">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{selectedDisk?.connection_type === 'usb' ? (
|
{selectedDisk?.connection_type === 'usb' ? (
|
||||||
|
|||||||
@@ -322,6 +322,8 @@
|
|||||||
"fragmentation": "Fragmentation",
|
"fragmentation": "Fragmentation",
|
||||||
"dedupRatio": "Dedup Ratio",
|
"dedupRatio": "Dedup Ratio",
|
||||||
"layout": "Layout",
|
"layout": "Layout",
|
||||||
|
"totalSize": "Total size",
|
||||||
|
"fragShort": "Frag.",
|
||||||
"singleDisk": "Single disk",
|
"singleDisk": "Single disk",
|
||||||
"stripe": "Stripe",
|
"stripe": "Stripe",
|
||||||
"mirror": "Mirror",
|
"mirror": "Mirror",
|
||||||
|
|||||||
@@ -369,8 +369,8 @@
|
|||||||
"notApplicable": "n / A",
|
"notApplicable": "n / A",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
"standby": "En reposo",
|
"standby": "En espera",
|
||||||
"standbyTitle": "La unidad está en espera: se omitió smartctl para mantenerla apagada",
|
"standbyTitle": "El disco está parado: no se le envía nada para no despertarlo",
|
||||||
"filesystemCorruption": "Se detectó corrupción en el sistema de archivos",
|
"filesystemCorruption": "Se detectó corrupción en el sistema de archivos",
|
||||||
"ioErrorOne": "{count} Error de E/S en 5 minutos",
|
"ioErrorOne": "{count} Error de E/S en 5 minutos",
|
||||||
"ioErrorMany": "{count} Errores de E/S en 5 minutos",
|
"ioErrorMany": "{count} Errores de E/S en 5 minutos",
|
||||||
@@ -983,53 +983,55 @@
|
|||||||
"temperatureUnavailableCount": "No disponible: {count}",
|
"temperatureUnavailableCount": "No disponible: {count}",
|
||||||
"zfs": {
|
"zfs": {
|
||||||
"classes": {
|
"classes": {
|
||||||
"normal": "datos",
|
"normal": "Datos",
|
||||||
"special": "Especial",
|
"special": "Especial",
|
||||||
"dedup": "Deduplicación",
|
"dedup": "Dedup",
|
||||||
"log": "Iniciar sesión",
|
"log": "Log",
|
||||||
"cache": "caché",
|
"cache": "Caché",
|
||||||
"spare": "Repuesto"
|
"spare": "Repuesto"
|
||||||
},
|
},
|
||||||
"poolDetails": "diseño de la piscina, estado de limpieza y estado del dispositivo",
|
"poolDetails": "Estructura del pool, estado del scrub y salud de los dispositivos",
|
||||||
"detailsUnavailable": "El diseño detallado del grupo requiere OpenZFS 2.3 o posterior.",
|
"detailsUnavailable": "La estructura detallada del pool requiere OpenZFS 2.3 o posterior.",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"action": "Acción",
|
"action": "Acción",
|
||||||
"capacity": "Capacidad",
|
"capacity": "Capacidad",
|
||||||
"fragmentation": "Fragmentación",
|
"fragmentation": "Fragmentación",
|
||||||
"dedupRatio": "Relación de deduplicación",
|
"dedupRatio": "Ratio de deduplicación",
|
||||||
"layout": "Diseño",
|
"layout": "Estructura",
|
||||||
"singleDisk": "disco único",
|
"totalSize": "Tamaño total",
|
||||||
"stripe": "Raya",
|
"fragShort": "Frag.",
|
||||||
|
"singleDisk": "Disco único",
|
||||||
|
"stripe": "Stripe",
|
||||||
"mirror": "Espejo",
|
"mirror": "Espejo",
|
||||||
"raidz": "RAIDZ{parity}",
|
"raidz": "RAIDZ{parity}",
|
||||||
"draid": "dRAID{parity}",
|
"draid": "dRAID{parity}",
|
||||||
"diskCountOne": "disco {count}",
|
"diskCountOne": "{count} disco",
|
||||||
"diskCountMany": "discos {count}",
|
"diskCountMany": "{count} discos",
|
||||||
"errorsReported": "Errores reportados",
|
"errorsReported": "Errores detectados",
|
||||||
"scanTitle": "Scrub / Resilver",
|
"scanTitle": "Scrub / Resilver",
|
||||||
"scrub": "fregar",
|
"scrub": "Scrub",
|
||||||
"resilver": "Resilver",
|
"resilver": "Resilver",
|
||||||
"neverScrubbed": "Nunca fregado",
|
"neverScrubbed": "Sin scrub todavía",
|
||||||
"scanRunning": "{kind} en progreso ({percent}%)",
|
"scanRunning": "{kind} en curso ({percent}%)",
|
||||||
"scanCanceled": "{kind} cancelado",
|
"scanCanceled": "{kind} cancelado",
|
||||||
"scanFinished": "{kind} terminado {date}",
|
"scanFinished": "{kind} terminado {date}",
|
||||||
"started": "iniciado",
|
"started": "Inicio",
|
||||||
"ended": "Terminado",
|
"ended": "Fin",
|
||||||
"repaired": "reparado",
|
"repaired": "Reparado",
|
||||||
"scanErrors": "errores de escaneo",
|
"scanErrors": "Errores del análisis",
|
||||||
"dataErrors": "errores de datos {count}",
|
"dataErrors": "{count} errores de datos",
|
||||||
"noDataErrors": "No hay errores de datos conocidos",
|
"noDataErrors": "No hay errores de datos conocidos",
|
||||||
"device": "Dispositivo",
|
"device": "Dispositivo",
|
||||||
"disk": "Disco",
|
"disk": "Disco",
|
||||||
"state": "Estado",
|
"state": "Estado",
|
||||||
"readErrors": "errores de lectura",
|
"readErrors": "Errores de lectura",
|
||||||
"writeErrors": "errores de escritura",
|
"writeErrors": "Errores de escritura",
|
||||||
"checksumErrors": "errores de suma de comprobación",
|
"checksumErrors": "Errores de checksum",
|
||||||
"readShort": "R",
|
"readShort": "R",
|
||||||
"writeShort": "W",
|
"writeShort": "W",
|
||||||
"checksumShort": "CK",
|
"checksumShort": "CK",
|
||||||
"slowIos": "E/S lentas",
|
"slowIos": "E/S lentas",
|
||||||
"via": "vía {name}"
|
"via": "a través de {name}"
|
||||||
},
|
},
|
||||||
"storageController": "Controlador de almacenamiento",
|
"storageController": "Controlador de almacenamiento",
|
||||||
"noController": "No se detectó ningún controlador",
|
"noController": "No se detectó ningún controlador",
|
||||||
|
|||||||
@@ -410,60 +410,16 @@ def _extract_temperature(data: dict[str, Any]) -> Optional[float]:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# ── Leaving idle and excluded disks alone ──────────────────────────
|
# ── Leaving excluded disks alone ─────────────────────────────────
|
||||||
#
|
#
|
||||||
# `-n standby` keeps a periodic reader from waking a disk that is asleep.
|
# A disk the user excludes in Settings receives nothing from a periodic
|
||||||
# It does not let an awake one fall asleep: a drive's spin-down timer
|
# reader, not even the power-mode question, so it can spin down on its own
|
||||||
# counts time without commands, and a read every minute resets it, so an
|
# timer. Every other disk is read on schedule: `-n standby` still keeps a
|
||||||
# unused drive whose timer is longer than that never spins down — and a
|
# reader from waking one that is already asleep.
|
||||||
# drive that parks its heads when idle loads them again on every read.
|
|
||||||
#
|
|
||||||
# So a rotational disk with no I/O since it was last looked at is not read
|
|
||||||
# at all, not even asked for its power mode. The counters come from
|
|
||||||
# /proc/diskstats, which costs no disk access, and a SMART query does not
|
|
||||||
# move them — passthrough commands are not accounted as reads or writes —
|
|
||||||
# so any change means something else used the disk. A disk in use is
|
|
||||||
# already awake, and reading it then costs nothing. Solid-state disks have
|
|
||||||
# no spindle and no heads, and keep their reading.
|
|
||||||
#
|
|
||||||
# A disk seen for the first time is read once, so a Monitor that has just
|
|
||||||
# started still has values to show; after that it is left alone for as
|
|
||||||
# long as nothing uses it.
|
|
||||||
|
|
||||||
READ = "read"
|
READ = "read"
|
||||||
IDLE = "idle"
|
|
||||||
EXCLUDED = "excluded"
|
EXCLUDED = "excluded"
|
||||||
|
|
||||||
_last_io: dict[str, tuple[int, int]] = {}
|
|
||||||
_idle_state: dict[str, float] = {}
|
|
||||||
_IDLE_TTL = 600 # same horizon as the standby badge
|
|
||||||
|
|
||||||
|
|
||||||
def _read_diskstats() -> dict[str, tuple[int, int]]:
|
|
||||||
"""Reads and writes completed per device, from /proc/diskstats."""
|
|
||||||
out: dict[str, tuple[int, int]] = {}
|
|
||||||
try:
|
|
||||||
with open("/proc/diskstats") as f:
|
|
||||||
for line in f:
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) < 8:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
out[parts[2]] = (int(parts[3]), int(parts[7]))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _is_rotational(disk_name: str) -> bool:
|
|
||||||
try:
|
|
||||||
with open(f"/sys/block/{disk_name}/queue/rotational") as f:
|
|
||||||
return f.read().strip() == "1"
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
_EXCLUDED_TTL = 15
|
_EXCLUDED_TTL = 15
|
||||||
_excluded_cache: Optional[tuple[float, set]] = None
|
_excluded_cache: Optional[tuple[float, set]] = None
|
||||||
@@ -503,50 +459,20 @@ _excluded_disk_names = excluded_disk_names
|
|||||||
|
|
||||||
|
|
||||||
def disk_read_policy(disk_name: str, excluded: Optional[set] = None) -> str:
|
def disk_read_policy(disk_name: str, excluded: Optional[set] = None) -> str:
|
||||||
"""Whether a periodic reader may touch this disk now: READ, IDLE or
|
"""Whether a periodic reader may touch this disk: READ or EXCLUDED.
|
||||||
EXCLUDED. Shared by every reader that runs on its own, so the
|
Shared by every reader that runs on its own, so the temperature poller
|
||||||
temperature poller and the storage view cannot disagree about a disk.
|
and the storage view cannot disagree about a disk. ``excluded`` lets a
|
||||||
``excluded`` lets a caller that checks many disks pass the list once."""
|
caller that checks many disks pass the list once."""
|
||||||
if excluded is None:
|
if excluded is None:
|
||||||
excluded = _excluded_disk_names()
|
excluded = _excluded_disk_names()
|
||||||
if disk_name in excluded:
|
return EXCLUDED if disk_name in excluded else READ
|
||||||
_idle_state.pop(disk_name, None)
|
|
||||||
return EXCLUDED
|
|
||||||
if not _is_rotational(disk_name):
|
|
||||||
return READ
|
|
||||||
current = _read_diskstats().get(disk_name)
|
|
||||||
with _cache_lock:
|
|
||||||
previous = _last_io.get(disk_name)
|
|
||||||
if current is not None:
|
|
||||||
_last_io[disk_name] = current
|
|
||||||
if current is None or previous is None or current != previous:
|
|
||||||
_idle_state.pop(disk_name, None)
|
|
||||||
return READ
|
|
||||||
_idle_state[disk_name] = time.time()
|
|
||||||
return IDLE
|
|
||||||
|
|
||||||
|
|
||||||
def settle_own_reads(disk_names) -> None:
|
|
||||||
"""A reader that has just read these disks itself takes their counters as
|
|
||||||
the new baseline, so its own I/O never counts as activity."""
|
|
||||||
stats = _read_diskstats()
|
|
||||||
with _cache_lock:
|
|
||||||
for name in disk_names:
|
|
||||||
if name in _last_io and name in stats:
|
|
||||||
_last_io[name] = stats[name]
|
|
||||||
|
|
||||||
|
|
||||||
def is_disk_idle(disk_name: str) -> bool:
|
|
||||||
"""True while the disk is being left alone for having no I/O."""
|
|
||||||
ts = _idle_state.get(disk_name)
|
|
||||||
return ts is not None and (time.time() - ts) < _IDLE_TTL
|
|
||||||
|
|
||||||
|
|
||||||
def record_all_disk_temperatures() -> int:
|
def record_all_disk_temperatures() -> int:
|
||||||
"""Sample the disks that may be read now and persist their temperature.
|
"""Sample the disks that may be read now and persist their temperature.
|
||||||
|
|
||||||
USB disks are included. A disk the user excluded, or a rotational one
|
USB disks are included. A disk the user excluded is skipped — see
|
||||||
with no I/O since the last cycle, is skipped — see ``disk_read_policy``.
|
``disk_read_policy``.
|
||||||
|
|
||||||
Sampling fans out across a thread pool so a host with N disks pays
|
Sampling fans out across a thread pool so a host with N disks pays
|
||||||
roughly the time of the slowest single ``smartctl`` call instead of
|
roughly the time of the slowest single ``smartctl`` call instead of
|
||||||
|
|||||||
@@ -807,7 +807,6 @@ def get_disks_for_exclusion():
|
|||||||
**disk,
|
**disk,
|
||||||
'excluded': entry is not None,
|
'excluded': entry is not None,
|
||||||
'excluded_at': entry.get('excluded_at') if entry else None,
|
'excluded_at': entry.get('excluded_at') if entry else None,
|
||||||
'idle': _dth.is_disk_idle(disk['name']),
|
|
||||||
'present': True,
|
'present': True,
|
||||||
})
|
})
|
||||||
# An excluded disk that is not connected right now — an unplugged USB
|
# An excluded disk that is not connected right now — an unplugged USB
|
||||||
@@ -826,7 +825,6 @@ def get_disks_for_exclusion():
|
|||||||
'rotational': False,
|
'rotational': False,
|
||||||
'excluded': True,
|
'excluded': True,
|
||||||
'excluded_at': entry.get('excluded_at'),
|
'excluded_at': entry.get('excluded_at'),
|
||||||
'idle': False,
|
|
||||||
'present': False,
|
'present': False,
|
||||||
})
|
})
|
||||||
result.sort(key=lambda d: (not d['present'], d['name']))
|
result.sort(key=lambda d: (not d['present'], d['name']))
|
||||||
|
|||||||
@@ -4418,9 +4418,6 @@ def get_system_disks():
|
|||||||
['pvs', *dev_args, '--noheadings', '-o', 'pv_name,vg_name'],
|
['pvs', *dev_args, '--noheadings', '-o', 'pv_name,vg_name'],
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
) if dev_args is not None else None
|
) if dev_args is not None else None
|
||||||
if result is not None:
|
|
||||||
from lvm_device_scope import settle_scan_reads
|
|
||||||
settle_scan_reads()
|
|
||||||
if result is not None and result.returncode == 0:
|
if result is not None and result.returncode == 0:
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
if line.strip():
|
if line.strip():
|
||||||
@@ -4558,12 +4555,10 @@ def get_storage_info():
|
|||||||
# temperature graph isn't a monitor bug — the
|
# temperature graph isn't a monitor bug — the
|
||||||
# disk is parked. See issue #232.
|
# disk is parked. See issue #232.
|
||||||
in_standby = False
|
in_standby = False
|
||||||
in_idle = False
|
|
||||||
in_excluded = False
|
in_excluded = False
|
||||||
try:
|
try:
|
||||||
import disk_temperature_history as _dth
|
import disk_temperature_history as _dth
|
||||||
in_standby = _dth.is_disk_in_standby(disk_name)
|
in_standby = _dth.is_disk_in_standby(disk_name)
|
||||||
in_idle = _dth.is_disk_idle(disk_name)
|
|
||||||
in_excluded = disk_name in _dth.excluded_disk_names()
|
in_excluded = disk_name in _dth.excluded_disk_names()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -4574,7 +4569,6 @@ def get_storage_info():
|
|||||||
'size_bytes': disk_size_bytes,
|
'size_bytes': disk_size_bytes,
|
||||||
'temperature': smart_data.get('temperature', 0),
|
'temperature': smart_data.get('temperature', 0),
|
||||||
'standby': in_standby,
|
'standby': in_standby,
|
||||||
'idle': in_idle,
|
|
||||||
'excluded': in_excluded,
|
'excluded': in_excluded,
|
||||||
'health': smart_data.get('health', 'unknown'),
|
'health': smart_data.get('health', 'unknown'),
|
||||||
'power_on_hours': smart_data.get('power_on_hours', 0),
|
'power_on_hours': smart_data.get('power_on_hours', 0),
|
||||||
@@ -5305,28 +5299,19 @@ def get_smart_data(disk_name):
|
|||||||
if cached and now - cached[0] < _SMART_RESULT_TTL:
|
if cached and now - cached[0] < _SMART_RESULT_TTL:
|
||||||
return dict(cached[1])
|
return dict(cached[1])
|
||||||
|
|
||||||
# Excluded, or rotational with no I/O since it was last looked at: send
|
# Excluded in Settings: send it nothing — not even the power-mode
|
||||||
# it nothing — not even the power-mode question below, which is still a
|
# question below, which is still a command. Serve what is known, without
|
||||||
# command. Serve what is known, without a temperature that would only be
|
# a temperature that would only be stale. Same rule as the temperature
|
||||||
# stale. Same rule as the temperature poller, so the two agree.
|
# poller, so the two agree.
|
||||||
try:
|
try:
|
||||||
import disk_temperature_history as _dth
|
import disk_temperature_history as _dth
|
||||||
policy = _dth.disk_read_policy(disk_name)
|
policy = _dth.disk_read_policy(disk_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
policy = 'read'
|
policy = 'read'
|
||||||
if policy != 'read':
|
if policy != 'read':
|
||||||
if not cached and policy == 'idle' and not _hdd_in_standby(disk_name):
|
|
||||||
# Nothing is known of it yet, as after a restart: a disk that is
|
|
||||||
# spinning is read once; a parked one is not woken.
|
|
||||||
result = _get_smart_data_uncached(disk_name)
|
|
||||||
_dth.settle_own_reads([disk_name])
|
|
||||||
if _smart_data_useful(result):
|
|
||||||
_smart_result_cache[disk_name] = (now, dict(result))
|
|
||||||
cached = _smart_result_cache[disk_name]
|
|
||||||
base = dict(cached[1]) if cached else _smart_identity_payload(disk_name)
|
base = dict(cached[1]) if cached else _smart_identity_payload(disk_name)
|
||||||
base['temperature'] = 0
|
base['temperature'] = 0
|
||||||
base['excluded'] = policy == 'excluded'
|
base['excluded'] = True
|
||||||
base['idle'] = policy == 'idle'
|
|
||||||
return base
|
return base
|
||||||
|
|
||||||
if _hdd_in_standby(disk_name):
|
if _hdd_in_standby(disk_name):
|
||||||
|
|||||||
@@ -2288,9 +2288,6 @@ class HealthMonitor:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=3
|
timeout=3
|
||||||
)
|
)
|
||||||
from lvm_device_scope import settle_scan_reads
|
|
||||||
settle_scan_reads()
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return {'status': 'WARNING', 'reason': 'lvs command failed'}
|
return {'status': 'WARNING', 'reason': 'lvs command failed'}
|
||||||
|
|
||||||
@@ -2314,7 +2311,6 @@ class HealthMonitor:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=3
|
timeout=3
|
||||||
)
|
)
|
||||||
settle_scan_reads()
|
|
||||||
if vg_result.returncode == 0 and vg_result.stdout.strip():
|
if vg_result.returncode == 0 and vg_result.stdout.strip():
|
||||||
return {'status': 'WARNING', 'reason': 'No active LVM volumes detected'}
|
return {'status': 'WARNING', 'reason': 'No active LVM volumes detected'}
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -9,20 +9,17 @@
|
|||||||
# Last Updated: 16/10/2026
|
# Last Updated: 16/10/2026
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
# Description:
|
# Description:
|
||||||
# Applies the automatic disk-read policy to LVM metadata scans in both
|
# Applies the disk exclusions to LVM metadata scans in both Monitor
|
||||||
# Monitor readers. Excluded and idle disks receive no power-mode probe;
|
# readers. Excluded disks receive no power-mode probe and a disk in
|
||||||
# every device built on them is excluded too, and the scan's own reads
|
# standby is not woken; every device built on them is excluded too. No
|
||||||
# are not taken as activity. No eligible devices means no scan.
|
# eligible devices means no scan.
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
|
||||||
|
|
||||||
_NAME = re.compile(r"[A-Za-z0-9._+-]+")
|
_NAME = re.compile(r"[A-Za-z0-9._+-]+")
|
||||||
_scan_lock = threading.Lock()
|
|
||||||
_scanned_disks = set()
|
|
||||||
|
|
||||||
|
|
||||||
def _topology():
|
def _topology():
|
||||||
@@ -116,14 +113,11 @@ def lvm_device_args(standby_check):
|
|||||||
excluded = excluded_disk_names()
|
excluded = excluded_disk_names()
|
||||||
blocked = set()
|
blocked = set()
|
||||||
for disk in sorted(disks):
|
for disk in sorted(disks):
|
||||||
# Test policy first: even CHECK POWER MODE is forbidden for an
|
# Test the exclusion first: even CHECK POWER MODE is forbidden
|
||||||
# excluded/idle disk. Some USB bridges do not honour -n standby.
|
# for an excluded disk. Some USB bridges do not honour -n standby.
|
||||||
if disk_read_policy(disk, excluded) != "read" or standby_check(disk):
|
if disk_read_policy(disk, excluded) != "read" or standby_check(disk):
|
||||||
blocked.add(disk)
|
blocked.add(disk)
|
||||||
allowed = sorted(f"/dev/{name}" for name in volumes if not (_disks_under(name, parents) & blocked))
|
allowed = sorted(f"/dev/{name}" for name in volumes if not (_disks_under(name, parents) & blocked))
|
||||||
with _scan_lock:
|
|
||||||
_scanned_disks.clear()
|
|
||||||
_scanned_disks.update(disks - blocked)
|
|
||||||
if not blocked:
|
if not blocked:
|
||||||
return []
|
return []
|
||||||
filters = _lvm_filters()
|
filters = _lvm_filters()
|
||||||
@@ -140,15 +134,3 @@ def lvm_device_args(standby_check):
|
|||||||
# Unavailable exclusions, topology or policy are not permission to
|
# Unavailable exclusions, topology or policy are not permission to
|
||||||
# open devices that may have been deliberately excluded.
|
# open devices that may have been deliberately excluded.
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def settle_scan_reads():
|
|
||||||
"""The LVM scan reads the disks it is allowed to. Those reads are its
|
|
||||||
own, so they must not make an unused disk look busy at the next check."""
|
|
||||||
try:
|
|
||||||
from disk_temperature_history import settle_own_reads
|
|
||||||
with _scan_lock:
|
|
||||||
disks = set(_scanned_disks)
|
|
||||||
settle_own_reads(disks)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ DEV_SCRIPTS_CATALOG = os.path.join(os.path.dirname(__file__), "..", "..", "oci",
|
|||||||
# Encryption key file
|
# Encryption key file
|
||||||
ENCRYPTION_KEY_FILE = os.path.join(OCI_BASE_DIR, ".encryption_key")
|
ENCRYPTION_KEY_FILE = os.path.join(OCI_BASE_DIR, ".encryption_key")
|
||||||
|
|
||||||
# Default storage for templates
|
# Preferred storage for templates; another active one is used when it is
|
||||||
|
# disabled, as `local` is on a BTRFS installation.
|
||||||
DEFAULT_STORAGE = "local"
|
DEFAULT_STORAGE = "local"
|
||||||
|
|
||||||
# Default storage for rootfs (will be auto-detected)
|
# Default storage for rootfs (will be auto-detected)
|
||||||
@@ -389,7 +390,22 @@ def _host_arch() -> str:
|
|||||||
}.get(machine, 'amd64')
|
}.get(machine, 'amd64')
|
||||||
|
|
||||||
|
|
||||||
def _download_alpine_template(storage: str = DEFAULT_STORAGE) -> bool:
|
def _template_storage() -> str:
|
||||||
|
"""An active storage that holds container templates: `local` when it
|
||||||
|
can, otherwise the first other one, such as `local-btrfs`."""
|
||||||
|
rc, out, _ = _run_pve_cmd(["pvesm", "status", "--content", "vztmpl"])
|
||||||
|
active = []
|
||||||
|
if rc == 0:
|
||||||
|
for line in out.strip().split("\n")[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 3 and parts[2] == "active":
|
||||||
|
active.append(parts[0])
|
||||||
|
if not active or DEFAULT_STORAGE in active:
|
||||||
|
return DEFAULT_STORAGE
|
||||||
|
return active[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _download_alpine_template(storage: Optional[str] = None) -> bool:
|
||||||
"""Download the latest Alpine LXC template using pveam.
|
"""Download the latest Alpine LXC template using pveam.
|
||||||
|
|
||||||
Filters by the host's architecture so an x86_64 host does not end up
|
Filters by the host's architecture so an x86_64 host does not end up
|
||||||
@@ -397,6 +413,7 @@ def _download_alpine_template(storage: str = DEFAULT_STORAGE) -> bool:
|
|||||||
kept the last match and could pick any arch that `pveam available`
|
kept the last match and could pick any arch that `pveam available`
|
||||||
happened to list (issue #324).
|
happened to list (issue #324).
|
||||||
"""
|
"""
|
||||||
|
storage = storage or _template_storage()
|
||||||
print("[*] Downloading Alpine Linux template...")
|
print("[*] Downloading Alpine Linux template...")
|
||||||
logger.info("Downloading Alpine template via pveam")
|
logger.info("Downloading Alpine template via pveam")
|
||||||
|
|
||||||
@@ -443,8 +460,9 @@ def _download_alpine_template(storage: str = DEFAULT_STORAGE) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _find_alpine_template(storage: str = DEFAULT_STORAGE, auto_download: bool = True) -> Optional[str]:
|
def _find_alpine_template(storage: Optional[str] = None, auto_download: bool = True) -> Optional[str]:
|
||||||
"""Find an available Alpine LXC template, optionally downloading if not found."""
|
"""Find an available Alpine LXC template, optionally downloading if not found."""
|
||||||
|
storage = storage or _template_storage()
|
||||||
template_dir = "/var/lib/vz/template/cache"
|
template_dir = "/var/lib/vz/template/cache"
|
||||||
|
|
||||||
# Try to get correct path from storage config
|
# Try to get correct path from storage config
|
||||||
@@ -577,7 +595,7 @@ def _configure_tailscale(vmid: int, config: Dict[str, Any]) -> bool:
|
|||||||
# =================================================================
|
# =================================================================
|
||||||
# OCI Image Management
|
# OCI Image Management
|
||||||
# =================================================================
|
# =================================================================
|
||||||
def pull_oci_image(image: str, tag: str = "latest", storage: str = DEFAULT_STORAGE) -> Dict[str, Any]:
|
def pull_oci_image(image: str, tag: str = "latest", storage: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Pull an OCI image from a registry and store as LXC template.
|
Pull an OCI image from a registry and store as LXC template.
|
||||||
Uses Proxmox's pvesh API to download OCI images (same as GUI).
|
Uses Proxmox's pvesh API to download OCI images (same as GUI).
|
||||||
@@ -590,6 +608,7 @@ def pull_oci_image(image: str, tag: str = "latest", storage: str = DEFAULT_STORA
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with success status and template path
|
Dict with success status and template path
|
||||||
"""
|
"""
|
||||||
|
storage = storage or _template_storage()
|
||||||
result = {
|
result = {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "",
|
"message": "",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
if str(SCRIPTS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
|
import oci_manager # noqa: E402
|
||||||
|
|
||||||
|
HEADER = "Name Type Status Total Used Available %\n"
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateStorage(unittest.TestCase):
|
||||||
|
"""The Alpine template goes to a storage Proxmox can use."""
|
||||||
|
|
||||||
|
def storage(self, listing, rc=0):
|
||||||
|
with patch.object(oci_manager, "_run_pve_cmd", return_value=(rc, HEADER + listing, "")):
|
||||||
|
return oci_manager._template_storage()
|
||||||
|
|
||||||
|
def test_local_is_used_while_it_is_active(self):
|
||||||
|
self.assertEqual(self.storage("local dir active 1 1 1 1%\nnas nfs active 1 1 1 1%\n"), "local")
|
||||||
|
|
||||||
|
def test_another_storage_is_used_when_local_is_disabled(self):
|
||||||
|
# On a BTRFS installation `local` is disabled and not listed.
|
||||||
|
self.assertEqual(self.storage("local-btrfs btrfs active 1 1 1 1%\n"), "local-btrfs")
|
||||||
|
self.assertEqual(self.storage("nas nfs inactive 0 0 0 0%\nlocal-btrfs btrfs active 1 1 1 1%\n"), "local-btrfs")
|
||||||
|
|
||||||
|
def test_an_unreadable_status_keeps_local(self):
|
||||||
|
self.assertEqual(self.storage("", rc=1), "local")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -125,13 +125,6 @@ class LvmScopeTests(unittest.TestCase):
|
|||||||
self.excluded = set()
|
self.excluded = set()
|
||||||
self.assertEqual(self.args(inventory), [])
|
self.assertEqual(self.args(inventory), [])
|
||||||
|
|
||||||
def test_the_scan_settles_its_own_reads_on_the_disks_it_read(self):
|
|
||||||
self.excluded = {"sda"}
|
|
||||||
settled = Mock()
|
|
||||||
self.disk_module.settle_own_reads = settled
|
|
||||||
self.args()
|
|
||||||
scope.settle_scan_reads()
|
|
||||||
settled.assert_called_once_with({"nvme0n1", "sdb"})
|
|
||||||
|
|
||||||
|
|
||||||
class LvmReaderTests(unittest.TestCase):
|
class LvmReaderTests(unittest.TestCase):
|
||||||
@@ -174,9 +167,20 @@ class LvmReaderTests(unittest.TestCase):
|
|||||||
self.assertIn('cp "$SCRIPT_DIR/lvm_device_scope.py" "$APP_DIR/usr/bin/"', (SCRIPTS / "build_appimage.sh").read_text())
|
self.assertIn('cp "$SCRIPT_DIR/lvm_device_scope.py" "$APP_DIR/usr/bin/"', (SCRIPTS / "build_appimage.sh").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
class DiskReadPolicyTests(unittest.TestCase):
|
||||||
|
"""Only the exclusions in Settings keep a disk from being read."""
|
||||||
|
|
||||||
|
def test_a_disk_without_activity_is_still_read_unless_excluded(self):
|
||||||
|
import disk_temperature_history as dth
|
||||||
|
with patch.object(dth, "_excluded_disk_names", return_value={"sdc"}):
|
||||||
|
self.assertEqual(dth.disk_read_policy("sdb"), dth.READ)
|
||||||
|
self.assertEqual(dth.disk_read_policy("sdb"), dth.READ)
|
||||||
|
self.assertEqual(dth.disk_read_policy("sdc"), dth.EXCLUDED)
|
||||||
|
|
||||||
|
|
||||||
class QuietDiskIdentityTests(unittest.TestCase):
|
class QuietDiskIdentityTests(unittest.TestCase):
|
||||||
"""A quiet rotational disk the Monitor knows nothing of yet, as after a
|
"""A rotational disk is read on schedule; a parked one is not woken and
|
||||||
restart, is still shown by its model, serial and type."""
|
is still shown by its model, serial and type."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
folder = tempfile.TemporaryDirectory()
|
folder = tempfile.TemporaryDirectory()
|
||||||
@@ -187,10 +191,9 @@ class QuietDiskIdentityTests(unittest.TestCase):
|
|||||||
("run/udev/data/b8:16", "E:ID_MODEL=WDC_WD40EFRX-68N32N0\nE:ID_SERIAL_SHORT=WD-WCC7K4FV37J0\n")):
|
("run/udev/data/b8:16", "E:ID_MODEL=WDC_WD40EFRX-68N32N0\nE:ID_SERIAL_SHORT=WD-WCC7K4FV37J0\n")):
|
||||||
(root / name).parent.mkdir(parents=True, exist_ok=True)
|
(root / name).parent.mkdir(parents=True, exist_ok=True)
|
||||||
(root / name).write_text(value)
|
(root / name).write_text(value)
|
||||||
self.policy = "idle"
|
self.policy = "read"
|
||||||
self.disk_module = types.ModuleType("disk_temperature_history")
|
self.disk_module = types.ModuleType("disk_temperature_history")
|
||||||
self.disk_module.disk_read_policy = lambda disk: self.policy
|
self.disk_module.disk_read_policy = lambda disk: self.policy
|
||||||
self.disk_module.settle_own_reads = Mock()
|
|
||||||
modules = patch.dict(sys.modules, disk_temperature_history=self.disk_module)
|
modules = patch.dict(sys.modules, disk_temperature_history=self.disk_module)
|
||||||
modules.start()
|
modules.start()
|
||||||
self.addCleanup(modules.stop)
|
self.addCleanup(modules.stop)
|
||||||
@@ -209,21 +212,17 @@ class QuietDiskIdentityTests(unittest.TestCase):
|
|||||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "flask_server.py", "exec"), namespace)
|
exec(compile(ast.Module(body=nodes, type_ignores=[]), "flask_server.py", "exec"), namespace)
|
||||||
self.get = namespace["get_smart_data"]
|
self.get = namespace["get_smart_data"]
|
||||||
|
|
||||||
def test_a_spinning_disk_is_read_once(self):
|
def test_a_spinning_disk_is_read(self):
|
||||||
data = self.get("sdb")
|
data = self.get("sdb")
|
||||||
self.assertEqual((data["model"], data["rotation_rate"], data["health"], data["idle"]),
|
self.assertEqual((data["model"], data["rotation_rate"], data["health"], data["temperature"]),
|
||||||
("WDC WD40EFRX-68N32N0", 5400, "healthy", True))
|
("WDC WD40EFRX-68N32N0", 5400, "healthy", 31))
|
||||||
self.disk_module.settle_own_reads.assert_called_once_with(["sdb"])
|
|
||||||
self.cache["sdb"] = (0, self.cache["sdb"][1])
|
|
||||||
self.get("sdb")
|
|
||||||
self.read.assert_called_once()
|
self.read.assert_called_once()
|
||||||
self.assertEqual(self.standby.call_count, 1)
|
|
||||||
|
|
||||||
def test_a_parked_disk_is_not_woken_and_keeps_its_identity(self):
|
def test_a_parked_disk_is_not_woken_and_keeps_its_identity(self):
|
||||||
self.standby.return_value = True
|
self.standby.return_value = True
|
||||||
data = self.get("sdb")
|
data = self.get("sdb")
|
||||||
self.read.assert_not_called()
|
self.read.assert_not_called()
|
||||||
self.assertEqual((data["model"], data["serial"], data["rotation_rate"], data["idle"]),
|
self.assertEqual((data["model"], data["serial"], data["rotation_rate"], data["standby"]),
|
||||||
("WDC WD40EFRX-68N32N0", "WD-WCC7K4FV37J0", -1, True))
|
("WDC WD40EFRX-68N32N0", "WD-WCC7K4FV37J0", -1, True))
|
||||||
|
|
||||||
def test_an_excluded_disk_receives_no_command(self):
|
def test_an_excluded_disk_receives_no_command(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user