New Push
This commit is contained in:
@@ -86,6 +86,30 @@ const defaultSchema = [
|
|||||||
validation: { minLength: 1 },
|
validation: { minLength: 1 },
|
||||||
orderIndex: 120
|
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',
|
key: 'makemkv_command',
|
||||||
category: 'Tools',
|
category: 'Tools',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const historyRoutes = require('./routes/historyRoutes');
|
|||||||
const wsService = require('./services/websocketService');
|
const wsService = require('./services/websocketService');
|
||||||
const pipelineService = require('./services/pipelineService');
|
const pipelineService = require('./services/pipelineService');
|
||||||
const diskDetectionService = require('./services/diskDetectionService');
|
const diskDetectionService = require('./services/diskDetectionService');
|
||||||
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
const { errorToMeta } = require('./utils/errorMeta');
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ async function start() {
|
|||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
wsService.init(server);
|
wsService.init(server);
|
||||||
|
await hardwareMonitorService.init();
|
||||||
|
|
||||||
diskDetectionService.on('discInserted', (device) => {
|
diskDetectionService.on('discInserted', (device) => {
|
||||||
logger.info('disk:inserted:event', { device });
|
logger.info('disk:inserted:event', { device });
|
||||||
@@ -69,6 +71,7 @@ async function start() {
|
|||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
logger.warn('backend:shutdown:received');
|
logger.warn('backend:shutdown:received');
|
||||||
diskDetectionService.stop();
|
diskDetectionService.stop();
|
||||||
|
hardwareMonitorService.stop();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.warn('backend:shutdown:completed');
|
logger.warn('backend:shutdown:completed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const asyncHandler = require('../middleware/asyncHandler');
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
const pipelineService = require('../services/pipelineService');
|
const pipelineService = require('../services/pipelineService');
|
||||||
const diskDetectionService = require('../services/diskDetectionService');
|
const diskDetectionService = require('../services/diskDetectionService');
|
||||||
|
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -10,7 +11,10 @@ router.get(
|
|||||||
'/state',
|
'/state',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
logger.debug('get:state', { reqId: req.reqId });
|
logger.debug('get:state', { reqId: req.reqId });
|
||||||
res.json({ pipeline: pipelineService.getSnapshot() });
|
res.json({
|
||||||
|
pipeline: pipelineService.getSnapshot(),
|
||||||
|
hardwareMonitoring: hardwareMonitorService.getSnapshot()
|
||||||
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const scriptService = require('../services/scriptService');
|
|||||||
const notificationService = require('../services/notificationService');
|
const notificationService = require('../services/notificationService');
|
||||||
const pipelineService = require('../services/pipelineService');
|
const pipelineService = require('../services/pipelineService');
|
||||||
const wsService = require('../services/websocketService');
|
const wsService = require('../services/websocketService');
|
||||||
|
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||||
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -140,6 +141,18 @@ router.put(
|
|||||||
message: error?.message || 'unknown'
|
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);
|
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||||
|
|
||||||
res.json({ setting: updated, reviewRefresh });
|
res.json({ setting: updated, reviewRefresh });
|
||||||
@@ -182,6 +195,17 @@ router.put(
|
|||||||
message: error?.message || 'unknown'
|
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) });
|
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
|
||||||
|
|
||||||
res.json({ changes, reviewRefresh });
|
res.json({ changes, reviewRefresh });
|
||||||
|
|||||||
953
backend/src/services/hardwareMonitorService.js
Normal file
953
backend/src/services/hardwareMonitorService.js
Normal 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();
|
||||||
@@ -9,6 +9,7 @@ import DatabasePage from './pages/DatabasePage';
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||||
|
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -16,6 +17,7 @@ function App() {
|
|||||||
const refreshPipeline = async () => {
|
const refreshPipeline = async () => {
|
||||||
const response = await api.getPipelineState();
|
const response = await api.getPipelineState();
|
||||||
setPipeline(response.pipeline);
|
setPipeline(response.pipeline);
|
||||||
|
setHardwareMonitoring(response?.hardwareMonitoring || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,6 +51,10 @@ function App() {
|
|||||||
if (message.type === 'DISC_REMOVED') {
|
if (message.type === 'DISC_REMOVED') {
|
||||||
setLastDiscEvent(null);
|
setLastDiscEvent(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
||||||
|
setHardwareMonitoring(message.payload || null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +94,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<DashboardPage
|
<DashboardPage
|
||||||
pipeline={pipeline}
|
pipeline={pipeline}
|
||||||
|
hardwareMonitoring={hardwareMonitoring}
|
||||||
lastDiscEvent={lastDiscEvent}
|
lastDiscEvent={lastDiscEvent}
|
||||||
refreshPipeline={refreshPipeline}
|
refreshPipeline={refreshPipeline}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const dashboardStatuses = new Set([
|
|||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'ERROR'
|
'ERROR'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
@@ -33,6 +34,80 @@ function normalizeJobId(value) {
|
|||||||
return Math.trunc(parsed);
|
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) {
|
function normalizeQueue(queue) {
|
||||||
const payload = queue && typeof queue === 'object' ? queue : {};
|
const payload = queue && typeof queue === 'object' ? queue : {};
|
||||||
const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
|
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 [busy, setBusy] = useState(false);
|
||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
@@ -258,11 +338,23 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
|
|||||||
const [jobsLoading, setJobsLoading] = useState(false);
|
const [jobsLoading, setJobsLoading] = useState(false);
|
||||||
const [dashboardJobs, setDashboardJobs] = useState([]);
|
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
|
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
||||||
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
||||||
const isProcessing = processingStates.includes(state);
|
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 () => {
|
const loadDashboardJobs = async () => {
|
||||||
setJobsLoading(true);
|
setJobsLoading(true);
|
||||||
@@ -881,6 +973,175 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
|
|||||||
<div className="page-grid">
|
<div className="page-grid">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<Card title="Hardware Monitoring" subTitle="CPU (inkl. Temperatur), RAM, GPU und freier Speicher in den konfigurierten Pfaden.">
|
||||||
|
<div className="hardware-monitor-head">
|
||||||
|
<Tag
|
||||||
|
value={monitoringState.enabled ? 'Aktiv' : 'Deaktiviert'}
|
||||||
|
severity={monitoringState.enabled ? 'success' : 'secondary'}
|
||||||
|
/>
|
||||||
|
<Tag value={`Intervall: ${monitoringState.intervalMs || 0} ms`} severity="info" />
|
||||||
|
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="warning" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{monitoringState.error ? (
|
||||||
|
<small className="error-text">{monitoringState.error}</small>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!monitoringState.enabled ? (
|
||||||
|
<p>Monitoring ist deaktiviert. Aktivierung in den Settings unter Kategorie "Monitoring".</p>
|
||||||
|
) : !monitoringSample ? (
|
||||||
|
<p>Monitoring ist aktiv. Erste Messwerte werden gesammelt ...</p>
|
||||||
|
) : (
|
||||||
|
<div className="hardware-monitor-grid">
|
||||||
|
<section className="hardware-monitor-block">
|
||||||
|
<h4>CPU</h4>
|
||||||
|
<div className="hardware-cpu-summary">
|
||||||
|
<div className="hardware-cpu-chip" title="CPU Gesamtauslastung">
|
||||||
|
<i className="pi pi-chart-line" />
|
||||||
|
<span>{formatPercent(cpuMetrics?.overallUsagePercent)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-cpu-chip" title="CPU Gesamttemperatur">
|
||||||
|
<i className="pi pi-bolt" />
|
||||||
|
<span>{formatTemperature(cpuMetrics?.overallTemperatureC)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-cpu-load-group">
|
||||||
|
<div className="hardware-cpu-chip" title="CPU Load Average">
|
||||||
|
<i className="pi pi-chart-bar" />
|
||||||
|
<span>{Array.isArray(cpuMetrics?.loadAverage) ? cpuMetrics.loadAverage.join(' / ') : '-'}</span>
|
||||||
|
</div>
|
||||||
|
{cpuPerCoreMetrics.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hardware-cpu-core-toggle-btn"
|
||||||
|
onClick={() => setCpuCoresExpanded((prev) => !prev)}
|
||||||
|
aria-label={cpuCoresExpanded ? 'CPU-Kerne ausblenden' : 'CPU-Kerne einblenden'}
|
||||||
|
aria-expanded={cpuCoresExpanded}
|
||||||
|
>
|
||||||
|
<i className={`pi ${cpuCoresExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cpuPerCoreMetrics.length === 0 ? (
|
||||||
|
<small>Pro-Core-Daten sind noch nicht verfuegbar.</small>
|
||||||
|
) : null}
|
||||||
|
{cpuPerCoreMetrics.length > 0 && cpuCoresExpanded ? (
|
||||||
|
<div className="hardware-core-grid compact">
|
||||||
|
{cpuPerCoreMetrics.map((core) => (
|
||||||
|
<div key={`core-${core.index}`} className="hardware-core-item compact">
|
||||||
|
<div className="hardware-core-title">C{core.index}</div>
|
||||||
|
<div className="hardware-core-metric" title="Auslastung">
|
||||||
|
<i className="pi pi-chart-line" />
|
||||||
|
<small>{formatPercent(core.usagePercent)}</small>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-core-metric" title="Temperatur">
|
||||||
|
<i className="pi pi-bolt" />
|
||||||
|
<small>{formatTemperature(core.temperatureC)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="hardware-monitor-block">
|
||||||
|
<h4>RAM</h4>
|
||||||
|
<div className="hardware-cpu-summary">
|
||||||
|
<div className="hardware-cpu-chip" title="RAM Auslastung">
|
||||||
|
<i className="pi pi-chart-pie" />
|
||||||
|
<span>{formatPercent(memoryMetrics?.usagePercent)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-cpu-chip" title="RAM Belegt">
|
||||||
|
<i className="pi pi-arrow-up" />
|
||||||
|
<span>{formatBytes(memoryMetrics?.usedBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-cpu-chip" title="RAM Frei">
|
||||||
|
<i className="pi pi-arrow-down" />
|
||||||
|
<span>{formatBytes(memoryMetrics?.freeBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hardware-cpu-chip" title="RAM Gesamt">
|
||||||
|
<i className="pi pi-database" />
|
||||||
|
<span>{formatBytes(memoryMetrics?.totalBytes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="hardware-monitor-block">
|
||||||
|
<h4>GPU</h4>
|
||||||
|
{!gpuMetrics?.available ? (
|
||||||
|
<small>{gpuMetrics?.message || 'Keine GPU-Metriken verfuegbar.'}</small>
|
||||||
|
) : (
|
||||||
|
<div className="hardware-gpu-list">
|
||||||
|
{gpuDevices.map((gpu, index) => (
|
||||||
|
<div key={`gpu-${gpu?.index ?? index}`} className="hardware-gpu-item">
|
||||||
|
<strong>
|
||||||
|
GPU {gpu?.index ?? index}
|
||||||
|
{gpu?.name ? ` | ${gpu.name}` : ''}
|
||||||
|
</strong>
|
||||||
|
<small>Load: {formatPercent(gpu?.utilizationPercent)}</small>
|
||||||
|
<small>Mem-Load: {formatPercent(gpu?.memoryUtilizationPercent)}</small>
|
||||||
|
<small>Temp: {formatTemperature(gpu?.temperatureC)}</small>
|
||||||
|
<small>VRAM: {formatBytes(gpu?.memoryUsedBytes)} / {formatBytes(gpu?.memoryTotalBytes)}</small>
|
||||||
|
<small>Power: {Number.isFinite(Number(gpu?.powerDrawW)) ? `${gpu.powerDrawW} W` : 'n/a'} / {Number.isFinite(Number(gpu?.powerLimitW)) ? `${gpu.powerLimitW} W` : 'n/a'}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="hardware-monitor-block">
|
||||||
|
<h4>Freier Speicher in Pfaden</h4>
|
||||||
|
<div className="hardware-storage-list">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={`storage-${entry?.key || entry?.label || 'path'}`}
|
||||||
|
className={`hardware-storage-item compact${entry?.error ? ' has-error' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="hardware-storage-head">
|
||||||
|
<strong>{entry?.label || entry?.key || 'Pfad'}</strong>
|
||||||
|
<span className={`hardware-storage-percent tone-${tone}`}>
|
||||||
|
{entry?.error ? 'Fehler' : formatPercent(entry?.usagePercent)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry?.error ? (
|
||||||
|
<small className="error-text">{entry.error}</small>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={`hardware-storage-bar tone-${tone}`}>
|
||||||
|
<ProgressBar value={barValue} showValue={false} />
|
||||||
|
</div>
|
||||||
|
<div className="hardware-storage-summary">
|
||||||
|
<small>Frei: {formatBytes(entry?.freeBytes)}</small>
|
||||||
|
<small>Gesamt: {formatBytes(entry?.totalBytes)}</small>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<small className="hardware-storage-path" title={entry?.path || '-'}>
|
||||||
|
Pfad: {entry?.path || '-'}
|
||||||
|
</small>
|
||||||
|
{entry?.queryPath && entry.queryPath !== entry.path ? (
|
||||||
|
<small className="hardware-storage-path" title={entry.queryPath}>
|
||||||
|
Parent: {entry.queryPath}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
{entry?.note ? <small className="hardware-storage-path">{entry.note}</small> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Job Queue" subTitle="Starts werden nach Parallel-Limit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
<Card title="Job Queue" subTitle="Starts werden nach Parallel-Limit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
|
||||||
<div className="pipeline-queue-meta">
|
<div className="pipeline-queue-meta">
|
||||||
<Tag value={`Parallel: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
<Tag value={`Parallel: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||||
@@ -1052,35 +1313,37 @@ export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline
|
|||||||
) : (
|
) : (
|
||||||
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
|
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
|
||||||
)}
|
)}
|
||||||
<div className="dashboard-job-row-main">
|
<div className="dashboard-job-row-content">
|
||||||
<strong className="dashboard-job-title-line">
|
<div className="dashboard-job-row-main">
|
||||||
<img
|
<strong className="dashboard-job-title-line">
|
||||||
src={mediaIndicator.src}
|
<img
|
||||||
alt={mediaIndicator.alt}
|
src={mediaIndicator.src}
|
||||||
title={mediaIndicator.title}
|
alt={mediaIndicator.alt}
|
||||||
className="media-indicator-icon"
|
title={mediaIndicator.title}
|
||||||
/>
|
className="media-indicator-icon"
|
||||||
<span>{jobTitle}</span>
|
/>
|
||||||
</strong>
|
<span>{jobTitle}</span>
|
||||||
<small>
|
</strong>
|
||||||
#{jobId}
|
<small>
|
||||||
{job?.year ? ` | ${job.year}` : ''}
|
#{jobId}
|
||||||
{job?.imdb_id ? ` | ${job.imdb_id}` : ''}
|
{job?.year ? ` | ${job.year}` : ''}
|
||||||
</small>
|
{job?.imdb_id ? ` | ${job.imdb_id}` : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-job-badges">
|
||||||
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
|
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||||
|
: null}
|
||||||
|
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
||||||
|
</div>
|
||||||
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
||||||
<ProgressBar value={clampedProgress} showValue={false} />
|
<ProgressBar value={clampedProgress} showValue={false} />
|
||||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dashboard-job-badges">
|
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
|
||||||
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
|
||||||
: null}
|
|
||||||
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
|
||||||
</div>
|
|
||||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -217,6 +217,266 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hardware-monitor-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-monitor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-monitor-block {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
padding: 0.65rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-monitor-block h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-monitor-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
padding: 0.22rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-chip span {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-chip .pi {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-load-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-core-toggle-btn {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
background: var(--rip-panel);
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
width: 2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-core-toggle-btn:hover {
|
||||||
|
background: #f1e1c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-cpu-core-toggle-btn .pi {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-monitor-meta > div,
|
||||||
|
.hardware-core-item,
|
||||||
|
.hardware-gpu-item,
|
||||||
|
.hardware-storage-item {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-item {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.42rem 0.5rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-grid.compact {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-item.compact {
|
||||||
|
grid-template-columns: auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-title {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
min-width: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-metric {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-metric .pi {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-gpu-list,
|
||||||
|
.hardware-storage-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-gpu-item,
|
||||||
|
.hardware-storage-item {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-item.compact {
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-head strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-percent {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-percent.tone-ok {
|
||||||
|
color: #18643a;
|
||||||
|
background: #d8f1df;
|
||||||
|
border-color: #9ed5ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-percent.tone-warn {
|
||||||
|
color: #7d4f00;
|
||||||
|
background: #f8ebc4;
|
||||||
|
border-color: #d7b46a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-percent.tone-high {
|
||||||
|
color: #8e3d00;
|
||||||
|
background: #f5dcc9;
|
||||||
|
border-color: #d9a578;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-percent.tone-critical {
|
||||||
|
color: #8f1f17;
|
||||||
|
background: #f6d6d5;
|
||||||
|
border-color: #d89a97;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-percent.tone-unknown {
|
||||||
|
color: #5b4636;
|
||||||
|
background: #eadfcc;
|
||||||
|
border-color: #c8ad88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-bar .p-progressbar {
|
||||||
|
height: 0.5rem;
|
||||||
|
background: #eadbc1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-bar.tone-ok .p-progressbar-value {
|
||||||
|
background: #2d9c4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-bar.tone-warn .p-progressbar-value {
|
||||||
|
background: #c58f00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-bar.tone-high .p-progressbar-value {
|
||||||
|
background: #d6761f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-bar.tone-critical .p-progressbar-value {
|
||||||
|
background: #c43c2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-bar.tone-unknown .p-progressbar-value {
|
||||||
|
background: #8a6a53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-storage-path {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.pipeline-queue-meta {
|
.pipeline-queue-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -312,7 +572,7 @@ body {
|
|||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
background: var(--rip-panel-soft);
|
background: var(--rip-panel-soft);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 48px minmax(0, 1fr) auto auto;
|
grid-template-columns: 48px minmax(0, 1fr) auto;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -325,6 +585,14 @@ body {
|
|||||||
background: #fbf0df;
|
background: #fbf0df;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row-content {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.3rem 0.7rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-job-row-main {
|
.dashboard-job-row-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
@@ -360,6 +628,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-job-row-progress {
|
.dashboard-job-row-progress {
|
||||||
|
grid-column: 1 / -1;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
@@ -377,7 +646,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,6 +1558,8 @@ body {
|
|||||||
|
|
||||||
.metadata-grid,
|
.metadata-grid,
|
||||||
.device-meta,
|
.device-meta,
|
||||||
|
.hardware-monitor-grid,
|
||||||
|
.hardware-monitor-meta,
|
||||||
.pipeline-queue-grid,
|
.pipeline-queue-grid,
|
||||||
.media-review-meta,
|
.media-review-meta,
|
||||||
.media-track-grid,
|
.media-track-grid,
|
||||||
@@ -1303,11 +1574,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-job-row {
|
.dashboard-job-row {
|
||||||
grid-template-columns: 48px minmax(0, 1fr);
|
grid-template-columns: 48px minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-job-badges {
|
.dashboard-job-badges {
|
||||||
grid-column: 1 / -1;
|
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,6 +1608,26 @@ body {
|
|||||||
.metadata-selection-dialog .p-datatable-wrapper {
|
.metadata-selection-dialog .p-datatable-wrapper {
|
||||||
max-height: 16rem !important;
|
max-height: 16rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hardware-core-item.compact {
|
||||||
|
grid-template-columns: auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-grid.compact {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-title {
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-core-metric {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -58,7 +58,7 @@
|
|||||||
</span><span id=__span-1-2><a id=__codelineno-1-2 name=__codelineno-1-2 href=#__codelineno-1-2></a>├── Pages
|
</span><span id=__span-1-2><a id=__codelineno-1-2 name=__codelineno-1-2 href=#__codelineno-1-2></a>├── Pages
|
||||||
</span><span id=__span-1-3><a id=__codelineno-1-3 name=__codelineno-1-3 href=#__codelineno-1-3></a>│ ├── DashboardPage.jsx ← Haupt-Interface
|
</span><span id=__span-1-3><a id=__codelineno-1-3 name=__codelineno-1-3 href=#__codelineno-1-3></a>│ ├── DashboardPage.jsx ← Haupt-Interface
|
||||||
</span><span id=__span-1-4><a id=__codelineno-1-4 name=__codelineno-1-4 href=#__codelineno-1-4></a>│ ├── SettingsPage.jsx
|
</span><span id=__span-1-4><a id=__codelineno-1-4 name=__codelineno-1-4 href=#__codelineno-1-4></a>│ ├── SettingsPage.jsx
|
||||||
</span><span id=__span-1-5><a id=__codelineno-1-5 name=__codelineno-1-5 href=#__codelineno-1-5></a>│ └── HistoryPage.jsx
|
</span><span id=__span-1-5><a id=__codelineno-1-5 name=__codelineno-1-5 href=#__codelineno-1-5></a>│ └── DatabasePage.jsx ← Historie/DB-Ansicht
|
||||||
</span><span id=__span-1-6><a id=__codelineno-1-6 name=__codelineno-1-6 href=#__codelineno-1-6></a>├── Components
|
</span><span id=__span-1-6><a id=__codelineno-1-6 name=__codelineno-1-6 href=#__codelineno-1-6></a>├── Components
|
||||||
</span><span id=__span-1-7><a id=__codelineno-1-7 name=__codelineno-1-7 href=#__codelineno-1-7></a>│ ├── PipelineStatusCard.jsx
|
</span><span id=__span-1-7><a id=__codelineno-1-7 name=__codelineno-1-7 href=#__codelineno-1-7></a>│ ├── PipelineStatusCard.jsx
|
||||||
</span><span id=__span-1-8><a id=__codelineno-1-8 name=__codelineno-1-8 href=#__codelineno-1-8></a>│ ├── MetadataSelectionDialog.jsx
|
</span><span id=__span-1-8><a id=__codelineno-1-8 name=__codelineno-1-8 href=#__codelineno-1-8></a>│ ├── MetadataSelectionDialog.jsx
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,22 +11,19 @@
|
|||||||
</span></code></pre></div> <div class="admonition tip"> <p class=admonition-title>Erste Schritte</p> <p>Die vollständige Installationsanleitung mit allen Voraussetzungen findest du unter <a href=getting-started/ >Erste Schritte</a>.</p> </div> <hr> <h2 id=pipeline-uberblick>Pipeline-Überblick<a class=headerlink href=#pipeline-uberblick title="Permanent link">¶</a></h2> <div class=pipeline-diagram> <pre class=mermaid><code>flowchart LR
|
</span></code></pre></div> <div class="admonition tip"> <p class=admonition-title>Erste Schritte</p> <p>Die vollständige Installationsanleitung mit allen Voraussetzungen findest du unter <a href=getting-started/ >Erste Schritte</a>.</p> </div> <hr> <h2 id=pipeline-uberblick>Pipeline-Überblick<a class=headerlink href=#pipeline-uberblick title="Permanent link">¶</a></h2> <div class=pipeline-diagram> <pre class=mermaid><code>flowchart LR
|
||||||
IDLE --> DD[DISC_DETECTED]
|
IDLE --> DD[DISC_DETECTED]
|
||||||
DD --> META[METADATA\nSELECTION]
|
DD --> META[METADATA\nSELECTION]
|
||||||
META -->|1 Kandidat| RTS[READY_TO\nSTART]
|
META --> RTS[READY_TO\nSTART]
|
||||||
META -->|Obfuskierung| WUD[WAITING_FOR\nUSER_DECISION]
|
RTS -->|Auto-Start| RIP[RIPPING]
|
||||||
WUD --> RTS
|
RTS -->|Auto-Start mit RAW| MIC
|
||||||
RTS --> RIP[RIPPING]
|
|
||||||
RTS -->|Raw vorhanden| MIC
|
|
||||||
RIP --> MIC[MEDIAINFO\nCHECK]
|
RIP --> MIC[MEDIAINFO\nCHECK]
|
||||||
|
MIC -->|Playlist offen (Backup)| WUD[WAITING_FOR\nUSER_DECISION]
|
||||||
|
WUD --> MIC
|
||||||
MIC --> RTE[READY_TO\nENCODE]
|
MIC --> RTE[READY_TO\nENCODE]
|
||||||
RTE --> ENC[ENCODING]
|
RTE --> ENC[ENCODING]
|
||||||
ENC --> PES[POST_ENCODE\nSCRIPTS]
|
ENC -->|inkl. Post-Skripte| FIN([FINISHED])
|
||||||
ENC -->|keine Skripte| FIN([FINISHED])
|
|
||||||
PES --> FIN
|
|
||||||
ENC --> ERR([ERROR])
|
ENC --> ERR([ERROR])
|
||||||
RIP --> ERR
|
RIP --> ERR
|
||||||
|
|
||||||
style FIN fill:#e8f5e9,stroke:#66bb6a,color:#2e7d32
|
style FIN fill:#e8f5e9,stroke:#66bb6a,color:#2e7d32
|
||||||
style ERR fill:#ffebee,stroke:#ef5350,color:#c62828
|
style ERR fill:#ffebee,stroke:#ef5350,color:#c62828
|
||||||
style WUD fill:#fff8e1,stroke:#ffa726,color:#e65100
|
style WUD fill:#fff8e1,stroke:#ffa726,color:#e65100
|
||||||
style PES fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a
|
style ENC fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a</code></pre> </div> <p><code>READY_TO_START</code> ist in der Praxis meist ein kurzer Übergangszustand: der Job wird nach Metadaten-Auswahl automatisch gestartet oder in die Queue eingeplant.</p> </article> </div> <script>var tabs=__md_get("__tabs");if(Array.isArray(tabs))e:for(var set of document.querySelectorAll(".tabbed-set")){var labels=set.querySelector(".tabbed-labels");for(var tab of tabs)for(var label of labels.getElementsByTagName("label"))if(label.innerText.trim()===tab){var input=document.getElementById(label.htmlFor);input.checked=!0;continue e}}</script> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type=button class="md-top md-icon" data-md-component=top hidden> <svg xmlns=http://www.w3.org/2000/svg viewbox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Zurück zum Seitenanfang </button> </main> <footer class=md-footer> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class=md-copyright> Made with <a href=https://squidfunk.github.io/mkdocs-material/ target=_blank rel=noopener> Material for MkDocs </a> </div> <div class=md-social> <a href=https://github.com/YOUR_GITHUB_USERNAME/ripster target=_blank rel=noopener title=github.com class=md-social__link> <svg xmlns=http://www.w3.org/2000/svg viewbox="0 0 512 512"><!-- Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9M252.8 8C114.1 8 8 113.3 8 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C436.2 457.8 504 362.9 504 252 504 113.3 391.5 8 252.8 8M105.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2"/></svg> </a> </div> </div> </div> </footer> </div> <div class=md-dialog data-md-component=dialog> <div class="md-dialog__inner md-typeset"></div> </div> <script id=__config type=application/json>{"annotate": null, "base": ".", "features": ["navigation.tabs", "navigation.tabs.sticky", "navigation.sections", "navigation.expand", "navigation.indexes", "navigation.top", "search.highlight", "search.suggest", "content.code.copy", "content.code.annotate", "content.tabs.link", "toc.integrate"], "search": "assets/javascripts/workers/search.7a47a382.min.js", "tags": null, "translations": {"clipboard.copied": "In Zwischenablage kopiert", "clipboard.copy": "In Zwischenablage kopieren", "search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite", "search.result.more.other": "# weitere Suchergebnisse auf dieser Seite", "search.result.none": "Keine Suchergebnisse", "search.result.one": "1 Suchergebnis", "search.result.other": "# Suchergebnisse", "search.result.placeholder": "Suchbegriff eingeben", "search.result.term.missing": "Es fehlt", "select.version": "Version ausw\u00e4hlen"}, "version": {"provider": "mike"}}</script> <script src=assets/javascripts/bundle.e71a0d61.min.js></script> </body> </html>
|
||||||
style ENC fill:#f3e5f5,stroke:#ab47bc,color:#6a1b9a</code></pre> </div> </article> </div> <script>var tabs=__md_get("__tabs");if(Array.isArray(tabs))e:for(var set of document.querySelectorAll(".tabbed-set")){var labels=set.querySelector(".tabbed-labels");for(var tab of tabs)for(var label of labels.getElementsByTagName("label"))if(label.innerText.trim()===tab){var input=document.getElementById(label.htmlFor);input.checked=!0;continue e}}</script> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type=button class="md-top md-icon" data-md-component=top hidden> <svg xmlns=http://www.w3.org/2000/svg viewbox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Zurück zum Seitenanfang </button> </main> <footer class=md-footer> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class=md-copyright> Made with <a href=https://squidfunk.github.io/mkdocs-material/ target=_blank rel=noopener> Material for MkDocs </a> </div> <div class=md-social> <a href=https://github.com/YOUR_GITHUB_USERNAME/ripster target=_blank rel=noopener title=github.com class=md-social__link> <svg xmlns=http://www.w3.org/2000/svg viewbox="0 0 512 512"><!-- Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9M252.8 8C114.1 8 8 113.3 8 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C436.2 457.8 504 362.9 504 252 504 113.3 391.5 8 252.8 8M105.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2"/></svg> </a> </div> </div> </div> </footer> </div> <div class=md-dialog data-md-component=dialog> <div class="md-dialog__inner md-typeset"></div> </div> <script id=__config type=application/json>{"annotate": null, "base": ".", "features": ["navigation.tabs", "navigation.tabs.sticky", "navigation.sections", "navigation.expand", "navigation.indexes", "navigation.top", "search.highlight", "search.suggest", "content.code.copy", "content.code.annotate", "content.tabs.link", "toc.integrate"], "search": "assets/javascripts/workers/search.7a47a382.min.js", "tags": null, "translations": {"clipboard.copied": "In Zwischenablage kopiert", "clipboard.copy": "In Zwischenablage kopieren", "search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite", "search.result.more.other": "# weitere Suchergebnisse auf dieser Seite", "search.result.none": "Keine Suchergebnisse", "search.result.one": "1 Suchergebnis", "search.result.other": "# Suchergebnisse", "search.result.placeholder": "Suchbegriff eingeben", "search.result.term.missing": "Es fehlt", "select.version": "Version ausw\u00e4hlen"}, "version": {"provider": "mike"}}</script> <script src=assets/javascripts/bundle.e71a0d61.min.js></script> </body> </html>
|
|
||||||
@@ -116,9 +116,9 @@
|
|||||||
</span><span id=__span-5-13><a id=__codelineno-5-13 name=__codelineno-5-13 href=#__codelineno-5-13></a>│ [✓] │ Track 1: Deutsch │Einbr.[ ]│Forced[ ]│Default[✓]│
|
</span><span id=__span-5-13><a id=__codelineno-5-13 name=__codelineno-5-13 href=#__codelineno-5-13></a>│ [✓] │ Track 1: Deutsch │Einbr.[ ]│Forced[ ]│Default[✓]│
|
||||||
</span><span id=__span-5-14><a id=__codelineno-5-14 name=__codelineno-5-14 href=#__codelineno-5-14></a>│ [ ] │ Track 2: English │Einbr.[ ]│Forced[ ]│Default[ ]│
|
</span><span id=__span-5-14><a id=__codelineno-5-14 name=__codelineno-5-14 href=#__codelineno-5-14></a>│ [ ] │ Track 2: English │Einbr.[ ]│Forced[ ]│Default[ ]│
|
||||||
</span><span id=__span-5-15><a id=__codelineno-5-15 name=__codelineno-5-15 href=#__codelineno-5-15></a>├──────┴──────────────────────────┴────────┴────────┴────────────┤
|
</span><span id=__span-5-15><a id=__codelineno-5-15 name=__codelineno-5-15 href=#__codelineno-5-15></a>├──────┴──────────────────────────┴────────┴────────┴────────────┤
|
||||||
</span><span id=__span-5-16><a id=__codelineno-5-16 name=__codelineno-5-16 href=#__codelineno-5-16></a>│ [Encode bestätigen] │
|
</span><span id=__span-5-16><a id=__codelineno-5-16 name=__codelineno-5-16 href=#__codelineno-5-16></a>│ [Encoding starten] │
|
||||||
</span><span id=__span-5-17><a id=__codelineno-5-17 name=__codelineno-5-17 href=#__codelineno-5-17></a>└─────────────────────────────────────────────────────────────────┘
|
</span><span id=__span-5-17><a id=__codelineno-5-17 name=__codelineno-5-17 href=#__codelineno-5-17></a>└─────────────────────────────────────────────────────────────────┘
|
||||||
</span></code></pre></div> <p>Der Benutzer kann: - <strong>Audio-Tracks</strong> per Checkbox aktivieren/deaktivieren - <strong>Untertitel-Flags</strong> (Einbrennen, Forced, Default) setzen - <strong>Mehrere Titel</strong> bei der Titleauswahl wechseln (für Discs mit mehreren Haupttiteln)</p> <hr> <h2 id=phase-7-benutzer-auswahl-anwenden-applymanualtrackselectiontoplan>Phase 7: Benutzer-Auswahl anwenden (<code>applyManualTrackSelectionToPlan</code>)<a class=headerlink href=#phase-7-benutzer-auswahl-anwenden-applymanualtrackselectiontoplan title="Permanent link">¶</a></h2> <p>Nach "Encode bestätigen" wird die Benutzer-Auswahl auf den Plan angewendet:</p> <div class="language-json highlight"><pre><span></span><code><span id=__span-6-1><a id=__codelineno-6-1 name=__codelineno-6-1 href=#__codelineno-6-1></a><span class=err>Payload</span><span class=p>:</span><span class=w> </span><span class=p>{</span>
|
</span></code></pre></div> <p>Der Benutzer kann: - <strong>Audio-Tracks</strong> per Checkbox aktivieren/deaktivieren - <strong>Untertitel-Flags</strong> (Einbrennen, Forced, Default) setzen - <strong>Mehrere Titel</strong> bei der Titleauswahl wechseln (für Discs mit mehreren Haupttiteln)</p> <hr> <h2 id=phase-7-benutzer-auswahl-anwenden-applymanualtrackselectiontoplan>Phase 7: Benutzer-Auswahl anwenden (<code>applyManualTrackSelectionToPlan</code>)<a class=headerlink href=#phase-7-benutzer-auswahl-anwenden-applymanualtrackselectiontoplan title="Permanent link">¶</a></h2> <p>Im Frontend wird die Benutzer-Auswahl beim Klick auf <strong>"Encoding starten"</strong> (ggf. automatisch) bestätigt und dann auf den Plan angewendet:</p> <div class="language-json highlight"><pre><span></span><code><span id=__span-6-1><a id=__codelineno-6-1 name=__codelineno-6-1 href=#__codelineno-6-1></a><span class=err>Payload</span><span class=p>:</span><span class=w> </span><span class=p>{</span>
|
||||||
</span><span id=__span-6-2><a id=__codelineno-6-2 name=__codelineno-6-2 href=#__codelineno-6-2></a><span class=w> </span><span class=nt>"selectedEncodeTitleId"</span><span class=p>:</span><span class=w> </span><span class=mi>1</span><span class=p>,</span>
|
</span><span id=__span-6-2><a id=__codelineno-6-2 name=__codelineno-6-2 href=#__codelineno-6-2></a><span class=w> </span><span class=nt>"selectedEncodeTitleId"</span><span class=p>:</span><span class=w> </span><span class=mi>1</span><span class=p>,</span>
|
||||||
</span><span id=__span-6-3><a id=__codelineno-6-3 name=__codelineno-6-3 href=#__codelineno-6-3></a><span class=w> </span><span class=nt>"selectedTrackSelection"</span><span class=p>:</span><span class=w> </span><span class=p>{</span>
|
</span><span id=__span-6-3><a id=__codelineno-6-3 name=__codelineno-6-3 href=#__codelineno-6-3></a><span class=w> </span><span class=nt>"selectedTrackSelection"</span><span class=p>:</span><span class=w> </span><span class=p>{</span>
|
||||||
</span><span id=__span-6-4><a id=__codelineno-6-4 name=__codelineno-6-4 href=#__codelineno-6-4></a><span class=w> </span><span class=nt>"1"</span><span class=p>:</span><span class=w> </span><span class=p>{</span>
|
</span><span id=__span-6-4><a id=__codelineno-6-4 name=__codelineno-6-4 href=#__codelineno-6-4></a><span class=w> </span><span class=nt>"1"</span><span class=p>:</span><span class=w> </span><span class=p>{</span>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user