991 lines
27 KiB
JavaScript
991 lines
27 KiB
JavaScript
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',
|
|
'raw_dir_bluray',
|
|
'raw_dir_dvd',
|
|
'raw_dir_cd',
|
|
'movie_dir',
|
|
'movie_dir_bluray',
|
|
'movie_dir_dvd',
|
|
'log_dir'
|
|
]);
|
|
|
|
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 normalizePathSetting(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
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 = {}) {
|
|
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
|
const bluray = settingsService.resolveEffectiveToolSettings(sourceMap, 'bluray');
|
|
const dvd = settingsService.resolveEffectiveToolSettings(sourceMap, 'dvd');
|
|
const cd = settingsService.resolveEffectiveToolSettings(sourceMap, 'cd');
|
|
const blurayRawPath = normalizePathSetting(bluray?.raw_dir);
|
|
const dvdRawPath = normalizePathSetting(dvd?.raw_dir);
|
|
const cdRawPath = normalizePathSetting(cd?.raw_dir);
|
|
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
|
|
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
|
|
const monitoredPaths = [];
|
|
|
|
const addPath = (key, label, monitoredPath) => {
|
|
monitoredPaths.push({
|
|
key,
|
|
label,
|
|
path: normalizePathSetting(monitoredPath)
|
|
});
|
|
};
|
|
|
|
if (blurayRawPath && dvdRawPath && blurayRawPath !== dvdRawPath) {
|
|
addPath('raw_dir_bluray', 'RAW-Verzeichnis (Blu-ray)', blurayRawPath);
|
|
addPath('raw_dir_dvd', 'RAW-Verzeichnis (DVD)', dvdRawPath);
|
|
} else {
|
|
addPath('raw_dir', 'RAW-Verzeichnis', blurayRawPath || dvdRawPath || sourceMap.raw_dir);
|
|
}
|
|
addPath('raw_dir_cd', 'CD-Verzeichnis', cdRawPath || sourceMap.raw_dir_cd);
|
|
|
|
if (blurayMoviePath && dvdMoviePath && blurayMoviePath !== dvdMoviePath) {
|
|
addPath('movie_dir_bluray', 'Movie-Verzeichnis (Blu-ray)', blurayMoviePath);
|
|
addPath('movie_dir_dvd', 'Movie-Verzeichnis (DVD)', dvdMoviePath);
|
|
} else {
|
|
addPath('movie_dir', 'Movie-Verzeichnis', blurayMoviePath || dvdMoviePath || sourceMap.movie_dir);
|
|
}
|
|
|
|
addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir);
|
|
|
|
return monitoredPaths;
|
|
}
|
|
|
|
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();
|