mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-02 05:46:21 +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 { useEffect, useState } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
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?: number // Changed from string to number (KB) for formatMemory()
|
||||||
size_formatted?: string // Added formatted size string for display
|
size_formatted?: string // Added formatted size string for display
|
||||||
temperature: number
|
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
|
health: string
|
||||||
power_on_hours?: number
|
power_on_hours?: number
|
||||||
smart_status?: string
|
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) => {
|
const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => {
|
||||||
if (temp === 0) return "text-gray-500"
|
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>
|
<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">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
{disk.temperature > 0 && (
|
{renderDiskTempOrStandby(disk)}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
{(disk.observations_count ?? 0) > 0 && (
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||||
<Info className="h-3 w-3" />
|
<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>
|
<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">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
{disk.temperature > 0 && (
|
{renderDiskTempOrStandby(disk)}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
{(disk.observations_count ?? 0) > 0 && (
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||||
<Info className="h-3 w-3" />
|
<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>
|
<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">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
{disk.temperature > 0 && (
|
{renderDiskTempOrStandby(disk)}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
{(disk.observations_count ?? 0) > 0 && (
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||||
<Info className="h-3 w-3" />
|
<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>
|
<Badge className="bg-orange-500/10 text-orange-400 border-orange-500/20 text-[10px] px-1.5">USB</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{disk.temperature > 0 && (
|
{renderDiskTempOrStandby(disk)}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{getHealthBadge(disk.health)}
|
{getHealthBadge(disk.health)}
|
||||||
{(disk.observations_count ?? 0) > 0 && (
|
{(disk.observations_count ?? 0) > 0 && (
|
||||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
<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]:
|
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
|
||||||
"""Build the smartctl invocation for a given probe key."""
|
"""Build the smartctl invocation for a given probe key.
|
||||||
cmd = ["smartctl", "-A", "-j"]
|
|
||||||
|
`-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":
|
if probe != "auto":
|
||||||
cmd.extend(["-d", probe])
|
cmd.extend(["-d", probe])
|
||||||
cmd.append(f"/dev/{disk_name}")
|
cmd.append(f"/dev/{disk_name}")
|
||||||
return cmd
|
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]:
|
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:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
_smartctl_cmd_for(disk_name, probe),
|
_smartctl_cmd_for(disk_name, probe),
|
||||||
capture_output=True, text=True, timeout=_SMARTCTL_TIMEOUT,
|
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
|
# smartctl returns non-zero on warnings (bit 0x40 etc.) even when
|
||||||
# JSON is fully populated. Don't gate on returncode — parse the
|
# JSON is fully populated. Don't gate on returncode — parse the
|
||||||
# body regardless.
|
# body regardless.
|
||||||
@@ -264,8 +290,24 @@ def _try_probe(disk_name: str, probe: str) -> Optional[float]:
|
|||||||
return None
|
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]:
|
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:
|
Caching strategy:
|
||||||
* If we've previously found a working probe for this disk we go
|
* 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.
|
and update the cache with whatever does work.
|
||||||
* Disks that never report a temperature get rate-limited via the
|
* Disks that never report a temperature get rate-limited via the
|
||||||
backoff table so we don't smartctl them every minute forever.
|
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()
|
now = time.time()
|
||||||
|
|
||||||
@@ -285,10 +330,23 @@ def _read_temperature(disk_name: str) -> Optional[float]:
|
|||||||
if retry_at > now:
|
if retry_at > now:
|
||||||
return None
|
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.
|
# Fast path: cached probe.
|
||||||
if cached_probe is not None:
|
if cached_probe is not None:
|
||||||
temp = _try_probe(disk_name, cached_probe)
|
temp = _handle(_try_probe(disk_name, cached_probe))
|
||||||
if temp is not None and temp > 0:
|
if temp == _STANDBY:
|
||||||
|
return None # parked — skip update, don't penalise
|
||||||
|
if temp is not None:
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
_disk_fail_counts.pop(disk_name, None)
|
_disk_fail_counts.pop(disk_name, None)
|
||||||
_disk_fail_backoff.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"):
|
for probe in ("auto", "nvme", "ata", "sat"):
|
||||||
if probe == cached_probe:
|
if probe == cached_probe:
|
||||||
continue # already tried above
|
continue # already tried above
|
||||||
temp = _try_probe(disk_name, probe)
|
temp = _handle(_try_probe(disk_name, probe))
|
||||||
if temp is not None and temp > 0:
|
if temp == _STANDBY:
|
||||||
|
return None
|
||||||
|
if temp is not None:
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
_disk_probe_cache[disk_name] = probe
|
_disk_probe_cache[disk_name] = probe
|
||||||
_disk_fail_counts.pop(disk_name, None)
|
_disk_fail_counts.pop(disk_name, None)
|
||||||
_disk_fail_backoff.pop(disk_name, None)
|
_disk_fail_backoff.pop(disk_name, None)
|
||||||
return temp
|
return temp
|
||||||
|
|
||||||
# All probes failed. Bump the failure counter and trip the backoff
|
# All probes failed (none returned a temperature OR standby). Bump
|
||||||
# if we've crossed the threshold.
|
# the failure counter and trip the backoff if threshold crossed.
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
n = _disk_fail_counts.get(disk_name, 0) + 1
|
n = _disk_fail_counts.get(disk_name, 0) + 1
|
||||||
_disk_fail_counts[disk_name] = n
|
_disk_fail_counts[disk_name] = n
|
||||||
|
|||||||
@@ -2989,12 +2989,25 @@ def get_storage_info():
|
|||||||
is_system_disk = sys_info.get('is_system', False)
|
is_system_disk = sys_info.get('is_system', False)
|
||||||
system_usage = sys_info.get('usage', [])
|
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] = {
|
physical_disks[disk_name] = {
|
||||||
'name': disk_name,
|
'name': disk_name,
|
||||||
'size': disk_size_kb, # In KB for formatMemory() in Storage Summary
|
'size': disk_size_kb, # In KB for formatMemory() in Storage Summary
|
||||||
'size_formatted': size_str, # Added formatted size string for Storage section
|
'size_formatted': size_str, # Added formatted size string for Storage section
|
||||||
'size_bytes': disk_size_bytes,
|
'size_bytes': disk_size_bytes,
|
||||||
'temperature': smart_data.get('temperature', 0),
|
'temperature': smart_data.get('temperature', 0),
|
||||||
|
'standby': in_standby,
|
||||||
'health': smart_data.get('health', 'unknown'),
|
'health': smart_data.get('health', 'unknown'),
|
||||||
'power_on_hours': smart_data.get('power_on_hours', 0),
|
'power_on_hours': smart_data.get('power_on_hours', 0),
|
||||||
'smart_status': smart_data.get('smart_status', 'unknown'),
|
'smart_status': smart_data.get('smart_status', 'unknown'),
|
||||||
|
|||||||
@@ -2424,10 +2424,23 @@ class HealthMonitor:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
|
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(
|
result = subprocess.run(
|
||||||
['smartctl', '--health', '-j', dev_path],
|
['smartctl', '-n', 'standby', '--health', '-j', dev_path],
|
||||||
capture_output=True, text=True, timeout=5
|
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
|
import json as _json
|
||||||
data = _json.loads(result.stdout)
|
data = _json.loads(result.stdout)
|
||||||
passed = data.get('smart_status', {}).get('passed', None)
|
passed = data.get('smart_status', {}).get('passed', None)
|
||||||
|
|||||||
Reference in New Issue
Block a user