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.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[^>]*>([^<]*))?.[1] ?? html.match(/>Type<\/div>\s*
]*>([^<]*))?.[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');
diff --git a/web/data/changelog/es.md b/web/data/changelog/es.md
index 98dc5ee0..f898dad9 100644
--- a/web/data/changelog/es.md
+++ b/web/data/changelog/es.md
@@ -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**.