diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 867fc0fb..dbbe99a3 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -804,7 +804,7 @@ export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = )} {/* 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" && (
{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 } =

{t("storage.rotationRate")}

-

{formatRotationRate(selectedDisk.rotation_rate)}

+

{formatRotationRate(selectedDisk.name, selectedDisk.rotation_rate)}

{t("storage.powerCycles")}

@@ -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(){
${tSmart("labels.model")}
-
${disk.model || sd?.model || t("app.unknown")}
+
${known(disk.model) || known(sd?.model) || t("app.unknown")}
${tSmart("labels.serial")}
-
${disk.serial || sd?.serial || t("app.unknown")}
+
${known(disk.serial) || known(sd?.serial) || t("app.unknown")}
${tSmart("labels.capacity")}
@@ -3836,7 +3829,7 @@ function pmxPrint(){
${tSmart("labels.type")}
-
${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}
+
${diskType === 'SAS' ? (rpm ? `SAS ${rpm} RPM` : disk.rotation_rate === -1 ? 'SAS HDD' : 'SAS SSD') : diskType === 'HDD' && rpm ? `HDD ${rpm} RPM` : diskType}
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? ` diff --git a/AppImage/scripts/audit_inventory.py b/AppImage/scripts/audit_inventory.py index 79074d85..8cc24ad8 100644 --- a/AppImage/scripts/audit_inventory.py +++ b/AppImage/scripts/audit_inventory.py @@ -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"), diff --git a/AppImage/scripts/disk_identity.py b/AppImage/scripts/disk_identity.py index e6b652cd..426432f4 100644 --- a/AppImage/scripts/disk_identity.py +++ b/AppImage/scripts/disk_identity.py @@ -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: diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index de42aa2a..e617932c 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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, diff --git a/CHANGELOG.md b/CHANGELOG.md index aae2065f..e4266899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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**. diff --git a/oci/catalog/apps/searxng.json b/oci/catalog/apps/searxng.json index 4997ef90..10c04ea3 100644 --- a/oci/catalog/apps/searxng.json +++ b/oci/catalog/apps/searxng.json @@ -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": { diff --git a/oci/catalog/curated/searxng.json b/oci/catalog/curated/searxng.json index 4997ef90..10c04ea3 100644 --- a/oci/catalog/curated/searxng.json +++ b/oci/catalog/curated/searxng.json @@ -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": { diff --git a/tests/test_disk_media_type.py b/tests/test_disk_media_type.py new file mode 100644 index 00000000..021333a3 --- /dev/null +++ b/tests/test_disk_media_type.py @@ -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() diff --git a/tests/test_lvm_disk_read_policy.py b/tests/test_lvm_disk_read_policy.py index 634f38de..5c90911b 100644 --- a/tests/test_lvm_disk_read_policy.py +++ b/tests/test_lvm_disk_read_policy.py @@ -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() diff --git a/tests/test_storage_messages.cjs b/tests/test_storage_messages.cjs index 9f35c3f7..885d4c47 100644 --- a/tests/test_storage_messages.cjs +++ b/tests/test_storage_messages.cjs @@ -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[^>]*>([^<]*)Type<\/div>\s*
]*>([^<]*)