mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
fix: one source for the disk type and a config volume for SearXNG
This commit is contained in:
@@ -804,7 +804,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
||||
</div>
|
||||
)}
|
||||
{/* Reallocated only meaningful on rotating disks. */}
|
||||
{disk.reallocated_sectors !== undefined && (disk.rotation_rate ?? 0) > 0 && (
|
||||
{disk.reallocated_sectors !== undefined && resolveDiskType(disk.name, disk.rotation_rate) === "HDD" && (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t("storage.reallocatedShort")}
|
||||
@@ -860,12 +860,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
||||
// Resolve disk class → threshold pair from the user-configurable
|
||||
// backend (single source of truth). The semantics: temp BELOW warn
|
||||
// is green, between warn and hot is amber, hot or above is red.
|
||||
let cls: keyof DiskTempMap = "HDD"
|
||||
if (diskName?.startsWith("nvme")) {
|
||||
cls = "NVMe"
|
||||
} else if (!rotationRate || rotationRate === 0) {
|
||||
cls = "SSD"
|
||||
}
|
||||
const cls: keyof DiskTempMap = resolveDiskType(diskName ?? "", rotationRate)
|
||||
const t = dtThresholds[cls]
|
||||
if (temp >= t.hot) return "text-red-500"
|
||||
if (temp >= t.warn) return "text-yellow-500"
|
||||
@@ -896,9 +891,9 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
||||
return t("storage.duration.daysShort", { days: totalDays })
|
||||
}
|
||||
|
||||
const formatRotationRate = (rpm: number | undefined) => {
|
||||
if (!rpm || rpm === 0) return "SSD"
|
||||
return `${rpm.toLocaleString()} RPM`
|
||||
const formatRotationRate = (diskName: string, rpm: number | undefined) => {
|
||||
if (getDiskType(diskName, rpm) !== "HDD") return "SSD"
|
||||
return rpm && rpm > 0 ? `${rpm.toLocaleString()} RPM` : t("common.notAvailable")
|
||||
}
|
||||
|
||||
// Thin wrapper over the shared classifier so the rest of the file
|
||||
@@ -2626,7 +2621,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } =
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.rotationRate")}</p>
|
||||
<p className="font-medium">{formatRotationRate(selectedDisk.rotation_rate)}</p>
|
||||
<p className="font-medium">{formatRotationRate(selectedDisk.name, selectedDisk.rotation_rate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.powerCycles")}</p>
|
||||
@@ -2871,14 +2866,12 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
||||
|
||||
// Determine disk type (SAS detected via backend flag or connection_type)
|
||||
const isSasDisk = sd?.is_sas === true || disk.connection_type === 'sas'
|
||||
let diskType = "HDD"
|
||||
if (disk.name.startsWith("nvme")) {
|
||||
diskType = "NVMe"
|
||||
} else if (isSasDisk) {
|
||||
let diskType: string = resolveDiskType(disk.name, disk.rotation_rate)
|
||||
if (diskType !== "NVMe" && isSasDisk) {
|
||||
diskType = "SAS"
|
||||
} else if (!disk.rotation_rate || disk.rotation_rate === 0) {
|
||||
diskType = "SSD"
|
||||
}
|
||||
const rpm = disk.rotation_rate && disk.rotation_rate > 0 ? disk.rotation_rate : 0
|
||||
const known = (value?: string) => (value && value !== "Unknown" ? value : undefined)
|
||||
|
||||
// Health status styling
|
||||
const healthStatus = String(testStatus.smart_status || (testStatus.smart_data?.smart_status) || 'unknown')
|
||||
@@ -3824,11 +3817,11 @@ function pmxPrint(){
|
||||
<div class="grid-4">
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.model")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${disk.model || sd?.model || t("app.unknown")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${known(disk.model) || known(sd?.model) || t("app.unknown")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.serial")}</div>
|
||||
<div class="card-value" style="font-size:11px;font-family:monospace;">${disk.serial || sd?.serial || t("app.unknown")}</div>
|
||||
<div class="card-value" style="font-size:11px;font-family:monospace;">${known(disk.serial) || known(sd?.serial) || t("app.unknown")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.capacity")}</div>
|
||||
@@ -3836,7 +3829,7 @@ function pmxPrint(){
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.type")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}</div>
|
||||
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (rpm ? `SAS ${rpm} RPM` : disk.rotation_rate === -1 ? 'SAS HDD' : 'SAS SSD') : diskType === 'HDD' && rpm ? `HDD ${rpm} RPM` : diskType}</div>
|
||||
</div>
|
||||
</div>
|
||||
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
|
||||
|
||||
@@ -27,6 +27,8 @@ import sys
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import disk_identity
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
# Disk entries in a guest configuration: rootfs and mpN for containers,
|
||||
@@ -630,12 +632,16 @@ def _physical_disks(ctx) -> list[dict[str, Any]]:
|
||||
size = row.get("SIZE", "")
|
||||
name = row.get("NAME", "")
|
||||
health = smart.get(name) or {}
|
||||
# The Monitor's rotation when it read the disk, so both agree.
|
||||
rate = health.get("rotation_rate")
|
||||
if not isinstance(rate, int):
|
||||
rate = -1 if row.get("ROTA") == "1" else 0
|
||||
disks.append({
|
||||
"name": name,
|
||||
"model": (row.get("MODEL") or "").strip(),
|
||||
"serial": (row.get("SERIAL") or "").strip(),
|
||||
"size_bytes": int(size) if size.isdigit() else None,
|
||||
"rotational": row.get("ROTA") == "1",
|
||||
"rotational": disk_identity.media_type(name, rate) == "HDD",
|
||||
"bus": (row.get("TRAN") or "").strip(),
|
||||
"health": health.get("smart_status"),
|
||||
"temperature": health.get("temperature"),
|
||||
|
||||
@@ -15,6 +15,7 @@ import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
_LSBLK_TIMEOUT = 5
|
||||
_SYS_BLOCK = "/sys/block"
|
||||
_FIELD_RE = re.compile(r'(\w+)="([^"]*)"')
|
||||
_SKIP_PREFIXES = ("loop", "zd", "nbd", "ram", "sr")
|
||||
|
||||
@@ -69,6 +70,27 @@ def list_physical_disks() -> List[Dict[str, Any]]:
|
||||
return disks
|
||||
|
||||
|
||||
def rotation_rate(name: str, reported: Any = None) -> int:
|
||||
"""The rotation every view of a disk shows: the RPM smartctl reported,
|
||||
0 meaning solid state, or else the kernel's flag, where -1 is a spinning
|
||||
disk of unknown speed. What smartctl reported wins, because USB-SATA
|
||||
bridges mark every disk behind them as rotational."""
|
||||
if isinstance(reported, int) and not isinstance(reported, bool) and reported >= 0:
|
||||
return reported
|
||||
try:
|
||||
with open(f"{_SYS_BLOCK}/{name}/queue/rotational") as flag:
|
||||
return -1 if flag.read().strip() == "1" else 0
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def media_type(name: str, rate: int) -> str:
|
||||
"""NVMe, SSD or HDD, from a rotation as ``rotation_rate`` returns it."""
|
||||
if name.startswith("nvme"):
|
||||
return "NVMe"
|
||||
return "HDD" if rate else "SSD"
|
||||
|
||||
|
||||
def names_for_keys(keys) -> set:
|
||||
"""Current kernel names of the disks whose identity is in ``keys``."""
|
||||
if not keys:
|
||||
|
||||
@@ -52,6 +52,7 @@ import time
|
||||
import threading
|
||||
import urllib.parse
|
||||
import uuid
|
||||
import disk_identity
|
||||
import hardware_monitor
|
||||
from health_persistence import health_persistence
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -5263,11 +5264,7 @@ def _smart_identity_payload(disk_name):
|
||||
base['serial'] = value.strip()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if Path(f'/sys/block/{disk_name}/queue/rotational').read_text().strip() == '1':
|
||||
base['rotation_rate'] = -1 # HDD, RPM unknown
|
||||
except OSError:
|
||||
pass
|
||||
base['rotation_rate'] = disk_identity.rotation_rate(disk_name)
|
||||
return base
|
||||
|
||||
|
||||
@@ -5327,7 +5324,8 @@ def get_smart_data(disk_name):
|
||||
|
||||
retry_at = _smart_fail_backoff.get(disk_name, 0)
|
||||
if retry_at > now:
|
||||
return dict(cached[1]) if cached else _smart_default_payload()
|
||||
# smartctl keeps failing: show what the host knows of the disk.
|
||||
return dict(cached[1]) if cached else _smart_identity_payload(disk_name)
|
||||
|
||||
result = _get_smart_data_uncached(disk_name)
|
||||
|
||||
@@ -5801,6 +5799,11 @@ def _get_smart_data_uncached(disk_name):
|
||||
# print(f"[v0] Health: CRITICAL (SMART failed)")
|
||||
pass
|
||||
|
||||
# Without an answer from smartctl, the kernel's flag; the temperature
|
||||
# class below depends on it.
|
||||
if not smart_data['_rotation_known']:
|
||||
smart_data['rotation_rate'] = disk_identity.rotation_rate(disk_name)
|
||||
|
||||
# Temperature-based health (only if we have a valid temperature)
|
||||
# Thresholds differ by disk class (HDD/SSD/NVMe/SAS) and are
|
||||
# user-configurable via health_thresholds; the values below are
|
||||
@@ -5831,27 +5834,6 @@ def _get_smart_data_uncached(disk_name):
|
||||
smart_data['health'] = 'critical'
|
||||
elif temp > warn:
|
||||
smart_data['health'] = 'warning'
|
||||
|
||||
# Fallback: ask the kernel only when smartctl had no opinion.
|
||||
# USB-SATA enclosures (ASM105x family etc.) advertise
|
||||
# rotational=1 to the kernel even when there's an SSD behind
|
||||
# them, so trusting /sys here without first checking what
|
||||
# smartctl said would flip a correctly-identified SSD back to
|
||||
# HDD. The previous condition (`rotation_rate == 0`) couldn't
|
||||
# tell "smartctl confirmed SSD" from "smartctl never spoke",
|
||||
# which is what bit the OCZ-SOLID2 behind an ASM105x dock on
|
||||
# the lab host (sdd showed as HDD in the UI while smartctl
|
||||
# was reporting it as SSD).
|
||||
if not smart_data['_rotation_known']:
|
||||
try:
|
||||
rotational_path = f"/sys/block/{disk_name}/queue/rotational"
|
||||
if os.path.exists(rotational_path):
|
||||
with open(rotational_path, 'r') as f:
|
||||
rotational = int(f.read().strip())
|
||||
if rotational == 1:
|
||||
smart_data['rotation_rate'] = -1 # HDD, RPM unknown
|
||||
except Exception:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
# print(f"[v0] ERROR: smartctl not found - install smartmontools for disk monitoring.")
|
||||
pass
|
||||
@@ -11523,7 +11505,7 @@ def api_smart_status(disk_name):
|
||||
'temperature': temp,
|
||||
'power_on_hours': poh,
|
||||
'power_cycles': cycles,
|
||||
'rotation_rate': data.get('rotation_rate', 0),
|
||||
'rotation_rate': disk_identity.rotation_rate(disk_name, data.get('rotation_rate')),
|
||||
'form_factor': form_factor,
|
||||
'physical_block_size': physical_block_size,
|
||||
'trim_supported': trim_supported,
|
||||
|
||||
@@ -23,6 +23,7 @@ A special thank you goes to [@Vaso73](https://github.com/Vaso73), [@f3rs3n](http
|
||||
- **The Ollama entry describes what the image provides** ([PR #566](https://github.com/MacRimi/ProxMenux/pull/566), contributed by [@jcastro](https://github.com/jcastro)): its HTTP API on port 11434, with its link and startup check on `/api/version`.
|
||||
- **An update clears the snapshot an interrupted backup leaves** ([#565](https://github.com/MacRimi/ProxMenux/issues/565), reported by [@kiosvantra](https://github.com/kiosvantra)). A snapshot made by the user stops the update, because a rebuilt container cannot keep it.
|
||||
- **A container that ignores the shutdown signal is stopped after 60 seconds**, as `docker stop` does, during an update and when an interrupted operation is recovered.
|
||||
- **SearXNG keeps its configuration in its own volume**, `/etc/searxng`, so Modify and updates accept it.
|
||||
|
||||
## 🖥️ Monitor — Proxmox updates and pending reboots
|
||||
|
||||
@@ -59,6 +60,7 @@ A special thank you goes to [@Vaso73](https://github.com/Vaso73), [@f3rs3n](http
|
||||
- **Bitwarden recognizes the 2FA field on the Monitor login** ([#570](https://github.com/MacRimi/ProxMenux/issues/570), reported by [@shtefko](https://github.com/shtefko)): the backup code hint keeps its text and place, outside the group of the field.
|
||||
- **Secure Gateway installs on Proxmox hosts on BTRFS** ([#564](https://github.com/MacRimi/ProxMenux/issues/564), reported by [@78v5](https://github.com/78v5)): the Alpine template goes to an active storage that holds container templates.
|
||||
- **A disk I/O error from the kernel is recorded on its disk**, with its serial number, also when the error names a partition.
|
||||
- **Every view of a disk shows the same type**: the Storage page, the disk details, the SMART report and the Audit & Report inventory take it from one source. A hard disk smartctl cannot read keeps its type, model and serial number, and a hard disk that reports no speed shows as HDD.
|
||||
- **The Monitor installs as an app (PWA) from the browser**: it serves its own web app manifest and service worker, with the 192 and 512 px icons Chrome requires.
|
||||
- **The Y-axis titles stay inside the overview charts** ([PR #539](https://github.com/MacRimi/ProxMenux/pull/539), contributed by [@f3rs3n](https://github.com/f3rs3n)).
|
||||
- **The application version-tracking catalog builds from the current Helper-Scripts repositories**.
|
||||
|
||||
@@ -85,6 +85,22 @@
|
||||
"backup": true,
|
||||
"default_size_gb": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "volume-1",
|
||||
"container_path": "/etc/searxng",
|
||||
"compose_source_example": "./config",
|
||||
"read_only": false,
|
||||
"required": true,
|
||||
"installation_choice": [
|
||||
"managed-volume",
|
||||
"host-bind"
|
||||
],
|
||||
"default": "managed-volume",
|
||||
"managed_volume": {
|
||||
"backup": true,
|
||||
"default_size_gb": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
@@ -127,7 +143,8 @@
|
||||
"8080:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"./cache:/var/cache/searxng"
|
||||
"./cache:/var/cache/searxng",
|
||||
"./config:/etc/searxng"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -153,6 +170,17 @@
|
||||
"shared_with_other_lxc": false,
|
||||
"source_path": null,
|
||||
"source_path_prompt": null
|
||||
},
|
||||
{
|
||||
"id": "searxng-volume-1",
|
||||
"service": "searxng",
|
||||
"container_path": "/etc/searxng",
|
||||
"mode": "managed-volume",
|
||||
"user_selectable": false,
|
||||
"backup": true,
|
||||
"shared_with_other_lxc": false,
|
||||
"source_path": null,
|
||||
"source_path_prompt": null
|
||||
}
|
||||
],
|
||||
"orchestration": {
|
||||
@@ -378,6 +406,12 @@
|
||||
"remove_lost_found": true,
|
||||
"owner_strategy": "mapped-application-user",
|
||||
"only_when_mount_type": "managed-volume"
|
||||
},
|
||||
{
|
||||
"container_path": "/etc/searxng",
|
||||
"remove_lost_found": true,
|
||||
"owner_strategy": "mapped-application-user",
|
||||
"only_when_mount_type": "managed-volume"
|
||||
}
|
||||
],
|
||||
"startup_healthcheck": {
|
||||
|
||||
@@ -85,6 +85,22 @@
|
||||
"backup": true,
|
||||
"default_size_gb": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "volume-1",
|
||||
"container_path": "/etc/searxng",
|
||||
"compose_source_example": "./config",
|
||||
"read_only": false,
|
||||
"required": true,
|
||||
"installation_choice": [
|
||||
"managed-volume",
|
||||
"host-bind"
|
||||
],
|
||||
"default": "managed-volume",
|
||||
"managed_volume": {
|
||||
"backup": true,
|
||||
"default_size_gb": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
@@ -127,7 +143,8 @@
|
||||
"8080:8080"
|
||||
],
|
||||
"volumes": [
|
||||
"./cache:/var/cache/searxng"
|
||||
"./cache:/var/cache/searxng",
|
||||
"./config:/etc/searxng"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -153,6 +170,17 @@
|
||||
"shared_with_other_lxc": false,
|
||||
"source_path": null,
|
||||
"source_path_prompt": null
|
||||
},
|
||||
{
|
||||
"id": "searxng-volume-1",
|
||||
"service": "searxng",
|
||||
"container_path": "/etc/searxng",
|
||||
"mode": "managed-volume",
|
||||
"user_selectable": false,
|
||||
"backup": true,
|
||||
"shared_with_other_lxc": false,
|
||||
"source_path": null,
|
||||
"source_path_prompt": null
|
||||
}
|
||||
],
|
||||
"orchestration": {
|
||||
@@ -378,6 +406,12 @@
|
||||
"remove_lost_found": true,
|
||||
"owner_strategy": "mapped-application-user",
|
||||
"only_when_mount_type": "managed-volume"
|
||||
},
|
||||
{
|
||||
"container_path": "/etc/searxng",
|
||||
"remove_lost_found": true,
|
||||
"owner_strategy": "mapped-application-user",
|
||||
"only_when_mount_type": "managed-volume"
|
||||
}
|
||||
],
|
||||
"startup_healthcheck": {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Every view of a disk takes its rotation and type from disk_identity."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
|
||||
import disk_identity
|
||||
import audit_inventory
|
||||
|
||||
|
||||
class DiskMediaTypeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.root = Path(folder.name)
|
||||
for name, flag in (("sda", "1"), ("sdb", "0"), ("nvme0n1", "0")):
|
||||
(self.root / name / "queue").mkdir(parents=True)
|
||||
(self.root / name / "queue/rotational").write_text(flag + "\n")
|
||||
sys_block = patch.object(disk_identity, "_SYS_BLOCK", str(self.root))
|
||||
sys_block.start()
|
||||
self.addCleanup(sys_block.stop)
|
||||
|
||||
def test_the_kernel_flag_answers_when_smartctl_did_not(self):
|
||||
self.assertEqual(disk_identity.rotation_rate("sda"), -1)
|
||||
self.assertEqual(disk_identity.rotation_rate("sdb"), 0)
|
||||
self.assertEqual(disk_identity.rotation_rate("sdz"), 0)
|
||||
|
||||
def test_what_smartctl_reported_wins(self):
|
||||
self.assertEqual(disk_identity.rotation_rate("sda", 0), 0)
|
||||
self.assertEqual(disk_identity.rotation_rate("sdb", 5400), 5400)
|
||||
self.assertEqual(disk_identity.rotation_rate("sda", None), -1)
|
||||
|
||||
def test_media_type(self):
|
||||
self.assertEqual(disk_identity.media_type("sda", -1), "HDD")
|
||||
self.assertEqual(disk_identity.media_type("sda", 7200), "HDD")
|
||||
self.assertEqual(disk_identity.media_type("sdb", 0), "SSD")
|
||||
self.assertEqual(disk_identity.media_type("nvme0n1", 0), "NVMe")
|
||||
|
||||
def test_the_inventory_agrees_with_the_monitor(self):
|
||||
rows = ('NAME="sda" MODEL="WDC WD7500BPVT" SERIAL="WX1" SIZE="750156374016" ROTA="1" TRAN="usb" TYPE="disk"\n'
|
||||
'NAME="sdb" MODEL="Samsung SSD" SERIAL="S1" SIZE="500107862016" ROTA="0" TRAN="sata" TYPE="disk"\n')
|
||||
ctx = types.SimpleNamespace(run=lambda *a, **k: (0, rows),
|
||||
monitor_snapshot={"smart": {"/dev/sda": [0, {"rotation_rate": 0}]}})
|
||||
with patch.object(audit_inventory, "_disk_observations", return_value={}):
|
||||
disks = {d["name"]: d["rotational"] for d in audit_inventory._physical_disks(ctx)}
|
||||
# The USB bridge marks sda rotational; smartctl, through the Monitor, says solid state.
|
||||
self.assertEqual(disks, {"sda": False, "sdb": False})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -209,10 +209,16 @@ class QuietDiskIdentityTests(unittest.TestCase):
|
||||
self.read = Mock(return_value={"model": "WDC WD40EFRX-68N32N0", "serial": "WD-WCC7K4FV37J0",
|
||||
"rotation_rate": 5400, "health": "healthy", "temperature": 31})
|
||||
self.cache = {}
|
||||
self.backoff = {}
|
||||
import disk_identity
|
||||
sys_block = patch.object(disk_identity, "_SYS_BLOCK", str(root / "sys/block"))
|
||||
sys_block.start()
|
||||
self.addCleanup(sys_block.stop)
|
||||
namespace = {"Path": lambda path: root / path.lstrip("/"), "time": __import__("time"),
|
||||
"disk_identity": disk_identity,
|
||||
"_smart_result_cache": self.cache, "_SMART_RESULT_TTL": 30, "_hdd_in_standby": self.standby,
|
||||
"_get_smart_data_uncached": self.read, "_smart_data_useful": lambda data: data.get("model") != "Unknown",
|
||||
"_smart_fail_backoff": {}, "_smart_fail_counts": {}, "_smart_probe_cache": {},
|
||||
"_smart_fail_backoff": self.backoff, "_smart_fail_counts": {}, "_smart_probe_cache": {},
|
||||
"_SMART_FAIL_THRESHOLD": 3, "_SMART_FAIL_BACKOFF_SEC": 3600}
|
||||
tree = ast.parse((SCRIPTS / "flask_server.py").read_text())
|
||||
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef)
|
||||
@@ -240,6 +246,13 @@ class QuietDiskIdentityTests(unittest.TestCase):
|
||||
self.read.assert_not_called()
|
||||
self.assertEqual((data["model"], data["rotation_rate"], data["excluded"]), ("WDC WD40EFRX-68N32N0", -1, True))
|
||||
|
||||
def test_a_disk_smartctl_keeps_failing_on_keeps_its_identity(self):
|
||||
self.backoff["sdb"] = __import__("time").time() + 3600
|
||||
data = self.get("sdb")
|
||||
self.read.assert_not_called()
|
||||
self.assertEqual((data["model"], data["serial"], data["rotation_rate"]),
|
||||
("WDC WD40EFRX-68N32N0", "WD-WCC7K4FV37J0", -1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -55,12 +55,15 @@ console.log('PASS: saved-data badge guards and actual provider fallback/translat
|
||||
const report = source.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'openSmartReport');
|
||||
assert.ok(report);
|
||||
let reportHtml;
|
||||
const diskTypeModule = {};
|
||||
new Function('exports', compile(read('AppImage/lib/disk-type.ts')))(diskTypeModule);
|
||||
const openReport = new Function('window', 'Blob', 'URL', 'setTimeout', 'getDiskTempThresholdsSync', 'getNvmeSmartAttributeKey',
|
||||
'resolveDiskType',
|
||||
compile('const APP_VERSION = "fixture";\n' + emit(report, source)) + '\nreturn openSmartReport;')(
|
||||
{location: {origin: 'http://fixture.invalid'}, open() {}},
|
||||
class { constructor(parts) { reportHtml = parts.join(''); } },
|
||||
{createObjectURL() { return 'blob:fixture'; }, revokeObjectURL() {}}, () => {},
|
||||
() => ({warning: 55, critical: 65}), () => undefined);
|
||||
() => ({warning: 55, critical: 65}), () => undefined, diskTypeModule.getDiskType);
|
||||
const expectedReport = {
|
||||
passedAssessment: 'The supplied SMART status is PASSED. This status alone does not establish that every attribute is within range or that the disk is free of faults. Power-on time shown: {uptime}. Temperature shown: {temperature}. {sectors}',
|
||||
noReallocatedSectorsReported: 'The supplied reallocated-sector value is zero or unavailable; this does not establish that there are no bad sectors.',
|
||||
@@ -107,5 +110,15 @@ translated.recommendations = {passedTitle: 'TRANSLATED_REC_TITLE', passedText: '
|
||||
const localized = renderReport({}, 'passed', 'synthetic', {...catalogs, synthetic: {storage: {smartReport: translated}}});
|
||||
for (const [, value] of leaves(translated)) assert.ok(localized.includes(value.split(' {')[0]), value);
|
||||
assert.ok(renderReport({name: 'nvme0n1'}, 'passed', 'en', catalogs, {nvme_raw: {critical_warning: 0, media_errors: 7}}).includes(expectedReport.passedMeaning));
|
||||
// One classifier for the report and the Storage page: a disk the kernel marks
|
||||
// rotational without an RPM is an HDD, and a real model wins over "Unknown".
|
||||
const typeCard = html => html.match(/labels\.type[^]*?card-value[^>]*>([^<]*)</)?.[1] ?? html.match(/>Type<\/div>\s*<div class="card-value"[^>]*>([^<]*)</)?.[1];
|
||||
assert.equal(typeCard(renderReport({rotation_rate: -1})), 'HDD');
|
||||
assert.equal(typeCard(renderReport({rotation_rate: 7200})), 'HDD 7200 RPM');
|
||||
assert.equal(typeCard(renderReport({rotation_rate: 0})), 'SSD');
|
||||
assert.ok(!renderReport({rotation_rate: -1}).includes('-1 RPM'));
|
||||
assert.equal(typeCard(renderReport({rotation_rate: -1, connection_type: 'sas'})), 'SAS HDD');
|
||||
const unknownModel = renderReport({model: 'Unknown', serial: 'Unknown'}, 'passed', 'en', catalogs, {model: 'WDC WD7500BPVT', serial: 'WX11A1234'});
|
||||
assert.ok(unknownModel.includes('WDC WD7500BPVT') && unknownModel.includes('WX11A1234'));
|
||||
if (process.env.STORAGE_REPORT_HTML) fs.writeFileSync(process.env.STORAGE_REPORT_HTML, renderReport());
|
||||
console.log('PASS: actual printable report, passed/failed/unknown/warning, missing/zero/nonzero sectors, NVMe errors and provider fallback');
|
||||
|
||||
@@ -23,6 +23,7 @@ Un agradecimiento especial a [@Vaso73](https://github.com/Vaso73), [@f3rs3n](htt
|
||||
- **La entrada de Ollama describe lo que ofrece la imagen** ([PR #566](https://github.com/MacRimi/ProxMenux/pull/566), aportado por [@jcastro](https://github.com/jcastro)): su API HTTP en el puerto 11434, con el enlace y la comprobación de arranque en `/api/version`.
|
||||
- **Una actualización elimina el snapshot que deja un backup interrumpido** ([#565](https://github.com/MacRimi/ProxMenux/issues/565), reportado por [@kiosvantra](https://github.com/kiosvantra)). Un snapshot hecho por el usuario detiene la actualización, porque un contenedor reconstruido no puede conservarlo.
|
||||
- **Un contenedor que ignora la señal de apagado se detiene a los 60 segundos**, como hace `docker stop`, durante una actualización y al recuperar una operación interrumpida.
|
||||
- **SearXNG guarda su configuración en su propio volumen**, `/etc/searxng`, de modo que Modificar y las actualizaciones lo aceptan.
|
||||
|
||||
## 🖥️ Monitor — actualizaciones de Proxmox y reinicios pendientes
|
||||
|
||||
@@ -59,6 +60,7 @@ Un agradecimiento especial a [@Vaso73](https://github.com/Vaso73), [@f3rs3n](htt
|
||||
- **Bitwarden reconoce el campo 2FA en el inicio de sesión del Monitor** ([#570](https://github.com/MacRimi/ProxMenux/issues/570), reportado por [@shtefko](https://github.com/shtefko)): la pista del código de backup mantiene su texto y su lugar, fuera del grupo del campo.
|
||||
- **Secure Gateway se instala en hosts Proxmox sobre BTRFS** ([#564](https://github.com/MacRimi/ProxMenux/issues/564), reportado por [@78v5](https://github.com/78v5)): la plantilla de Alpine va a un almacenamiento activo que admite plantillas de contenedor.
|
||||
- **Un error de E/S de disco del kernel se registra en su disco**, con su número de serie, también cuando el error nombra una partición.
|
||||
- **Todas las vistas de un disco muestran el mismo tipo**: la página de almacenamiento, el detalle del disco, el informe SMART y el inventario de Audit & Report lo toman de una sola fuente. Un disco duro que smartctl no consigue leer conserva su tipo, modelo y número de serie, y un disco duro que no informa de su velocidad aparece como HDD.
|
||||
- **El Monitor se instala como aplicación (PWA) desde el navegador**: sirve su propio manifiesto de aplicación web y su service worker, con los iconos de 192 y 512 px que exige Chrome.
|
||||
- **Los títulos del eje Y se mantienen dentro de los gráficos de la vista general** ([PR #539](https://github.com/MacRimi/ProxMenux/pull/539), aportado por [@f3rs3n](https://github.com/f3rs3n)).
|
||||
- **El catálogo de seguimiento de versiones de aplicaciones se genera desde los repositorios actuales de Helper-Scripts**.
|
||||
|
||||
Reference in New Issue
Block a user