diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 6afcd8e1..d079df9d 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -15,6 +15,7 @@ import { type GPU, type PCIDevice, type StorageDevice, + type Temperature, type CoralTPU, type UsbDevice, fetcher as swrFetcher, @@ -241,11 +242,13 @@ const translateDeviceType = (type: string | null | undefined, t: TFunction): str return text } -const groupAndSortTemperatures = (temperatures: any[]) => { +const groupAndSortTemperatures = (temperatures: Temperature[]) => { const groups = { CPU: [] as any[], GPU: [] as any[], NVME: [] as any[], + HDD: [] as any[], + SSD: [] as any[], PCI: [] as any[], OTHER: [] as any[], } @@ -254,7 +257,21 @@ const groupAndSortTemperatures = (temperatures: any[]) => { const nameLower = temp.name.toLowerCase() const adapterLower = temp.adapter?.toLowerCase() || "" - if (nameLower.includes("cpu") || nameLower.includes("core") || nameLower.includes("package")) { + if (temp.type === "cpu") { + groups.CPU.push(temp) + } else if (temp.type === "gpu") { + groups.GPU.push(temp) + } else if (temp.type === "nvme") { + groups.NVME.push(temp) + } else if (temp.type === "hdd") { + groups.HDD.push(temp) + } else if (temp.type === "ssd") { + groups.SSD.push(temp) + } else if (temp.type === "pci") { + groups.PCI.push(temp) + } else if (temp.type) { + groups.OTHER.push(temp) + } else if (nameLower.includes("cpu") || nameLower.includes("core") || nameLower.includes("package")) { groups.CPU.push(temp) } else if (nameLower.includes("gpu") || adapterLower.includes("gpu")) { groups.GPU.push(temp) @@ -270,6 +287,58 @@ const groupAndSortTemperatures = (temperatures: any[]) => { return groups } +const StorageTemperatureGroup = ({ title, temperatures }: { title: string; temperatures: Temperature[] }) => { + if (temperatures.length === 0) return null + + return ( +
1 ? "md:col-span-2" : ""}> +
+ +

{title}

+ + {temperatures.length} + +
+
1 ? "md:grid-cols-2" : ""}`}> + {temperatures.map((temp, index) => { + const percentage = temp.critical && temp.critical > 0 ? (temp.current / temp.critical) * 100 : temp.current + const isHot = temp.current > (temp.high || 80) + const isCritical = temp.current > (temp.critical || 90) + const devices = temp.devices?.length ? temp.devices : temp.device ? [temp.device] : [] + + return ( +
+
+ + {temp.model || temp.name} + + + {temp.current.toFixed(1)}°C + +
+
+
+
+ {devices.length > 0 ? ( + + {devices.map((device) => `/dev/${device}`).join(" · ")} + + ) : ( + temp.adapter && {temp.adapter} + )} +
+ ) + })} +
+
+ ) +} + export default function Hardware() { const t = useT() @@ -834,52 +903,18 @@ export default function Hardware() {
)} - {/* NVME Sensors */} - {groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 0 && ( -
1 ? "md:col-span-2" : "" - } - > -
- -

NVME

- - {groupAndSortTemperatures(hardwareData.temperatures).NVME.length} - -
-
1 ? "md:grid-cols-2" : ""}`} - > - {groupAndSortTemperatures(hardwareData.temperatures).NVME.map((temp, index) => { - const percentage = - temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 - const isHot = temp.current > (temp.high || 80) - const isCritical = temp.current > (temp.critical || 90) - - return ( -
-
- {temp.name} - - {temp.current.toFixed(1)}°C - -
-
-
-
- {temp.adapter && {temp.adapter}} -
- ) - })} -
-
- )} + + + {/* PCI Sensors */} {groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 0 && ( diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 65213e91..f2abd051 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -135,6 +135,7 @@ chmod +x "$APP_DIR/usr/bin/update_docker_engine.py" 2>/dev/null || true cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found" cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found" cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found" +cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found" cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found" cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found" cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found" diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 046ea35d..81deb39c 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -77,6 +77,7 @@ from smartctl_resolver import ( # noqa: E402 smartctl_probe_types, smartctl_type_args, ) +from temperature_sensor_resolver import get_storage_temperatures # noqa: E402 from flask_script_runner import script_runner import threading from proxmox_storage_monitor import classify_storage_state, proxmox_storage_monitor @@ -6907,6 +6908,30 @@ def identify_temperature_sensor(sensor_name, adapter, chip_name=None): return sensor_name +def classify_temperature_sensor(sensor_name, adapter, chip_name=None, identified_name=None): + sensor_lower = sensor_name.lower() + adapter_lower = adapter.lower() if adapter else "" + chip_lower = chip_name.lower() if chip_name else "" + identified_lower = identified_name.lower() if identified_name else "" + combined = f"{sensor_lower} {adapter_lower} {chip_lower} {identified_lower}" + + if "nvme" in chip_lower or "nvme" in sensor_lower or "composite" in sensor_lower or "nvme" in identified_lower: + return "nvme" + if "drivetemp" in chip_lower or "sata" in sensor_lower or "ata" in sensor_lower or "sata" in identified_lower: + return "storage" + if identified_lower.startswith("cpu") or any( + cpu_label in sensor_lower for cpu_label in ["cpu", "package", "tctl", "tccd", "core"] + ): + return "cpu" + if identified_lower.startswith("gpu") or any( + gpu_driver in combined for gpu_driver in ["nouveau", "amdgpu", "radeon", "i915"] + ): + return "gpu" + if identified_lower.startswith("pci") or ("pci" in adapter_lower and "temp" in sensor_lower): + return "pci" + return "other" + + def identify_fan(sensor_name, adapter, chip_name=None): """Identify what a fan sensor corresponds to, using hardware_monitor for GPU detection""" sensor_lower = sensor_name.lower() @@ -7071,6 +7096,13 @@ def get_temperature_info(): """Get detailed temperature information from sensors command""" temperatures = [] power_meter = None + covered_storage_kinds = set() + + try: + storage_temperatures, covered_storage_kinds = get_storage_temperatures() + temperatures.extend(storage_temperatures) + except Exception: + covered_storage_kinds = set() try: sensors_output = get_cached_sensors_output() @@ -7119,6 +7151,13 @@ def get_temperature_info(): # Parse temperature sensors elif '°C' in value_part or 'C' in value_part: try: + chip_lower = current_chip.lower() if current_chip else "" + if ( + ("nvme" in chip_lower and "nvme" in covered_storage_kinds) + or ("drivetemp" in chip_lower and "drivetemp" in covered_storage_kinds) + ): + continue + # Extract temperature value temp_match = re.search(r'([+-]?[\d.]+)\s*°?C', value_part) if temp_match: @@ -7138,6 +7177,12 @@ def get_temperature_info(): continue identified_name = identify_temperature_sensor(sensor_name, current_adapter, current_chip) + sensor_type = classify_temperature_sensor( + sensor_name, + current_adapter, + current_chip, + identified_name, + ) temperatures.append({ 'name': identified_name, @@ -7145,7 +7190,8 @@ def get_temperature_info(): 'current': temp_value, 'high': high_value, 'critical': crit_value, - 'adapter': current_adapter + 'adapter': current_adapter, + 'type': sensor_type }) except ValueError: pass diff --git a/AppImage/scripts/temperature_sensor_resolver.py b/AppImage/scripts/temperature_sensor_resolver.py new file mode 100644 index 00000000..f218dbd8 --- /dev/null +++ b/AppImage/scripts/temperature_sensor_resolver.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 + +import os +import re +import time +from pathlib import Path + + +_TOPOLOGY_CACHE = { + "key": None, + "time": 0.0, + "entries": [], +} +_TOPOLOGY_CACHE_TTL = 60 + + +def _read_text(path): + try: + return Path(path).read_text(encoding="utf-8", errors="replace").strip() + except (OSError, ValueError): + return "" + + +def _read_temperature(path): + value = _read_text(path) + if not value: + return 0.0 + try: + return float(value) / 1000.0 + except ValueError: + return 0.0 + + +def _related_path_distance(first, second): + first_parts = Path(first).parts + second_parts = Path(second).parts + common = 0 + for first_part, second_part in zip(first_parts, second_parts): + if first_part != second_part: + break + common += 1 + if common == min(len(first_parts), len(second_parts)): + return (len(first_parts) - common) + (len(second_parts) - common) + return None + + +def _block_devices_for_hwmon(hwmon_device, block_root): + exact = [] + related = [] + try: + block_entries = sorted(Path(block_root).iterdir(), key=lambda item: item.name) + except OSError: + return [] + + hwmon_real = os.path.realpath(hwmon_device) + for block_entry in block_entries: + block_device = block_entry / "device" + if not block_device.exists(): + continue + block_real = os.path.realpath(block_device) + if block_real == hwmon_real: + exact.append(block_entry.name) + continue + distance = _related_path_distance(hwmon_real, block_real) + if distance is not None: + related.append((distance, block_entry.name)) + + if exact: + return exact + if not related: + return [] + + minimum_distance = min(distance for distance, _ in related) + return [name for distance, name in related if distance == minimum_distance] + + +def _device_metadata(device_name, hwmon_kind, block_root): + block_path = Path(block_root) / device_name + rotational = _read_text(block_path / "queue" / "rotational") + if hwmon_kind == "nvme": + sensor_type = "nvme" + elif rotational == "1": + sensor_type = "hdd" + elif rotational == "0": + sensor_type = "ssd" + else: + sensor_type = "storage" + + return { + "type": sensor_type, + "model": _read_text(block_path / "device" / "model"), + "serial": _read_text(block_path / "device" / "serial"), + } + + +def _build_topology(hwmon_root, block_root): + entries = [] + try: + hwmon_entries = sorted(Path(hwmon_root).glob("hwmon*"), key=lambda item: item.name) + except OSError: + return entries + + for hwmon_path in hwmon_entries: + hwmon_kind = _read_text(hwmon_path / "name").lower() + if hwmon_kind not in {"nvme", "drivetemp"}: + continue + + devices = _block_devices_for_hwmon(hwmon_path / "device", block_root) + device = devices[0] if devices else "" + metadata = _device_metadata(device, hwmon_kind, block_root) if device else { + "type": "nvme" if hwmon_kind == "nvme" else "storage", + "model": "", + "serial": "", + } + entries.append({ + "hwmon_path": str(hwmon_path), + "kind": hwmon_kind, + "device": device, + "devices": devices, + **metadata, + }) + return entries + + +def _get_topology(hwmon_root, block_root, cache_ttl): + cache_key = (os.path.realpath(hwmon_root), os.path.realpath(block_root)) + now = time.monotonic() + if ( + _TOPOLOGY_CACHE["key"] == cache_key + and now - _TOPOLOGY_CACHE["time"] < cache_ttl + ): + return _TOPOLOGY_CACHE["entries"] + + entries = _build_topology(hwmon_root, block_root) + _TOPOLOGY_CACHE.update({ + "key": cache_key, + "time": now, + "entries": entries, + }) + return entries + + +def clear_temperature_topology_cache(): + _TOPOLOGY_CACHE.update({"key": None, "time": 0.0, "entries": []}) + + +def _temperature_inputs(entry): + hwmon_path = Path(entry["hwmon_path"]) + inputs = sorted(hwmon_path.glob("temp*_input"), key=lambda item: item.name) + if entry["kind"] == "drivetemp": + preferred = [item for item in inputs if item.name == "temp1_input"] + return preferred or inputs[:1] + + selected = [] + for input_path in inputs: + match = re.fullmatch(r"temp(\d+)_input", input_path.name) + if not match: + continue + index = match.group(1) + label = _read_text(hwmon_path / f"temp{index}_label") + if label.lower() == "composite" or (not label and index == "1"): + selected.append(input_path) + return selected + + +def get_storage_temperatures( + hwmon_root="/sys/class/hwmon", + block_root="/sys/block", + cache_ttl=_TOPOLOGY_CACHE_TTL, +): + temperatures = [] + expected_by_kind = {} + resolved_by_kind = {} + + for entry in _get_topology(hwmon_root, block_root, cache_ttl): + inputs = _temperature_inputs(entry) + if not inputs: + continue + expected_by_kind[entry["kind"]] = expected_by_kind.get(entry["kind"], 0) + 1 + + input_path = inputs[0] + match = re.fullmatch(r"temp(\d+)_input", input_path.name) + if not match: + continue + index = match.group(1) + current = _read_temperature(input_path) + if current == 0.0 and not _read_text(input_path): + # Sensor present but no readable temperature (e.g. an NVMe in + # low power state that leaves temp1_input empty). Mark the + # kind as covered anyway so the lm-sensors fallback does not + # add a duplicate entry for the same hwmon device. + resolved_by_kind[entry["kind"]] = resolved_by_kind.get(entry["kind"], 0) + 1 + continue + + sensor_type = entry["type"] + if sensor_type == "nvme": + name = "NVMe SSD" + adapter = "PCI adapter" + elif sensor_type == "hdd": + name = "HDD" + adapter = "SCSI adapter" + elif sensor_type == "ssd": + name = "SSD" + adapter = "SCSI adapter" + else: + name = "Storage device" + adapter = "SCSI adapter" + + label = _read_text(Path(entry["hwmon_path"]) / f"temp{index}_label") + temperatures.append({ + "name": name, + "original_name": label or f"temp{index}", + "current": current, + "high": _read_temperature(Path(entry["hwmon_path"]) / f"temp{index}_max"), + "critical": _read_temperature(Path(entry["hwmon_path"]) / f"temp{index}_crit"), + "adapter": adapter, + "type": sensor_type, + "device": entry["device"], + "devices": entry["devices"], + "model": entry["model"], + "serial": entry["serial"], + }) + resolved_by_kind[entry["kind"]] = resolved_by_kind.get(entry["kind"], 0) + 1 + + covered_kinds = { + kind + for kind, expected in expected_by_kind.items() + if resolved_by_kind.get(kind, 0) == expected + } + temperatures.sort(key=lambda item: (item["type"], item["device"], item["name"])) + return temperatures, covered_kinds diff --git a/AppImage/scripts/tests/test_temperature_sensor_resolver.py b/AppImage/scripts/tests/test_temperature_sensor_resolver.py new file mode 100644 index 00000000..e4f66584 --- /dev/null +++ b/AppImage/scripts/tests/test_temperature_sensor_resolver.py @@ -0,0 +1,160 @@ +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from temperature_sensor_resolver import ( # noqa: E402 + clear_temperature_topology_cache, + get_storage_temperatures, +) + + +class TemperatureSensorResolverTests(unittest.TestCase): + def setUp(self): + clear_temperature_topology_cache() + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.hwmon_root = self.root / "sys" / "class" / "hwmon" + self.block_root = self.root / "sys" / "block" + self.devices_root = self.root / "sys" / "devices" + self.hwmon_root.mkdir(parents=True) + self.block_root.mkdir(parents=True) + self.devices_root.mkdir(parents=True) + + def tearDown(self): + self.temporary.cleanup() + + def _add_sensor(self, hwmon_index, kind, device_path, values): + hwmon_path = self.hwmon_root / f"hwmon{hwmon_index}" + hwmon_path.mkdir() + (hwmon_path / "name").write_text(kind, encoding="utf-8") + (hwmon_path / "device").symlink_to(device_path) + for filename, value in values.items(): + (hwmon_path / filename).write_text(str(value), encoding="utf-8") + + def _add_block_device(self, name, device_path, rotational, model, serial): + block_path = self.block_root / name + (block_path / "queue").mkdir(parents=True) + (block_path / "queue" / "rotational").write_text(str(rotational), encoding="utf-8") + (block_path / "device").symlink_to(device_path) + (device_path / "model").write_text(model, encoding="utf-8") + (device_path / "serial").write_text(serial, encoding="utf-8") + + def test_nvme_sensor_resolves_to_its_exact_namespace(self): + controller = self.devices_root / "pci0000:00" / "0000:01:00.0" / "nvme" / "nvme0" + controller.mkdir(parents=True) + self._add_block_device("nvme0n1", controller, 0, "WD Red SN700", "NVME-SERIAL") + self._add_sensor(0, "nvme", controller, { + "temp1_label": "Composite", + "temp1_input": 17900, + "temp1_max": 64800, + "temp1_crit": 79800, + "temp2_label": "Sensor 1", + "temp2_input": 22000, + }) + + temperatures, covered = get_storage_temperatures( + self.hwmon_root, + self.block_root, + cache_ttl=0, + ) + + self.assertEqual(len(temperatures), 1) + self.assertEqual(temperatures[0]["type"], "nvme") + self.assertEqual(temperatures[0]["device"], "nvme0n1") + self.assertEqual(temperatures[0]["model"], "WD Red SN700") + self.assertEqual(temperatures[0]["serial"], "NVME-SERIAL") + self.assertEqual(temperatures[0]["current"], 17.9) + self.assertEqual(temperatures[0]["high"], 64.8) + self.assertEqual(temperatures[0]["critical"], 79.8) + self.assertEqual(covered, {"nvme"}) + + def test_multiple_nvme_controllers_keep_their_own_device_identity(self): + first = self.devices_root / "pci0000:00" / "0000:01:00.0" / "nvme" / "nvme0" + second = self.devices_root / "pci0000:00" / "0000:04:00.0" / "nvme" / "nvme1" + first.mkdir(parents=True) + second.mkdir(parents=True) + self._add_block_device("nvme0n1", first, 0, "NVMe One", "SERIAL-ONE") + self._add_block_device("nvme1n1", second, 0, "NVMe Two", "SERIAL-TWO") + self._add_sensor(0, "nvme", second, { + "temp1_label": "Composite", + "temp1_input": 42000, + }) + self._add_sensor(1, "nvme", first, { + "temp1_label": "Composite", + "temp1_input": 37000, + }) + + temperatures, _ = get_storage_temperatures( + self.hwmon_root, + self.block_root, + cache_ttl=0, + ) + + by_device = {item["device"]: item for item in temperatures} + self.assertEqual(by_device["nvme0n1"]["model"], "NVMe One") + self.assertEqual(by_device["nvme0n1"]["current"], 37.0) + self.assertEqual(by_device["nvme1n1"]["model"], "NVMe Two") + self.assertEqual(by_device["nvme1n1"]["current"], 42.0) + + def test_drivetemp_uses_rotational_flag_to_identify_hdd(self): + drive = self.devices_root / "pci0000:00" / "ata4" / "host3" / "target3:0:0" / "3:0:0:0" + drive.mkdir(parents=True) + self._add_block_device("sda", drive, 1, "ST4000VN006", "HDD-SERIAL") + self._add_sensor(1, "drivetemp", drive, { + "temp1_input": 35000, + "temp1_max": 60000, + "temp1_crit": 85000, + }) + + temperatures, covered = get_storage_temperatures( + self.hwmon_root, + self.block_root, + cache_ttl=0, + ) + + self.assertEqual(temperatures[0]["name"], "HDD") + self.assertEqual(temperatures[0]["type"], "hdd") + self.assertEqual(temperatures[0]["device"], "sda") + self.assertEqual(temperatures[0]["original_name"], "temp1") + self.assertEqual(covered, {"drivetemp"}) + + def test_drivetemp_does_not_assume_every_drive_is_rotational(self): + drive = self.devices_root / "pci0000:00" / "ata5" / "host4" / "target4:0:0" / "4:0:0:0" + drive.mkdir(parents=True) + self._add_block_device("sdb", drive, 0, "SATA SSD", "SSD-SERIAL") + self._add_sensor(2, "drivetemp", drive, {"temp1_input": 31000}) + + temperatures, _ = get_storage_temperatures( + self.hwmon_root, + self.block_root, + cache_ttl=0, + ) + + self.assertEqual(temperatures[0]["type"], "ssd") + self.assertEqual(temperatures[0]["device"], "sdb") + + def test_topology_cache_does_not_cache_temperature_values(self): + controller = self.devices_root / "pci0000:00" / "0000:01:00.0" / "nvme" / "nvme0" + controller.mkdir(parents=True) + self._add_block_device("nvme0n1", controller, 0, "NVMe Live", "SERIAL-LIVE") + self._add_sensor(0, "nvme", controller, { + "temp1_label": "Composite", + "temp1_input": 25000, + }) + + first, _ = get_storage_temperatures(self.hwmon_root, self.block_root, cache_ttl=60) + (self.hwmon_root / "hwmon0" / "temp1_input").write_text("29000", encoding="utf-8") + second, _ = get_storage_temperatures(self.hwmon_root, self.block_root, cache_ttl=60) + + self.assertEqual(first[0]["current"], 25.0) + self.assertEqual(second[0]["current"], 29.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/AppImage/types/hardware.ts b/AppImage/types/hardware.ts index 092b1dd1..10a3af1a 100644 --- a/AppImage/types/hardware.ts +++ b/AppImage/types/hardware.ts @@ -7,6 +7,11 @@ export interface Temperature { high?: number critical?: number adapter?: string + type?: "cpu" | "gpu" | "nvme" | "hdd" | "ssd" | "storage" | "pci" | "other" + device?: string + devices?: string[] + model?: string + serial?: string } export interface PowerMeter { diff --git a/web/messages/en/docs/monitor/dashboard/hardware.json b/web/messages/en/docs/monitor/dashboard/hardware.json index 11df20db..52009e57 100644 --- a/web/messages/en/docs/monitor/dashboard/hardware.json +++ b/web/messages/en/docs/monitor/dashboard/hardware.json @@ -33,11 +33,13 @@ "memoryTitle": "Memory Modules", "memoryBody": "One row per populated slot from dmidecode: slot label, module size, type (DDR4 / DDR5 / ECC variants), speed (configured and rated), manufacturer, part number and serial. Empty slots are listed greyed-out so you can see the upgrade headroom at a glance.", "thermalTitle": "Thermal Monitoring", - "thermalIntro": "Five sub-blocks, each fed by lm-sensors + tool-specific scrapers. A block hides itself when no sensors are reported in that category.", + "thermalIntro": "Seven sub-blocks, fed by lm-sensors, sysfs and tool-specific scrapers. Storage hwmon sensors are correlated with the physical block device, so each detected drive includes its model and /dev/... path. A block hides itself when no sensors are reported in that category.", "thermalItems": [ "CPU — package and per-core temperatures.", "GPU — discrete-GPU sensors via nvidia-smi / amdgpu_top / Intel iGPU. Includes hot-spot and memory-junction when the driver exposes them.", - "NVME — composite + per-sensor temperatures from nvme.", + "NVME — controller composite temperature associated with its exact NVMe namespace.", + "HDDdrivetemp readings for rotational block devices.", + "SSDdrivetemp readings for non-rotational SATA/SAS block devices.", "PCI — sensors that surface as PCI-attached devices (HBAs, network cards with internal sensors).", "OTHER — chipset, VRM, ambient sensors that don't fit elsewhere." ] @@ -182,7 +184,7 @@ { "section": "Live sensor values", "endpoint": "/api/hardware/live", - "source": "sensors (lm-sensors), package temperatures, fan RPM. Refreshed each request." + "source": "sensors (lm-sensors) plus /sys/class/hwmon and /sys/block for storage-device identity. Temperature values remain live; only the hwmon-to-device topology is cached for 60 seconds." }, { "section": "CPU temperature history", diff --git a/web/messages/es/docs/monitor/dashboard/hardware.json b/web/messages/es/docs/monitor/dashboard/hardware.json index 12ed076c..71995ba0 100644 --- a/web/messages/es/docs/monitor/dashboard/hardware.json +++ b/web/messages/es/docs/monitor/dashboard/hardware.json @@ -33,11 +33,13 @@ "memoryTitle": "Memory Modules", "memoryBody": "Una fila por slot poblado desde dmidecode: etiqueta del slot, tamaño del módulo, tipo (DDR4 / DDR5 / variantes ECC), velocidad (configurada y nominal), fabricante, part number y serial. Los slots vacíos se listan atenuados para que veas de un vistazo el margen de ampliación.", "thermalTitle": "Thermal Monitoring", - "thermalIntro": "Cinco sub-bloques, cada uno alimentado por lm-sensors + scrapers específicos por herramienta. Un bloque se oculta cuando no hay sensores reportados en esa categoría.", + "thermalIntro": "Siete sub-bloques alimentados por lm-sensors, sysfs y scrapers específicos por herramienta. Los sensores hwmon de almacenamiento se correlacionan con el dispositivo de bloques físico, por lo que cada unidad detectada incluye su modelo y la ruta /dev/.... Un bloque permanece oculto cuando no hay sensores en esa categoría.", "thermalItems": [ "CPU — temperaturas de package y por core.", "GPU — sensores de GPU discreta vía nvidia-smi / amdgpu_top / iGPU Intel. Incluye hot-spot y memory-junction cuando el driver los expone.", - "NVME — temperaturas composite + por sensor de nvme.", + "NVME — temperatura composite del controlador asociada a su namespace NVMe exacto.", + "HDD — lecturas de drivetemp para dispositivos de bloques rotacionales.", + "SSD — lecturas de drivetemp para dispositivos de bloques SATA/SAS no rotacionales.", "PCI — sensores que aparecen como dispositivos conectados a PCI (HBAs, tarjetas de red con sensores internos).", "OTHER — chipset, VRM, sensores ambiente que no encajan en otro sitio." ] @@ -182,7 +184,7 @@ { "section": "Valores de sensores en vivo", "endpoint": "/api/hardware/live", - "source": "sensors (lm-sensors), temperaturas de package, RPM de ventiladores. Refrescado en cada petición." + "source": "sensors (lm-sensors) junto con /sys/class/hwmon y /sys/block para identificar los dispositivos de almacenamiento. Las temperaturas permanecen en vivo; solo la topología hwmon-dispositivo se guarda en caché durante 60 segundos." }, { "section": "Historial de temperatura de CPU",