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)