From 6836892907510ea04c1e1f4f8c6bedf1c53fc63f Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Thu, 5 Mar 2026 19:34:01 +0000 Subject: [PATCH] New Push --- backend/src/db/defaultSettings.js | 24 + backend/src/index.js | 3 + backend/src/routes/pipelineRoutes.js | 6 +- backend/src/routes/settingsRoutes.js | 24 + .../src/services/hardwareMonitorService.js | 953 ++++++++++++++++++ frontend/src/App.jsx | 7 + frontend/src/pages/DashboardPage.jsx | 313 +++++- frontend/src/styles/app.css | 298 +++++- site/api/pipeline/index.html | 135 +-- site/api/websocket/index.html | 184 ++-- site/architecture/backend/index.html | 33 +- site/architecture/frontend/index.html | 19 +- site/architecture/index.html | 2 +- site/architecture/overview/index.html | 8 +- .../settings-reference/index.html | 2 +- site/getting-started/configuration/index.html | 2 +- site/getting-started/quickstart/index.html | 42 +- site/index.html | 17 +- site/pipeline/encoding/index.html | 4 +- site/pipeline/post-encode-scripts/index.html | 47 +- site/pipeline/workflow/index.html | 46 +- site/search/search_index.json | 2 +- 22 files changed, 1869 insertions(+), 302 deletions(-) create mode 100644 backend/src/services/hardwareMonitorService.js diff --git a/backend/src/db/defaultSettings.js b/backend/src/db/defaultSettings.js index 9046e2c..90f6c6b 100644 --- a/backend/src/db/defaultSettings.js +++ b/backend/src/db/defaultSettings.js @@ -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', diff --git a/backend/src/index.js b/backend/src/index.js index bfccfd2..d1b03cd 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 8abfa97..95da306 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -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() + }); }) ); diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index c7bdf66..9231aba 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -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 }); diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js new file mode 100644 index 0000000..c227706 --- /dev/null +++ b/backend/src/services/hardwareMonitorService.js @@ -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(); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7ad42a0..5c5ddfe 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import DatabasePage from './pages/DatabasePage'; function App() { const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} }); + const [hardwareMonitoring, setHardwareMonitoring] = useState(null); const [lastDiscEvent, setLastDiscEvent] = useState(null); const location = useLocation(); const navigate = useNavigate(); @@ -16,6 +17,7 @@ function App() { const refreshPipeline = async () => { const response = await api.getPipelineState(); setPipeline(response.pipeline); + setHardwareMonitoring(response?.hardwareMonitoring || null); }; useEffect(() => { @@ -49,6 +51,10 @@ function App() { if (message.type === 'DISC_REMOVED') { setLastDiscEvent(null); } + + if (message.type === 'HARDWARE_MONITOR_UPDATE') { + setHardwareMonitoring(message.payload || null); + } } }); @@ -88,6 +94,7 @@ function App() { element={ diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 749f0c6..47eda4e 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -25,6 +25,7 @@ const dashboardStatuses = new Set([ 'CANCELLED', 'ERROR' ]); + function normalizeJobId(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { @@ -33,6 +34,80 @@ function normalizeJobId(value) { return Math.trunc(parsed); } +function formatPercent(value, digits = 1) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return 'n/a'; + } + return `${parsed.toFixed(digits)}%`; +} + +function formatTemperature(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return 'n/a'; + } + return `${parsed.toFixed(1)}°C`; +} + +function formatBytes(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return 'n/a'; + } + if (parsed === 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let unitIndex = 0; + let current = parsed; + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex += 1; + } + const digits = unitIndex <= 1 ? 0 : 2; + return `${current.toFixed(digits)} ${units[unitIndex]}`; +} + +function formatUpdatedAt(value) { + if (!value) { + return '-'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '-'; + } + return date.toLocaleString('de-DE'); +} + +function normalizeHardwareMonitoringPayload(rawPayload) { + const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; + return { + enabled: Boolean(payload.enabled), + intervalMs: Number(payload.intervalMs || 0), + updatedAt: payload.updatedAt || null, + sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null, + error: payload.error ? String(payload.error) : null + }; +} + +function getStorageUsageTone(usagePercent) { + const value = Number(usagePercent); + if (!Number.isFinite(value)) { + return 'unknown'; + } + if (value >= 95) { + return 'critical'; + } + if (value >= 85) { + return 'high'; + } + if (value >= 70) { + return 'warn'; + } + return 'ok'; +} + function normalizeQueue(queue) { const payload = queue && typeof queue === 'object' ? queue : {}; const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : []; @@ -240,7 +315,12 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) { }; } -export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline }) { +export default function DashboardPage({ + pipeline, + hardwareMonitoring, + lastDiscEvent, + refreshPipeline +}) { const [busy, setBusy] = useState(false); const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [metadataDialogContext, setMetadataDialogContext] = useState(null); @@ -258,11 +338,23 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline const [jobsLoading, setJobsLoading] = useState(false); const [dashboardJobs, setDashboardJobs] = useState([]); const [expandedJobId, setExpandedJobId] = useState(undefined); + const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false); const toastRef = useRef(null); const state = String(pipeline?.state || 'IDLE').trim().toUpperCase(); const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId); const isProcessing = processingStates.includes(state); + const monitoringState = useMemo( + () => normalizeHardwareMonitoringPayload(hardwareMonitoring), + [hardwareMonitoring] + ); + const monitoringSample = monitoringState.sample; + const cpuMetrics = monitoringSample?.cpu || null; + const memoryMetrics = monitoringSample?.memory || null; + const gpuMetrics = monitoringSample?.gpu || null; + const storageMetrics = Array.isArray(monitoringSample?.storage) ? monitoringSample.storage : []; + const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : []; + const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : []; const loadDashboardJobs = async () => { setJobsLoading(true); @@ -881,6 +973,175 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
+ +
+ + + +
+ + {monitoringState.error ? ( + {monitoringState.error} + ) : null} + + {!monitoringState.enabled ? ( +

Monitoring ist deaktiviert. Aktivierung in den Settings unter Kategorie "Monitoring".

+ ) : !monitoringSample ? ( +

Monitoring ist aktiv. Erste Messwerte werden gesammelt ...

+ ) : ( +
+
+

CPU

+
+
+ + {formatPercent(cpuMetrics?.overallUsagePercent)} +
+
+ + {formatTemperature(cpuMetrics?.overallTemperatureC)} +
+
+
+ + {Array.isArray(cpuMetrics?.loadAverage) ? cpuMetrics.loadAverage.join(' / ') : '-'} +
+ {cpuPerCoreMetrics.length > 0 ? ( + + ) : null} +
+
+ {cpuPerCoreMetrics.length === 0 ? ( + Pro-Core-Daten sind noch nicht verfuegbar. + ) : null} + {cpuPerCoreMetrics.length > 0 && cpuCoresExpanded ? ( +
+ {cpuPerCoreMetrics.map((core) => ( +
+
C{core.index}
+
+ + {formatPercent(core.usagePercent)} +
+
+ + {formatTemperature(core.temperatureC)} +
+
+ ))} +
+ ) : null} +
+ +
+

RAM

+
+
+ + {formatPercent(memoryMetrics?.usagePercent)} +
+
+ + {formatBytes(memoryMetrics?.usedBytes)} +
+
+ + {formatBytes(memoryMetrics?.freeBytes)} +
+
+ + {formatBytes(memoryMetrics?.totalBytes)} +
+
+
+ +
+

GPU

+ {!gpuMetrics?.available ? ( + {gpuMetrics?.message || 'Keine GPU-Metriken verfuegbar.'} + ) : ( +
+ {gpuDevices.map((gpu, index) => ( +
+ + GPU {gpu?.index ?? index} + {gpu?.name ? ` | ${gpu.name}` : ''} + + Load: {formatPercent(gpu?.utilizationPercent)} + Mem-Load: {formatPercent(gpu?.memoryUtilizationPercent)} + Temp: {formatTemperature(gpu?.temperatureC)} + VRAM: {formatBytes(gpu?.memoryUsedBytes)} / {formatBytes(gpu?.memoryTotalBytes)} + Power: {Number.isFinite(Number(gpu?.powerDrawW)) ? `${gpu.powerDrawW} W` : 'n/a'} / {Number.isFinite(Number(gpu?.powerLimitW)) ? `${gpu.powerLimitW} W` : 'n/a'} +
+ ))} +
+ )} +
+ +
+

Freier Speicher in Pfaden

+
+ {storageMetrics.map((entry) => { + const tone = getStorageUsageTone(entry?.usagePercent); + const usagePercent = Number(entry?.usagePercent); + const barValue = Number.isFinite(usagePercent) + ? Math.max(0, Math.min(100, usagePercent)) + : 0; + return ( +
+
+ {entry?.label || entry?.key || 'Pfad'} + + {entry?.error ? 'Fehler' : formatPercent(entry?.usagePercent)} + +
+ + {entry?.error ? ( + {entry.error} + ) : ( + <> +
+ +
+
+ Frei: {formatBytes(entry?.freeBytes)} + Gesamt: {formatBytes(entry?.totalBytes)} +
+ + )} + + + Pfad: {entry?.path || '-'} + + {entry?.queryPath && entry.queryPath !== entry.path ? ( + + Parent: {entry.queryPath} + + ) : null} + {entry?.note ? {entry.note} : null} +
+ ); + })} +
+
+
+ )} +
+
@@ -1052,35 +1313,37 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline ) : (
Kein Poster
)} -
- - {mediaIndicator.alt} - {jobTitle} - - - #{jobId} - {job?.year ? ` | ${job.year}` : ''} - {job?.imdb_id ? ` | ${job.imdb_id}` : ''} - +
+
+ + {mediaIndicator.alt} + {jobTitle} + + + #{jobId} + {job?.year ? ` | ${job.year}` : ''} + {job?.imdb_id ? ` | ${job.imdb_id}` : ''} + +
+
+ + {isCurrentSession ? : null} + {isResumable ? : null} + {normalizedStatus === 'READY_TO_ENCODE' + ? + : null} + +
{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}
-
- - {isCurrentSession ? : null} - {isResumable ? : null} - {normalizedStatus === 'READY_TO_ENCODE' - ? - : null} - -