diff --git a/AppImage/ProxMenux-1.2.2.2-beta.AppImage b/AppImage/ProxMenux-1.2.2.2-beta.AppImage index eddc30c2..4be06157 100755 Binary files a/AppImage/ProxMenux-1.2.2.2-beta.AppImage and b/AppImage/ProxMenux-1.2.2.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 3b94e54b..0335f8e8 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -6bd898c3801bdf27b983ca5c107421d485e163f0c339cbc5f5cc90c9bb26a4d6 ProxMenux-1.2.2.2-beta.AppImage +79be8caa3baebc9276e409c1ef1e228e33265279df41a03eba8bcc859f67a44d ProxMenux-1.2.2.2-beta.AppImage diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index a522d8cf..5a8d6b9c 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 } 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 } 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" @@ -22,6 +22,11 @@ interface DiskInfo { size?: number // Changed from string to number (KB) for formatMemory() size_formatted?: string // Added formatted size string for display temperature: number + // True when the temperature poller's last smartctl exited with + // "device is in standby". The UI uses this to render a Standby + // badge AND to suppress the (stale) temperature value, so the + // operator understands the graph is frozen on purpose — issue #232. + standby?: boolean health: string power_on_hours?: number smart_status?: string @@ -279,6 +284,35 @@ export function StorageOverview() { } } + // Renders either the live temperature or a "Standby" badge for a + // spun-down drive. Centralised here because the same pattern shows up + // in 4 different disk-list views (system / data / pool / other) and we + // want them all to behave identically — issue #232 fix. + const renderDiskTempOrStandby = (disk: DiskInfo) => { + if (disk.standby) { + return ( + + + Standby + + ) + } + if (disk.temperature > 0) { + return ( +
+ + + {disk.temperature}°C + +
+ ) + } + return null + } + const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => { if (temp === 0) return "text-gray-500" @@ -1374,18 +1408,7 @@ export function StorageOverview() {

{disk.model}

)}
- {disk.temperature > 0 && ( -
- - - {disk.temperature}°C - -
- )} + {renderDiskTempOrStandby(disk)} {(disk.observations_count ?? 0) > 0 && ( @@ -1466,18 +1489,7 @@ export function StorageOverview() {

{disk.model}

)}
- {disk.temperature > 0 && ( -
- - - {disk.temperature}°C - -
- )} + {renderDiskTempOrStandby(disk)} {(disk.observations_count ?? 0) > 0 && ( @@ -1578,14 +1590,7 @@ export function StorageOverview() {

{disk.model}

)}
- {disk.temperature > 0 && ( -
- - - {disk.temperature}°C - -
- )} + {renderDiskTempOrStandby(disk)} {(disk.observations_count ?? 0) > 0 && ( @@ -1632,14 +1637,7 @@ export function StorageOverview() { USB
- {disk.temperature > 0 && ( -
- - - {disk.temperature}°C - -
- )} + {renderDiskTempOrStandby(disk)} {getHealthBadge(disk.health)} {(disk.observations_count ?? 0) > 0 && ( diff --git a/AppImage/scripts/disk_temperature_history.py b/AppImage/scripts/disk_temperature_history.py index e19d4d00..d69ed3b0 100644 --- a/AppImage/scripts/disk_temperature_history.py +++ b/AppImage/scripts/disk_temperature_history.py @@ -238,21 +238,47 @@ def _list_target_disks() -> list[str]: def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]: - """Build the smartctl invocation for a given probe key.""" - cmd = ["smartctl", "-A", "-j"] + """Build the smartctl invocation for a given probe key. + + `-n standby` makes smartctl exit immediately with code 2 (no disk + I/O) when the drive is already in standby. Without it, this + once-a-minute poller was spinning HDDs back up on every cycle, + breaking NAS / SnapRAID setups that rely on hdparm-driven spin-down + (issue #232). + """ + cmd = ["smartctl", "-n", "standby", "-A", "-j"] if probe != "auto": cmd.extend(["-d", probe]) cmd.append(f"/dev/{disk_name}") return cmd +# Sentinel returned by `_try_probe` when the drive is in standby. Distinct +# from `None` (read failure / no temperature attribute), so the caller +# can keep the last known reading instead of marking the disk as failing. +_STANDBY = "standby" + + def _try_probe(disk_name: str, probe: str) -> Optional[float]: - """Run a single smartctl invocation and parse the temperature.""" + """Run a single smartctl invocation and parse the temperature. + + Returns: + * a float — current temperature in °C. + * the string ``_STANDBY`` — drive is in standby, NOT read. + * ``None`` — read failed for any other reason. + """ try: proc = subprocess.run( _smartctl_cmd_for(disk_name, probe), capture_output=True, text=True, timeout=_SMARTCTL_TIMEOUT, ) + # `-n standby` makes smartctl exit with code 2 when the drive is + # parked. We must not treat that as a read failure (would trigger + # the backoff and stop polling that drive forever) — surface it + # as the dedicated _STANDBY sentinel so the caller skips the + # update cleanly. + if proc.returncode == 2: + return _STANDBY # type: ignore[return-value] # smartctl returns non-zero on warnings (bit 0x40 etc.) even when # JSON is fully populated. Don't gate on returncode — parse the # body regardless. @@ -264,8 +290,24 @@ def _try_probe(disk_name: str, probe: str) -> Optional[float]: return None +# Disks that returned "standby" on their last poll. Used by the +# /api/storage/disks endpoint to render a Standby badge so the operator +# understands why the temperature graph for that drive is frozen — the +# disk really is parked, not the monitor that's broken. +_standby_state: dict[str, float] = {} # disk_name -> last-seen timestamp +_STANDBY_TTL = 600 # treat as stale after 10 min of no observation + + +def is_disk_in_standby(disk_name: str) -> bool: + """True if our last smartctl poll for this disk hit a standby spindle. + Falls back to False when the cached observation is older than the + TTL — the drive may have woken up between polls.""" + ts = _standby_state.get(disk_name) + return ts is not None and (time.time() - ts) < _STANDBY_TTL + + def _read_temperature(disk_name: str) -> Optional[float]: - """Pull the current temperature from ``smartctl -A -j``. + """Pull the current temperature from ``smartctl -n standby -A -j``. Caching strategy: * If we've previously found a working probe for this disk we go @@ -275,6 +317,9 @@ def _read_temperature(disk_name: str) -> Optional[float]: and update the cache with whatever does work. * Disks that never report a temperature get rate-limited via the backoff table so we don't smartctl them every minute forever. + * Disks in standby return ``None`` but DON'T count toward the + failure backoff — they're not broken, they're just parked. + The standby state is recorded so the UI can show a badge. """ now = time.time() @@ -285,10 +330,23 @@ def _read_temperature(disk_name: str) -> Optional[float]: if retry_at > now: return None + def _handle(result): + """Clear failure state + record standby observation. Returns the + numeric temperature if the result is one (else None / standby).""" + if result == _STANDBY: + _standby_state[disk_name] = time.time() + return _STANDBY + if isinstance(result, (int, float)) and result > 0: + _standby_state.pop(disk_name, None) + return result + return None + # Fast path: cached probe. if cached_probe is not None: - temp = _try_probe(disk_name, cached_probe) - if temp is not None and temp > 0: + temp = _handle(_try_probe(disk_name, cached_probe)) + if temp == _STANDBY: + return None # parked — skip update, don't penalise + if temp is not None: with _cache_lock: _disk_fail_counts.pop(disk_name, None) _disk_fail_backoff.pop(disk_name, None) @@ -299,16 +357,18 @@ def _read_temperature(disk_name: str) -> Optional[float]: for probe in ("auto", "nvme", "ata", "sat"): if probe == cached_probe: continue # already tried above - temp = _try_probe(disk_name, probe) - if temp is not None and temp > 0: + temp = _handle(_try_probe(disk_name, probe)) + if temp == _STANDBY: + return None + if temp is not None: with _cache_lock: _disk_probe_cache[disk_name] = probe _disk_fail_counts.pop(disk_name, None) _disk_fail_backoff.pop(disk_name, None) return temp - # All probes failed. Bump the failure counter and trip the backoff - # if we've crossed the threshold. + # All probes failed (none returned a temperature OR standby). Bump + # the failure counter and trip the backoff if threshold crossed. with _cache_lock: n = _disk_fail_counts.get(disk_name, 0) + 1 _disk_fail_counts[disk_name] = n diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index a7c31f99..3292d8f0 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -2989,12 +2989,25 @@ def get_storage_info(): is_system_disk = sys_info.get('is_system', False) system_usage = sys_info.get('usage', []) + # `standby` reflects what the temperature poller + # last observed (smartctl -n standby exit code 2). + # Surfaced here so the UI can paint a "Standby" + # badge and tell the operator that a frozen + # temperature graph isn't a monitor bug — the + # disk is parked. See issue #232. + in_standby = False + try: + import disk_temperature_history as _dth + in_standby = _dth.is_disk_in_standby(disk_name) + except Exception: + pass physical_disks[disk_name] = { 'name': disk_name, 'size': disk_size_kb, # In KB for formatMemory() in Storage Summary 'size_formatted': size_str, # Added formatted size string for Storage section 'size_bytes': disk_size_bytes, 'temperature': smart_data.get('temperature', 0), + 'standby': in_standby, 'health': smart_data.get('health', 'unknown'), 'power_on_hours': smart_data.get('power_on_hours', 0), 'smart_status': smart_data.get('smart_status', 'unknown'), diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index bd4137f1..8022c2d8 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -2424,10 +2424,23 @@ class HealthMonitor: try: dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name + # `-n standby` skips the command (exit code 2, no disk I/O) + # when the drive is parked, preventing the health poller + # from spinning up HDDs that hdparm / hd-idle just put to + # sleep — issue #232. The "UNKNOWN" branch below correctly + # keeps the previous cached result alive on exit code 2. result = subprocess.run( - ['smartctl', '--health', '-j', dev_path], + ['smartctl', '-n', 'standby', '--health', '-j', dev_path], capture_output=True, text=True, timeout=5 ) + if result.returncode == 2: + # Drive in standby — reuse the previous health state + # if we have one, otherwise report UNKNOWN. Either way, + # don't refresh the cache TTL so we retry on the next + # cycle (a drive can come out of standby at any time). + if cached: + return cached['result'] + return 'UNKNOWN' import json as _json data = _json.loads(result.stdout) passed = data.get('smart_status', {}).get('passed', None)