fix(monitor): place the Secure Gateway template on an active storage

This commit is contained in:
MacRimi
2026-10-30 22:58:12 +01:00
parent dc823bc21b
commit aa4adc34a9
12 changed files with 234 additions and 259 deletions
-10
View File
@@ -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")}
+104 -63
View File
@@ -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' ? (
+2
View File
@@ -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",
+29 -27
View File
@@ -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",
+12 -86
View File
@@ -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
-2
View File
@@ -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']))
+5 -20
View File
@@ -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):
-4
View File
@@ -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:
+6 -24
View File
@@ -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
+23 -4
View File
@@ -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()
+18 -19
View File
@@ -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):