diff --git a/AppImage/components/disk-exclusions.tsx b/AppImage/components/disk-exclusions.tsx index 64c953a9..a9db48d7 100644 --- a/AppImage/components/disk-exclusions.tsx +++ b/AppImage/components/disk-exclusions.tsx @@ -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")} )} - {!excluded && disk.idle && ( - - {t("storage.idle")} - - )} {!disk.present && ( {t("settings.diskExclusions.notConnected")} diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 4b59fabc..95fbe102 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -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 ( + + + + {pct}% + {label} + + ) +} + +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 } = ) } - if (disk.idle) { - return ( - - - {t("storage.idle")} - - ) - } 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. */} { if (!open) setMountDetail(null) }}> - + {mountDetail && (() => { const m = mountDetail const isStale = m.status === "stale" @@ -1782,7 +1794,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = -
+
{/* Type + tags */}
{m.fstype} @@ -1895,7 +1907,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {storageData.zfs_pools.map((pool) => (
setSelectedPool(pool)} >
@@ -1903,7 +1915,10 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =

{pool.name}

{getHealthBadge(pool.health)}
- {getHealthIcon(pool.health)} +
+ {pool.capacity != null && {pool.capacity}%} + {getHealthIcon(pool.health)} +
{pool.details_available && (() => { const dataDevices = (pool.vdevs ?? []) @@ -1930,6 +1945,18 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =

) })()} + {pool.capacity != null && ( + 90 + ? "[&>div]:bg-red-500" + : pool.capacity > 75 + ? "[&>div]:bg-yellow-500" + : "[&>div]:bg-blue-500" + }`} + /> + )}

{t("storage.size")}

@@ -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). */} { if (!open) setSelectedPool(null) }}> - + {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 ( <> - + {pool.name} @@ -1973,7 +2000,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {t("storage.zfs.poolDetails")} -
+
{(pool.status || pool.action) && (
{pool.status &&

{t("storage.zfs.status")}: {pool.status}

} @@ -1981,35 +2008,57 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
)} -
-
-

{t("storage.size")}

-

{pool.size}

-
-
-

{t("storage.allocated")}

-

{pool.allocated}

-
-
-

{t("storage.free")}

-

{pool.free}

-
- {pool.capacity != null && ( -
-

{t("storage.zfs.capacity")}

-

{pool.capacity}%

+
+
+
+
+

{t("storage.free")}

+

{pool.free}

+
+ {pool.capacity != null && {pool.capacity}%}
- )} - {pool.fragmentation != null && ( -
-

{t("storage.zfs.fragmentation")}

-

{pool.fragmentation}%

+ {pool.capacity != null && ( + 90 + ? "[&>div]:bg-red-500" + : pool.capacity > 75 + ? "[&>div]:bg-yellow-500" + : "[&>div]:bg-blue-500" + }`} + /> + )} +
+
+

{t("storage.allocated")}

+

{pool.allocated}

+
+
+

{t("storage.zfs.totalSize")}

+

{pool.size}

+
- )} - {pool.dedup_ratio != null && ( -
-

{t("storage.zfs.dedupRatio")}

-

{pool.dedup_ratio.toFixed(2)}x

+
+ {(pool.fragmentation != null || pool.dedup_ratio != null) && ( +
+ {pool.fragmentation != null && ( + + )} +
+ {pool.fragmentation != null && ( +
+

{t("storage.zfs.fragmentation")}

+

{pool.fragmentation}%

+
+ )} + {pool.dedup_ratio != null && ( +
+

{t("storage.zfs.dedupRatio")}

+

{pool.dedup_ratio.toFixed(2)}x

+
+ )} +
)}
@@ -2019,7 +2068,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = ) : ( <> {/* Scrub / resilver */} -
+

{t("storage.zfs.scanTitle")}

@@ -2054,16 +2103,14 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =

- {/* Layout: one block per top-level vdev */} -
+ {/* Layout: one block per top-level vdev; the last one + reaches the bottom of the dialog. */} +

{t("storage.zfs.layout")} - {getZfsLayoutSummary(pool) && ( - {getZfsLayoutSummary(pool)} - )}

{vdevs.map((vdev, i) => ( -
+
{t(`storage.zfs.classes.${vdev.class}`)} {getZfsVdevLabel(vdev)} @@ -2219,13 +2266,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = setSmartJsonData(null) } }}> - + {selectedDisk?.connection_type === 'usb' ? ( diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 1ec257ca..dbefcb3c 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -322,6 +322,8 @@ "fragmentation": "Fragmentation", "dedupRatio": "Dedup Ratio", "layout": "Layout", + "totalSize": "Total size", + "fragShort": "Frag.", "singleDisk": "Single disk", "stripe": "Stripe", "mirror": "Mirror", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index f795fcd5..c515d02e 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -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", diff --git a/AppImage/scripts/disk_temperature_history.py b/AppImage/scripts/disk_temperature_history.py index 2100c355..0cff3ab2 100644 --- a/AppImage/scripts/disk_temperature_history.py +++ b/AppImage/scripts/disk_temperature_history.py @@ -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 diff --git a/AppImage/scripts/flask_health_routes.py b/AppImage/scripts/flask_health_routes.py index a5287b69..798249a8 100644 --- a/AppImage/scripts/flask_health_routes.py +++ b/AppImage/scripts/flask_health_routes.py @@ -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'])) diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3a9db342..a90a6b47 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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): diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index cea8de4f..5bbfbc1e 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -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: diff --git a/AppImage/scripts/lvm_device_scope.py b/AppImage/scripts/lvm_device_scope.py index c3c410bd..c4c36d2d 100644 --- a/AppImage/scripts/lvm_device_scope.py +++ b/AppImage/scripts/lvm_device_scope.py @@ -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 diff --git a/AppImage/scripts/oci_manager.py b/AppImage/scripts/oci_manager.py index 5d8624a1..ab4ff0da 100644 --- a/AppImage/scripts/oci_manager.py +++ b/AppImage/scripts/oci_manager.py @@ -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": "", diff --git a/AppImage/scripts/tests/test_secure_gateway_template_storage.py b/AppImage/scripts/tests/test_secure_gateway_template_storage.py new file mode 100644 index 00000000..25160278 --- /dev/null +++ b/AppImage/scripts/tests/test_secure_gateway_template_storage.py @@ -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() diff --git a/tests/test_lvm_disk_read_policy.py b/tests/test_lvm_disk_read_policy.py index c7502079..f259ccae 100644 --- a/tests/test_lvm_disk_read_policy.py +++ b/tests/test_lvm_disk_read_policy.py @@ -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):