mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
6bd898c3801bdf27b983ca5c107421d485e163f0c339cbc5f5cc90c9bb26a4d6 ProxMenux-1.2.2.2-beta.AppImage
|
||||
79be8caa3baebc9276e409c1ef1e228e33265279df41a03eba8bcc859f67a44d ProxMenux-1.2.2.2-beta.AppImage
|
||||
|
||||
@@ -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 (
|
||||
<Badge
|
||||
className="bg-blue-500/10 text-blue-300 border-blue-500/30 gap-1"
|
||||
title="Drive is in standby — smartctl skipped to keep it spun down"
|
||||
>
|
||||
<Power className="h-3 w-3" />
|
||||
Standby
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (disk.temperature > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
|
||||
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => {
|
||||
if (temp === 0) return "text-gray-500"
|
||||
|
||||
@@ -1374,18 +1408,7 @@ export function StorageOverview() {
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer
|
||||
className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{renderDiskTempOrStandby(disk)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
@@ -1466,18 +1489,7 @@ export function StorageOverview() {
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer
|
||||
className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{renderDiskTempOrStandby(disk)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
@@ -1578,14 +1590,7 @@ export function StorageOverview() {
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
|
||||
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{renderDiskTempOrStandby(disk)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
@@ -1632,14 +1637,7 @@ export function StorageOverview() {
|
||||
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`} />
|
||||
<span className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{renderDiskTempOrStandby(disk)}
|
||||
{getHealthBadge(disk.health)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user