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
excluded: boolean
excluded_at: string | null
idle: boolean
present: boolean
}
@@ -206,15 +205,6 @@ export function DiskExclusions() {
{t("settings.diskExclusions.excluded")}
</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 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
{t("settings.diskExclusions.notConnected")}
+104 -63
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
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 { Progress } from "@/components/ui/progress"
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
// operator understands the graph is frozen on purpose — issue #232.
standby?: boolean
idle?: boolean
excluded?: boolean
health: string
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.
// 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 } = {}) {
const t = useT()
@@ -332,6 +349,12 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
// 85 vh capped by the visual viewport otherwise). Keeps every
// detail modal at a matching size across the app.
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(() => {
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
// want them all to behave identically — issue #232 fix.
// 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.
const renderNoReadingBadge = (disk: DiskInfo) => {
if (disk.excluded) {
@@ -586,17 +609,6 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</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
}
@@ -1100,7 +1112,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
storageData.disks.forEach((disk) => {
// 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) {
unavailable++
return
@@ -1735,7 +1747,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
overlay with text-xs/text-[10px] all over and looked
shrunken next to the rest of the modals. */}
<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 && (() => {
const m = mountDetail
const isStale = m.status === "stale"
@@ -1782,7 +1794,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</DialogDescription>
</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 */}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
@@ -1895,7 +1907,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
{storageData.zfs_pools.map((pool) => (
<div
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)}
>
<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>
{getHealthBadge(pool.health)}
</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>
{pool.details_available && (() => {
const dataDevices = (pool.vdevs ?? [])
@@ -1930,6 +1945,18 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</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>
<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
the background refresh while open (e.g. resilver progress). */}
<Dialog open={selectedPool !== null} onOpenChange={(open) => { if (!open) setSelectedPool(null) }}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogContent className={detailDialogClass}>
{selectedPool && (() => {
const pool = storageData.zfs_pools.find((p) => p.name === selectedPool.name) ?? selectedPool
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))
return (
<>
<DialogHeader>
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle className="flex items-center gap-3">
<Database className="h-5 w-5" />
{pool.name}
@@ -1973,7 +2000,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
<DialogDescription>{t("storage.zfs.poolDetails")}</DialogDescription>
</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) && (
<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>}
@@ -1981,35 +2008,57 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{t("storage.size")}</p>
<p className="font-medium">{pool.size}</p>
</div>
<div>
<p className="text-muted-foreground">{t("storage.allocated")}</p>
<p className="font-medium">{pool.allocated}</p>
</div>
<div>
<p className="text-muted-foreground">{t("storage.free")}</p>
<p className="font-medium">{pool.free}</p>
</div>
{pool.capacity != null && (
<div>
<p className="text-muted-foreground">{t("storage.zfs.capacity")}</p>
<p className="font-medium">{pool.capacity}%</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-border p-4 flex flex-col justify-center gap-3">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">{t("storage.free")}</p>
<p className="text-lg font-semibold">{pool.free}</p>
</div>
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
</div>
)}
{pool.fragmentation != null && (
<div>
<p className="text-muted-foreground">{t("storage.zfs.fragmentation")}</p>
<p className="font-medium">{pool.fragmentation}%</p>
{pool.capacity != null && (
<Progress
value={pool.capacity}
className={`h-2 ${
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>
)}
{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>
{(pool.fragmentation != null || pool.dedup_ratio != null) && (
<div className="rounded-lg border border-border p-4 flex items-center gap-5">
{pool.fragmentation != null && (
<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>
@@ -2019,7 +2068,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
) : (
<>
{/* 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">
{t("storage.zfs.scanTitle")}
</h3>
@@ -2054,16 +2103,14 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
</p>
</div>
{/* Layout: one block per top-level vdev */}
<div className="space-y-3">
{/* Layout: one block per top-level vdev; the last one
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">
{t("storage.zfs.layout")}
{getZfsLayoutSummary(pool) && (
<span className="ml-2 normal-case font-normal text-foreground">{getZfsLayoutSummary(pool)}</span>
)}
</h3>
{vdevs.map((vdev, i) => (
<div key={`${vdev.class}-${vdev.name}-${i}`} className="border rounded-lg">
<div 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">
<Badge variant="outline" className="text-xs">{t(`storage.zfs.classes.${vdev.class}`)}</Badge>
<span className="text-sm font-medium">{getZfsVdevLabel(vdev)}</span>
@@ -2219,13 +2266,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
setSmartJsonData(null)
}
}}>
<DialogContent
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)]"
}`}
>
<DialogContent className={detailDialogClass}>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle className="flex items-center gap-2">
{selectedDisk?.connection_type === 'usb' ? (
+2
View File
@@ -322,6 +322,8 @@
"fragmentation": "Fragmentation",
"dedupRatio": "Dedup Ratio",
"layout": "Layout",
"totalSize": "Total size",
"fragShort": "Frag.",
"singleDisk": "Single disk",
"stripe": "Stripe",
"mirror": "Mirror",
+29 -27
View File
@@ -369,8 +369,8 @@
"notApplicable": "n / A",
"error": "Error",
"system": "Sistema",
"standby": "En reposo",
"standbyTitle": "La unidad está en espera: se omitió smartctl para mantenerla apagada",
"standby": "En espera",
"standbyTitle": "El disco está parado: no se le envía nada para no despertarlo",
"filesystemCorruption": "Se detectó corrupción en el sistema de archivos",
"ioErrorOne": "{count} Error de E/S en 5 minutos",
"ioErrorMany": "{count} Errores de E/S en 5 minutos",
@@ -983,53 +983,55 @@
"temperatureUnavailableCount": "No disponible: {count}",
"zfs": {
"classes": {
"normal": "datos",
"normal": "Datos",
"special": "Especial",
"dedup": "Deduplicación",
"log": "Iniciar sesión",
"cache": "caché",
"dedup": "Dedup",
"log": "Log",
"cache": "Caché",
"spare": "Repuesto"
},
"poolDetails": "diseño de la piscina, estado de limpieza y estado del dispositivo",
"detailsUnavailable": "El diseño detallado del grupo requiere OpenZFS 2.3 o posterior.",
"poolDetails": "Estructura del pool, estado del scrub y salud de los dispositivos",
"detailsUnavailable": "La estructura detallada del pool requiere OpenZFS 2.3 o posterior.",
"status": "Estado",
"action": "Acción",
"capacity": "Capacidad",
"fragmentation": "Fragmentación",
"dedupRatio": "Relación de deduplicación",
"layout": "Diseño",
"singleDisk": "disco único",
"stripe": "Raya",
"dedupRatio": "Ratio de deduplicación",
"layout": "Estructura",
"totalSize": "Tamaño total",
"fragShort": "Frag.",
"singleDisk": "Disco único",
"stripe": "Stripe",
"mirror": "Espejo",
"raidz": "RAIDZ{parity}",
"draid": "dRAID{parity}",
"diskCountOne": "disco {count}",
"diskCountMany": "discos {count}",
"errorsReported": "Errores reportados",
"diskCountOne": "{count} disco",
"diskCountMany": "{count} discos",
"errorsReported": "Errores detectados",
"scanTitle": "Scrub / Resilver",
"scrub": "fregar",
"scrub": "Scrub",
"resilver": "Resilver",
"neverScrubbed": "Nunca fregado",
"scanRunning": "{kind} en progreso ({percent}%)",
"neverScrubbed": "Sin scrub todavía",
"scanRunning": "{kind} en curso ({percent}%)",
"scanCanceled": "{kind} cancelado",
"scanFinished": "{kind} terminado {date}",
"started": "iniciado",
"ended": "Terminado",
"repaired": "reparado",
"scanErrors": "errores de escaneo",
"dataErrors": "errores de datos {count}",
"started": "Inicio",
"ended": "Fin",
"repaired": "Reparado",
"scanErrors": "Errores del análisis",
"dataErrors": "{count} errores de datos",
"noDataErrors": "No hay errores de datos conocidos",
"device": "Dispositivo",
"disk": "Disco",
"state": "Estado",
"readErrors": "errores de lectura",
"writeErrors": "errores de escritura",
"checksumErrors": "errores de suma de comprobación",
"readErrors": "Errores de lectura",
"writeErrors": "Errores de escritura",
"checksumErrors": "Errores de checksum",
"readShort": "R",
"writeShort": "W",
"checksumShort": "CK",
"slowIos": "E/S lentas",
"via": "vía {name}"
"via": "a través de {name}"
},
"storageController": "Controlador de almacenamiento",
"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.
# It does not let an awake one fall asleep: a drive's spin-down timer
# counts time without commands, and a read every minute resets it, so an
# unused drive whose timer is longer than that never spins down — and a
# 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.
# A disk the user excludes in Settings receives nothing from a periodic
# reader, not even the power-mode question, so it can spin down on its own
# timer. Every other disk is read on schedule: `-n standby` still keeps a
# reader from waking one that is already asleep.
READ = "read"
IDLE = "idle"
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_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:
"""Whether a periodic reader may touch this disk now: READ, IDLE or
EXCLUDED. Shared by every reader that runs on its own, so the
temperature poller and the storage view cannot disagree about a disk.
``excluded`` lets a caller that checks many disks pass the list once."""
"""Whether a periodic reader may touch this disk: READ or EXCLUDED.
Shared by every reader that runs on its own, so the temperature poller
and the storage view cannot disagree about a disk. ``excluded`` lets a
caller that checks many disks pass the list once."""
if excluded is None:
excluded = _excluded_disk_names()
if disk_name in excluded:
_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
return EXCLUDED if disk_name in excluded else READ
def record_all_disk_temperatures() -> int:
"""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
with no I/O since the last cycle, is skipped — see ``disk_read_policy``.
USB disks are included. A disk the user excluded is skipped — see
``disk_read_policy``.
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
-2
View File
@@ -807,7 +807,6 @@ def get_disks_for_exclusion():
**disk,
'excluded': entry is not None,
'excluded_at': entry.get('excluded_at') if entry else None,
'idle': _dth.is_disk_idle(disk['name']),
'present': True,
})
# An excluded disk that is not connected right now — an unplugged USB
@@ -826,7 +825,6 @@ def get_disks_for_exclusion():
'rotational': False,
'excluded': True,
'excluded_at': entry.get('excluded_at'),
'idle': False,
'present': False,
})
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'],
capture_output=True, text=True, timeout=5
) 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:
for line in result.stdout.strip().split('\n'):
if line.strip():
@@ -4558,12 +4555,10 @@ def get_storage_info():
# temperature graph isn't a monitor bug — the
# disk is parked. See issue #232.
in_standby = False
in_idle = False
in_excluded = False
try:
import disk_temperature_history as _dth
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()
except Exception:
pass
@@ -4574,7 +4569,6 @@ def get_storage_info():
'size_bytes': disk_size_bytes,
'temperature': smart_data.get('temperature', 0),
'standby': in_standby,
'idle': in_idle,
'excluded': in_excluded,
'health': smart_data.get('health', 'unknown'),
'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:
return dict(cached[1])
# Excluded, or rotational with no I/O since it was last looked at: send
# it nothing — not even the power-mode question below, which is still a
# command. Serve what is known, without a temperature that would only be
# stale. Same rule as the temperature poller, so the two agree.
# Excluded in Settings: send it nothing — not even the power-mode
# question below, which is still a command. Serve what is known, without
# a temperature that would only be stale. Same rule as the temperature
# poller, so the two agree.
try:
import disk_temperature_history as _dth
policy = _dth.disk_read_policy(disk_name)
except Exception:
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['temperature'] = 0
base['excluded'] = policy == 'excluded'
base['idle'] = policy == 'idle'
base['excluded'] = True
return base
if _hdd_in_standby(disk_name):
-4
View File
@@ -2288,9 +2288,6 @@ class HealthMonitor:
text=True,
timeout=3
)
from lvm_device_scope import settle_scan_reads
settle_scan_reads()
if result.returncode != 0:
return {'status': 'WARNING', 'reason': 'lvs command failed'}
@@ -2314,7 +2311,6 @@ class HealthMonitor:
text=True,
timeout=3
)
settle_scan_reads()
if vg_result.returncode == 0 and vg_result.stdout.strip():
return {'status': 'WARNING', 'reason': 'No active LVM volumes detected'}
else:
+6 -24
View File
@@ -9,20 +9,17 @@
# Last Updated: 16/10/2026
# ==========================================================
# Description:
# Applies the automatic disk-read policy to LVM metadata scans in both
# Monitor readers. Excluded and idle disks receive no power-mode probe;
# every device built on them is excluded too, and the scan's own reads
# are not taken as activity. No eligible devices means no scan.
# Applies the disk exclusions to LVM metadata scans in both Monitor
# readers. Excluded disks receive no power-mode probe and a disk in
# standby is not woken; every device built on them is excluded too. No
# eligible devices means no scan.
# ==========================================================
import json
import re
import subprocess
import threading
_NAME = re.compile(r"[A-Za-z0-9._+-]+")
_scan_lock = threading.Lock()
_scanned_disks = set()
def _topology():
@@ -116,14 +113,11 @@ def lvm_device_args(standby_check):
excluded = excluded_disk_names()
blocked = set()
for disk in sorted(disks):
# Test policy first: even CHECK POWER MODE is forbidden for an
# excluded/idle disk. Some USB bridges do not honour -n standby.
# Test the exclusion first: even CHECK POWER MODE is forbidden
# for an excluded disk. Some USB bridges do not honour -n standby.
if disk_read_policy(disk, excluded) != "read" or standby_check(disk):
blocked.add(disk)
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:
return []
filters = _lvm_filters()
@@ -140,15 +134,3 @@ def lvm_device_args(standby_check):
# Unavailable exclusions, topology or policy are not permission to
# open devices that may have been deliberately excluded.
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 = 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 for rootfs (will be auto-detected)
@@ -389,7 +390,22 @@ def _host_arch() -> str:
}.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.
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`
happened to list (issue #324).
"""
storage = storage or _template_storage()
print("[*] Downloading Alpine Linux template...")
logger.info("Downloading Alpine template via pveam")
@@ -443,8 +460,9 @@ def _download_alpine_template(storage: str = DEFAULT_STORAGE) -> bool:
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."""
storage = storage or _template_storage()
template_dir = "/var/lib/vz/template/cache"
# 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
# =================================================================
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.
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:
Dict with success status and template path
"""
storage = storage or _template_storage()
result = {
"success": False,
"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.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):
@@ -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())
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):
"""A quiet rotational disk the Monitor knows nothing of yet, as after a
restart, is still shown by its model, serial and type."""
"""A rotational disk is read on schedule; a parked one is not woken and
is still shown by its model, serial and type."""
def setUp(self):
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")):
(root / name).parent.mkdir(parents=True, exist_ok=True)
(root / name).write_text(value)
self.policy = "idle"
self.policy = "read"
self.disk_module = types.ModuleType("disk_temperature_history")
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.start()
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)
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")
self.assertEqual((data["model"], data["rotation_rate"], data["health"], data["idle"]),
("WDC WD40EFRX-68N32N0", 5400, "healthy", True))
self.disk_module.settle_own_reads.assert_called_once_with(["sdb"])
self.cache["sdb"] = (0, self.cache["sdb"][1])
self.get("sdb")
self.assertEqual((data["model"], data["rotation_rate"], data["health"], data["temperature"]),
("WDC WD40EFRX-68N32N0", 5400, "healthy", 31))
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):
self.standby.return_value = True
data = self.get("sdb")
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))
def test_an_excluded_disk_receives_no_command(self):