fix: one source for the disk type and a config volume for SearXNG

This commit is contained in:
MacRimi
2026-11-02 17:28:31 +01:00
parent 41081121f8
commit 9f3fffd249
11 changed files with 208 additions and 53 deletions
+13 -20
View File
@@ -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) ? `
+7 -1
View File
@@ -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"),
+22
View File
@@ -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:
+10 -28
View File
@@ -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,
+2
View File
@@ -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**.
+35 -1
View File
@@ -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": {
+35 -1
View File
@@ -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": {
+54
View File
@@ -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()
+14 -1
View File
@@ -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()
+14 -1
View File
@@ -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');
+2
View File
@@ -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**.