This commit is contained in:
2026-03-05 19:34:01 +00:00
parent c3d1df42b0
commit 6836892907
22 changed files with 1869 additions and 302 deletions

View File

@@ -86,6 +86,30 @@ const defaultSchema = [
validation: { minLength: 1 },
orderIndex: 120
},
{
key: 'hardware_monitoring_enabled',
category: 'Monitoring',
label: 'Hardware Monitoring aktiviert',
type: 'boolean',
required: 1,
description: 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).',
defaultValue: 'true',
options: [],
validation: {},
orderIndex: 130
},
{
key: 'hardware_monitoring_interval_ms',
category: 'Monitoring',
label: 'Hardware Monitoring Intervall (ms)',
type: 'number',
required: 1,
description: 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.',
defaultValue: '5000',
options: [],
validation: { min: 1000, max: 60000 },
orderIndex: 140
},
{
key: 'makemkv_command',
category: 'Tools',

View File

@@ -13,6 +13,7 @@ const historyRoutes = require('./routes/historyRoutes');
const wsService = require('./services/websocketService');
const pipelineService = require('./services/pipelineService');
const diskDetectionService = require('./services/diskDetectionService');
const hardwareMonitorService = require('./services/hardwareMonitorService');
const logger = require('./services/logger').child('BOOT');
const { errorToMeta } = require('./utils/errorMeta');
@@ -38,6 +39,7 @@ async function start() {
const server = http.createServer(app);
wsService.init(server);
await hardwareMonitorService.init();
diskDetectionService.on('discInserted', (device) => {
logger.info('disk:inserted:event', { device });
@@ -69,6 +71,7 @@ async function start() {
const shutdown = () => {
logger.warn('backend:shutdown:received');
diskDetectionService.stop();
hardwareMonitorService.stop();
server.close(() => {
logger.warn('backend:shutdown:completed');
process.exit(0);

View File

@@ -2,6 +2,7 @@ const express = require('express');
const asyncHandler = require('../middleware/asyncHandler');
const pipelineService = require('../services/pipelineService');
const diskDetectionService = require('../services/diskDetectionService');
const hardwareMonitorService = require('../services/hardwareMonitorService');
const logger = require('../services/logger').child('PIPELINE_ROUTE');
const router = express.Router();
@@ -10,7 +11,10 @@ router.get(
'/state',
asyncHandler(async (req, res) => {
logger.debug('get:state', { reqId: req.reqId });
res.json({ pipeline: pipelineService.getSnapshot() });
res.json({
pipeline: pipelineService.getSnapshot(),
hardwareMonitoring: hardwareMonitorService.getSnapshot()
});
})
);

View File

@@ -5,6 +5,7 @@ const scriptService = require('../services/scriptService');
const notificationService = require('../services/notificationService');
const pipelineService = require('../services/pipelineService');
const wsService = require('../services/websocketService');
const hardwareMonitorService = require('../services/hardwareMonitorService');
const logger = require('../services/logger').child('SETTINGS_ROUTE');
const router = express.Router();
@@ -140,6 +141,18 @@ router.put(
message: error?.message || 'unknown'
};
}
try {
await hardwareMonitorService.handleSettingsChanged([key]);
} catch (error) {
logger.warn('put:setting:hardware-monitor-refresh-failed', {
reqId: req.reqId,
key,
error: {
name: error?.name,
message: error?.message
}
});
}
wsService.broadcast('SETTINGS_UPDATED', updated);
res.json({ setting: updated, reviewRefresh });
@@ -182,6 +195,17 @@ router.put(
message: error?.message || 'unknown'
};
}
try {
await hardwareMonitorService.handleSettingsChanged(changes.map((item) => item.key));
} catch (error) {
logger.warn('put:settings:bulk:hardware-monitor-refresh-failed', {
reqId: req.reqId,
error: {
name: error?.name,
message: error?.message
}
});
}
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
res.json({ changes, reviewRefresh });

View File

@@ -0,0 +1,953 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const settingsService = require('./settingsService');
const wsService = require('./websocketService');
const logger = require('./logger').child('HWMON');
const { errorToMeta } = require('../utils/errorMeta');
const execFileAsync = promisify(execFile);
const DEFAULT_INTERVAL_MS = 5000;
const MIN_INTERVAL_MS = 1000;
const MAX_INTERVAL_MS = 60000;
const DF_TIMEOUT_MS = 1800;
const SENSORS_TIMEOUT_MS = 1800;
const NVIDIA_SMI_TIMEOUT_MS = 1800;
const RELEVANT_SETTINGS_KEYS = new Set([
'hardware_monitoring_enabled',
'hardware_monitoring_interval_ms',
'raw_dir',
'movie_dir',
'log_dir'
]);
const MONITORED_PATH_DEFINITIONS = [
{ key: 'raw_dir', label: 'RAW-Verzeichnis' },
{ key: 'movie_dir', label: 'Movie-Verzeichnis' },
{ key: 'log_dir', label: 'Log-Verzeichnis' }
];
function nowIso() {
return new Date().toISOString();
}
function toBoolean(value) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) {
return false;
}
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
return false;
}
return Boolean(normalized);
}
function clampIntervalMs(rawValue) {
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) {
return DEFAULT_INTERVAL_MS;
}
const clamped = Math.max(MIN_INTERVAL_MS, Math.min(MAX_INTERVAL_MS, Math.trunc(parsed)));
return clamped || DEFAULT_INTERVAL_MS;
}
function roundNumber(rawValue, digits = 1) {
const value = Number(rawValue);
if (!Number.isFinite(value)) {
return null;
}
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function averageNumberList(values = []) {
const list = (Array.isArray(values) ? values : []).filter((value) => Number.isFinite(Number(value)));
if (list.length === 0) {
return null;
}
const sum = list.reduce((acc, value) => acc + Number(value), 0);
return sum / list.length;
}
function parseMaybeNumber(rawValue) {
if (rawValue === null || rawValue === undefined) {
return null;
}
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
return rawValue;
}
const normalized = String(rawValue).trim().replace(',', '.');
if (!normalized) {
return null;
}
const cleaned = normalized.replace(/[^0-9.+-]/g, '');
if (!cleaned) {
return null;
}
const parsed = Number(cleaned);
if (!Number.isFinite(parsed)) {
return null;
}
return parsed;
}
function normalizeTempC(rawValue) {
const parsed = parseMaybeNumber(rawValue);
if (!Number.isFinite(parsed)) {
return null;
}
let celsius = parsed;
if (Math.abs(celsius) > 500) {
celsius = celsius / 1000;
}
if (!Number.isFinite(celsius) || celsius <= -40 || celsius >= 160) {
return null;
}
return roundNumber(celsius, 1);
}
function isCommandMissingError(error) {
return String(error?.code || '').toUpperCase() === 'ENOENT';
}
function readTextFileSafe(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8').trim();
} catch (_error) {
return '';
}
}
function collectTemperatureCandidates(node, pathParts = [], out = []) {
if (!node || typeof node !== 'object') {
return out;
}
for (const [key, value] of Object.entries(node)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
collectTemperatureCandidates(value, [...pathParts, key], out);
continue;
}
if (!/^temp\d+_input$/i.test(String(key || ''))) {
continue;
}
const normalizedTemp = normalizeTempC(value);
if (normalizedTemp === null) {
continue;
}
out.push({
label: [...pathParts, key].join(' / '),
value: normalizedTemp
});
}
return out;
}
function mapTemperatureCandidates(candidates = []) {
const perCoreSamples = new Map();
const packageSamples = [];
const genericSamples = [];
for (const entry of Array.isArray(candidates) ? candidates : []) {
const value = Number(entry?.value);
if (!Number.isFinite(value)) {
continue;
}
const label = String(entry?.label || '');
const labelLower = label.toLowerCase();
const coreMatch = labelLower.match(/\bcore\s*([0-9]+)\b/);
if (coreMatch) {
const index = Number(coreMatch[1]);
if (Number.isFinite(index) && index >= 0) {
const list = perCoreSamples.get(index) || [];
list.push(value);
perCoreSamples.set(index, list);
continue;
}
}
if (/package id|tdie|tctl|cpu package|physical id/.test(labelLower)) {
packageSamples.push(value);
continue;
}
genericSamples.push(value);
}
const perCore = Array.from(perCoreSamples.entries())
.sort((a, b) => a[0] - b[0])
.map(([index, values]) => ({
index,
temperatureC: roundNumber(averageNumberList(values), 1)
}))
.filter((item) => item.temperatureC !== null);
const overallRaw = packageSamples.length > 0
? averageNumberList(packageSamples)
: (perCore.length > 0 ? averageNumberList(perCore.map((item) => item.temperatureC)) : averageNumberList(genericSamples));
const overallC = roundNumber(overallRaw, 1);
return {
overallC,
perCore,
available: Boolean(overallC !== null || perCore.length > 0)
};
}
function isLikelyCpuTemperatureLabel(label = '') {
const normalized = String(label || '').trim().toLowerCase();
if (!normalized) {
return false;
}
return /cpu|core|package|tdie|tctl|physical id|x86_pkg_temp|k10temp|zenpower|cpu-thermal|soc_thermal/.test(normalized);
}
function preferCpuTemperatureCandidates(candidates = []) {
const list = Array.isArray(candidates) ? candidates : [];
const cpuLikely = list.filter((item) => isLikelyCpuTemperatureLabel(item?.label));
return cpuLikely.length > 0 ? cpuLikely : list;
}
function parseDfStats(rawOutput) {
const lines = String(rawOutput || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length < 2) {
return null;
}
const dataLine = lines[lines.length - 1];
const columns = dataLine.split(/\s+/);
if (columns.length < 6) {
return null;
}
const totalKb = parseMaybeNumber(columns[1]);
const usedKb = parseMaybeNumber(columns[2]);
const availableKb = parseMaybeNumber(columns[3]);
const usagePercent = parseMaybeNumber(String(columns[4]).replace('%', ''));
const mountPoint = columns.slice(5).join(' ');
if (!Number.isFinite(totalKb) || !Number.isFinite(usedKb) || !Number.isFinite(availableKb)) {
return null;
}
return {
totalBytes: Math.max(0, Math.round(totalKb * 1024)),
usedBytes: Math.max(0, Math.round(usedKb * 1024)),
freeBytes: Math.max(0, Math.round(availableKb * 1024)),
usagePercent: Number.isFinite(usagePercent)
? roundNumber(usagePercent, 1)
: (totalKb > 0 ? roundNumber((usedKb / totalKb) * 100, 1) : null),
mountPoint: mountPoint || null
};
}
function parseNvidiaCsvLine(line) {
const columns = String(line || '').split(',').map((part) => part.trim());
if (columns.length < 10) {
return null;
}
const index = parseMaybeNumber(columns[0]);
const memoryUsedMiB = parseMaybeNumber(columns[5]);
const memoryTotalMiB = parseMaybeNumber(columns[6]);
return {
index: Number.isFinite(index) ? Math.trunc(index) : null,
name: columns[1] || null,
utilizationPercent: roundNumber(parseMaybeNumber(columns[2]), 1),
memoryUtilizationPercent: roundNumber(parseMaybeNumber(columns[3]), 1),
temperatureC: roundNumber(parseMaybeNumber(columns[4]), 1),
memoryUsedBytes: Number.isFinite(memoryUsedMiB) ? Math.round(memoryUsedMiB * 1024 * 1024) : null,
memoryTotalBytes: Number.isFinite(memoryTotalMiB) ? Math.round(memoryTotalMiB * 1024 * 1024) : null,
powerDrawW: roundNumber(parseMaybeNumber(columns[7]), 1),
powerLimitW: roundNumber(parseMaybeNumber(columns[8]), 1),
fanPercent: roundNumber(parseMaybeNumber(columns[9]), 1)
};
}
class HardwareMonitorService {
constructor() {
this.enabled = false;
this.intervalMs = DEFAULT_INTERVAL_MS;
this.monitoredPaths = [];
this.running = false;
this.timer = null;
this.pollInFlight = false;
this.lastCpuTimes = null;
this.sensorsCommandAvailable = null;
this.nvidiaSmiAvailable = null;
this.lastSnapshot = {
enabled: false,
intervalMs: DEFAULT_INTERVAL_MS,
updatedAt: null,
sample: null,
error: null
};
}
async init() {
await this.reloadFromSettings({
forceBroadcast: true,
forceImmediatePoll: true
});
}
stop() {
this.stopPolling();
}
getSnapshot() {
return {
enabled: Boolean(this.lastSnapshot?.enabled),
intervalMs: Number(this.lastSnapshot?.intervalMs || DEFAULT_INTERVAL_MS),
updatedAt: this.lastSnapshot?.updatedAt || null,
sample: this.lastSnapshot?.sample || null,
error: this.lastSnapshot?.error || null
};
}
async handleSettingsChanged(changedKeys = []) {
const normalizedKeys = (Array.isArray(changedKeys) ? changedKeys : [])
.map((key) => String(key || '').trim().toLowerCase())
.filter(Boolean);
if (normalizedKeys.length === 0) {
return;
}
const relevant = normalizedKeys.some((key) => RELEVANT_SETTINGS_KEYS.has(key));
if (!relevant) {
return;
}
await this.reloadFromSettings({
forceImmediatePoll: true
});
}
async reloadFromSettings(options = {}) {
const forceBroadcast = Boolean(options?.forceBroadcast);
const forceImmediatePoll = Boolean(options?.forceImmediatePoll);
let settingsMap = {};
try {
settingsMap = await settingsService.getSettingsMap();
} catch (error) {
logger.warn('settings:load:failed', { error: errorToMeta(error) });
return this.getSnapshot();
}
const nextEnabled = toBoolean(settingsMap.hardware_monitoring_enabled);
const nextIntervalMs = clampIntervalMs(settingsMap.hardware_monitoring_interval_ms);
const nextPaths = this.buildMonitoredPaths(settingsMap);
const wasEnabled = this.enabled;
const intervalChanged = nextIntervalMs !== this.intervalMs;
const pathsChanged = this.pathsSignature(this.monitoredPaths) !== this.pathsSignature(nextPaths);
this.enabled = nextEnabled;
this.intervalMs = nextIntervalMs;
this.monitoredPaths = nextPaths;
this.lastSnapshot = {
...this.lastSnapshot,
enabled: this.enabled,
intervalMs: this.intervalMs
};
if (!this.enabled) {
this.stopPolling();
this.lastSnapshot = {
enabled: false,
intervalMs: this.intervalMs,
updatedAt: nowIso(),
sample: null,
error: null
};
this.broadcastUpdate();
return this.getSnapshot();
}
if (!this.running) {
this.startPolling();
} else if (intervalChanged || pathsChanged || forceImmediatePoll || !wasEnabled) {
this.scheduleNext(25);
}
if (forceBroadcast || intervalChanged || !wasEnabled) {
this.broadcastUpdate();
}
return this.getSnapshot();
}
buildMonitoredPaths(settingsMap = {}) {
return MONITORED_PATH_DEFINITIONS.map((definition) => ({
...definition,
path: String(settingsMap?.[definition.key] || '').trim()
}));
}
pathsSignature(paths = []) {
return (Array.isArray(paths) ? paths : [])
.map((item) => `${String(item?.key || '')}:${String(item?.path || '')}`)
.join('|');
}
startPolling() {
if (this.running) {
return;
}
this.running = true;
logger.info('start', {
intervalMs: this.intervalMs,
pathKeys: this.monitoredPaths.map((item) => item.key)
});
this.scheduleNext(20);
}
stopPolling() {
const wasActive = this.running || this.pollInFlight || Boolean(this.timer);
this.running = false;
this.pollInFlight = false;
this.lastCpuTimes = null;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (wasActive) {
logger.info('stop');
}
}
scheduleNext(delayMs) {
if (!this.running) {
return;
}
if (this.timer) {
clearTimeout(this.timer);
}
const delay = Math.max(0, Math.trunc(Number(delayMs) || this.intervalMs));
this.timer = setTimeout(() => {
this.timer = null;
void this.pollOnce();
}, delay);
}
async pollOnce() {
if (!this.running || !this.enabled) {
return;
}
if (this.pollInFlight) {
this.scheduleNext(this.intervalMs);
return;
}
this.pollInFlight = true;
try {
const sample = await this.collectSample();
this.lastSnapshot = {
enabled: true,
intervalMs: this.intervalMs,
updatedAt: nowIso(),
sample,
error: null
};
this.broadcastUpdate();
} catch (error) {
logger.warn('poll:failed', { error: errorToMeta(error) });
this.lastSnapshot = {
...this.lastSnapshot,
enabled: true,
intervalMs: this.intervalMs,
updatedAt: nowIso(),
error: error?.message || 'Hardware-Monitoring fehlgeschlagen.'
};
this.broadcastUpdate();
} finally {
this.pollInFlight = false;
if (this.running && this.enabled) {
this.scheduleNext(this.intervalMs);
}
}
}
broadcastUpdate() {
wsService.broadcast('HARDWARE_MONITOR_UPDATE', this.getSnapshot());
}
async collectSample() {
const memory = this.collectMemoryMetrics();
const [cpu, gpu, storage] = await Promise.all([
this.collectCpuMetrics(),
this.collectGpuMetrics(),
this.collectStorageMetrics()
]);
return {
cpu,
memory,
gpu,
storage
};
}
collectMemoryMetrics() {
const totalBytes = Number(os.totalmem() || 0);
const freeBytes = Number(os.freemem() || 0);
const usedBytes = Math.max(0, totalBytes - freeBytes);
const usagePercent = totalBytes > 0
? roundNumber((usedBytes / totalBytes) * 100, 1)
: null;
return {
totalBytes,
usedBytes,
freeBytes,
usagePercent
};
}
getCpuTimes() {
const cpus = os.cpus() || [];
return cpus.map((cpu) => {
const times = cpu?.times || {};
const idle = Number(times.idle || 0);
const total = Object.values(times).reduce((sum, value) => sum + Number(value || 0), 0);
return { idle, total };
});
}
calculateCpuUsage(currentTimes = [], previousTimes = []) {
const perCore = [];
const coreCount = Math.min(currentTimes.length, previousTimes.length);
if (coreCount <= 0) {
return {
overallUsagePercent: null,
perCore
};
}
let totalDelta = 0;
let idleDelta = 0;
for (let index = 0; index < coreCount; index += 1) {
const prev = previousTimes[index];
const cur = currentTimes[index];
const deltaTotal = Number(cur?.total || 0) - Number(prev?.total || 0);
const deltaIdle = Number(cur?.idle || 0) - Number(prev?.idle || 0);
const usage = deltaTotal > 0
? roundNumber(((deltaTotal - deltaIdle) / deltaTotal) * 100, 1)
: null;
perCore.push({
index,
usagePercent: usage
});
if (deltaTotal > 0) {
totalDelta += deltaTotal;
idleDelta += deltaIdle;
}
}
const overallUsagePercent = totalDelta > 0
? roundNumber(((totalDelta - idleDelta) / totalDelta) * 100, 1)
: null;
return {
overallUsagePercent,
perCore
};
}
async collectCpuMetrics() {
const cpus = os.cpus() || [];
const currentTimes = this.getCpuTimes();
const usage = this.calculateCpuUsage(currentTimes, this.lastCpuTimes || []);
this.lastCpuTimes = currentTimes;
const tempMetrics = await this.collectCpuTemperatures();
const tempByCoreIndex = new Map(
(tempMetrics.perCore || []).map((item) => [Number(item.index), item.temperatureC])
);
const perCore = usage.perCore.map((entry) => ({
index: entry.index,
usagePercent: entry.usagePercent,
temperatureC: tempByCoreIndex.has(entry.index) ? tempByCoreIndex.get(entry.index) : null
}));
for (const tempEntry of tempMetrics.perCore || []) {
const index = Number(tempEntry?.index);
if (!Number.isFinite(index) || perCore.some((item) => item.index === index)) {
continue;
}
perCore.push({
index,
usagePercent: null,
temperatureC: tempEntry.temperatureC
});
}
perCore.sort((a, b) => a.index - b.index);
return {
model: cpus[0]?.model || null,
logicalCoreCount: cpus.length,
loadAverage: os.loadavg().map((value) => roundNumber(value, 2)),
overallUsagePercent: usage.overallUsagePercent,
overallTemperatureC: tempMetrics.overallC,
usageAvailable: usage.overallUsagePercent !== null,
temperatureAvailable: Boolean(tempMetrics.available),
temperatureSource: tempMetrics.source,
perCore
};
}
async collectCpuTemperatures() {
const sensors = await this.collectTempsViaSensors();
if (sensors.available) {
return sensors;
}
const hwmon = this.collectTempsViaHwmon();
if (hwmon.available) {
return hwmon;
}
const thermalZones = this.collectTempsViaThermalZones();
if (thermalZones.available) {
return thermalZones;
}
return {
source: 'none',
overallC: null,
perCore: [],
available: false
};
}
async collectTempsViaSensors() {
if (this.sensorsCommandAvailable === false) {
return {
source: 'sensors',
overallC: null,
perCore: [],
available: false
};
}
try {
const { stdout } = await execFileAsync('sensors', ['-j'], {
timeout: SENSORS_TIMEOUT_MS,
maxBuffer: 2 * 1024 * 1024
});
this.sensorsCommandAvailable = true;
const parsed = JSON.parse(String(stdout || '{}'));
const candidates = collectTemperatureCandidates(parsed);
const preferred = preferCpuTemperatureCandidates(candidates);
return {
source: 'sensors',
...mapTemperatureCandidates(preferred)
};
} catch (error) {
if (isCommandMissingError(error)) {
this.sensorsCommandAvailable = false;
}
logger.debug('cpu-temp:sensors:failed', { error: errorToMeta(error) });
return {
source: 'sensors',
overallC: null,
perCore: [],
available: false
};
}
}
collectTempsViaHwmon() {
const hwmonRoot = '/sys/class/hwmon';
if (!fs.existsSync(hwmonRoot)) {
return {
source: 'hwmon',
overallC: null,
perCore: [],
available: false
};
}
const candidates = [];
let dirs = [];
try {
dirs = fs.readdirSync(hwmonRoot, { withFileTypes: true });
} catch (_error) {
dirs = [];
}
for (const dir of dirs) {
if (!dir.isDirectory()) {
continue;
}
const basePath = path.join(hwmonRoot, dir.name);
const sensorName = readTextFileSafe(path.join(basePath, 'name')) || dir.name;
let files = [];
try {
files = fs.readdirSync(basePath);
} catch (_error) {
files = [];
}
const tempInputFiles = files.filter((file) => /^temp\d+_input$/i.test(file));
for (const fileName of tempInputFiles) {
const tempValue = normalizeTempC(readTextFileSafe(path.join(basePath, fileName)));
if (tempValue === null) {
continue;
}
const labelFile = fileName.replace('_input', '_label');
const label = readTextFileSafe(path.join(basePath, labelFile)) || fileName;
candidates.push({
label: `${sensorName} / ${label}`,
value: tempValue
});
}
}
return {
source: 'hwmon',
...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates))
};
}
collectTempsViaThermalZones() {
const thermalRoot = '/sys/class/thermal';
if (!fs.existsSync(thermalRoot)) {
return {
source: 'thermal_zone',
overallC: null,
perCore: [],
available: false
};
}
let files = [];
try {
files = fs.readdirSync(thermalRoot, { withFileTypes: true });
} catch (_error) {
files = [];
}
const candidates = [];
for (const dir of files) {
if (!dir.isDirectory() || !dir.name.startsWith('thermal_zone')) {
continue;
}
const basePath = path.join(thermalRoot, dir.name);
const tempC = normalizeTempC(readTextFileSafe(path.join(basePath, 'temp')));
if (tempC === null) {
continue;
}
const zoneType = readTextFileSafe(path.join(basePath, 'type')) || dir.name;
candidates.push({
label: `${zoneType} / temp`,
value: tempC
});
}
return {
source: 'thermal_zone',
...mapTemperatureCandidates(preferCpuTemperatureCandidates(candidates))
};
}
async collectGpuMetrics() {
if (this.nvidiaSmiAvailable === false) {
return {
source: 'nvidia-smi',
available: false,
devices: [],
message: 'nvidia-smi ist nicht verfuegbar.'
};
}
try {
const { stdout } = await execFileAsync(
'nvidia-smi',
[
'--query-gpu=index,name,utilization.gpu,utilization.memory,temperature.gpu,memory.used,memory.total,power.draw,power.limit,fan.speed',
'--format=csv,noheader,nounits'
],
{
timeout: NVIDIA_SMI_TIMEOUT_MS,
maxBuffer: 1024 * 1024
}
);
this.nvidiaSmiAvailable = true;
const devices = String(stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => parseNvidiaCsvLine(line))
.filter(Boolean);
if (devices.length === 0) {
return {
source: 'nvidia-smi',
available: false,
devices: [],
message: 'Keine GPU-Daten ueber nvidia-smi erkannt.'
};
}
return {
source: 'nvidia-smi',
available: true,
devices,
message: null
};
} catch (error) {
const commandMissing = isCommandMissingError(error);
if (commandMissing) {
this.nvidiaSmiAvailable = false;
}
logger.debug('gpu:nvidia-smi:failed', { error: errorToMeta(error) });
return {
source: 'nvidia-smi',
available: false,
devices: [],
message: commandMissing
? 'nvidia-smi ist nicht verfuegbar.'
: (String(error?.stderr || error?.message || 'GPU-Abfrage fehlgeschlagen').trim().slice(0, 220))
};
}
}
async collectStorageMetrics() {
const list = [];
for (const entry of this.monitoredPaths) {
list.push(await this.collectStorageForPath(entry));
}
return list;
}
findNearestExistingPath(inputPath) {
const normalized = String(inputPath || '').trim();
if (!normalized) {
return null;
}
let candidate = path.resolve(normalized);
for (let depth = 0; depth < 64; depth += 1) {
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(candidate);
if (!parent || parent === candidate) {
break;
}
candidate = parent;
}
if (fs.existsSync(candidate)) {
return candidate;
}
return null;
}
async collectStorageForPath(entry) {
const key = String(entry?.key || '');
const label = String(entry?.label || key || 'Pfad');
const rawPath = String(entry?.path || '').trim();
if (!rawPath) {
return {
key,
label,
path: null,
queryPath: null,
exists: false,
totalBytes: null,
usedBytes: null,
freeBytes: null,
usagePercent: null,
mountPoint: null,
note: null,
error: 'Pfad ist leer.'
};
}
const resolvedPath = path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(rawPath);
const exists = fs.existsSync(resolvedPath);
const queryPath = exists ? resolvedPath : this.findNearestExistingPath(resolvedPath);
if (!queryPath) {
return {
key,
label,
path: resolvedPath,
queryPath: null,
exists: false,
totalBytes: null,
usedBytes: null,
freeBytes: null,
usagePercent: null,
mountPoint: null,
note: null,
error: 'Pfad oder Parent existiert nicht.'
};
}
try {
const { stdout } = await execFileAsync('df', ['-Pk', queryPath], {
timeout: DF_TIMEOUT_MS,
maxBuffer: 256 * 1024
});
const parsed = parseDfStats(stdout);
if (!parsed) {
return {
key,
label,
path: resolvedPath,
queryPath,
exists,
totalBytes: null,
usedBytes: null,
freeBytes: null,
usagePercent: null,
mountPoint: null,
note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
error: 'Dateisystemdaten konnten nicht geparst werden.'
};
}
return {
key,
label,
path: resolvedPath,
queryPath,
exists,
...parsed,
note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
error: null
};
} catch (error) {
return {
key,
label,
path: resolvedPath,
queryPath,
exists,
totalBytes: null,
usedBytes: null,
freeBytes: null,
usagePercent: null,
mountPoint: null,
note: null,
error: String(error?.message || 'df Abfrage fehlgeschlagen')
};
}
}
}
module.exports = new HardwareMonitorService();