This commit is contained in:
2026-03-13 15:50:45 +00:00
parent b6cac5efb4
commit df708485b5
7 changed files with 153 additions and 61 deletions

View File

@@ -857,7 +857,7 @@ async function migrateSettingsSchemaMetadata(db) {
} }
const rawDirCdLabel = 'CD RAW-Ordner'; const rawDirCdLabel = 'CD RAW-Ordner';
const rawDirCdDescription = 'Basisordner für CD-Rips. Enthält die WAV-Rohdaten (RAW) sowie den encodierten Audio-Output. Leer = Standardpfad (data/output/cd).'; const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).';
const rawDirCdResult = await db.run( const rawDirCdResult = await db.run(
`UPDATE settings_schema `UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
@@ -870,6 +870,26 @@ async function migrateSettingsSchemaMetadata(db) {
label: rawDirCdLabel label: rawDirCdLabel
}); });
} }
// Migrate raw_dir_cd_owner label
await db.run(
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
);
// Add movie_dir_cd if not already present
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`);
// Add movie_dir_cd_owner if not already present
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);
} }
async function getDb() { async function getDb() {

View File

@@ -161,6 +161,41 @@ function hasBlurayStructure(rawPath) {
return false; return false;
} }
function hasCdStructure(rawPath) {
const basePath = String(rawPath || '').trim();
if (!basePath) {
return false;
}
try {
if (!fs.existsSync(basePath)) {
return false;
}
const stat = fs.statSync(basePath);
if (!stat.isDirectory()) {
return false;
}
const entries = fs.readdirSync(basePath);
const audioExtensions = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg', '.aiff', '.aif']);
return entries.some((entry) => audioExtensions.has(path.extname(entry).toLowerCase()));
} catch (_error) {
return false;
}
}
function detectOrphanMediaType(rawPath) {
if (hasBlurayStructure(rawPath)) {
return 'bluray';
}
if (hasDvdStructure(rawPath)) {
return 'dvd';
}
if (hasCdStructure(rawPath)) {
return 'cd';
}
return 'other';
}
function hasDvdStructure(rawPath) { function hasDvdStructure(rawPath) {
const basePath = String(rawPath || '').trim(); const basePath = String(rawPath || '').trim();
if (!basePath) { if (!basePath) {
@@ -439,17 +474,16 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType); const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
const rawDir = String(effectiveSettings?.raw_dir || '').trim(); const rawDir = String(effectiveSettings?.raw_dir || '').trim();
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim(); const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
const movieDir = mediaType === 'cd' ? rawDir : configuredMovieDir; const movieDir = configuredMovieDir || rawDir;
const rawLookupDirs = getConfiguredMediaPathList(settings || {}, 'raw_dir') const rawLookupDirs = getConfiguredMediaPathList(settings || {}, 'raw_dir')
.filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir)); .filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir));
const effectiveRawPath = job?.raw_path const effectiveRawPath = job?.raw_path
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs) ? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
: (job?.raw_path || null); : (job?.raw_path || null);
const effectiveOutputPath = mediaType === 'cd' // For CD, output_path is a directory (album folder) — skip path-relocation heuristic
? (job?.output_path || null) const effectiveOutputPath = (mediaType !== 'cd' && configuredMovieDir && job?.output_path)
: (configuredMovieDir && job?.output_path
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir) ? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
: (job?.output_path || null)); : (job?.output_path || null);
return { return {
mediaType, mediaType,
@@ -1398,6 +1432,7 @@ class HistoryService {
const stat = fs.statSync(rawPath); const stat = fs.statSync(rawPath);
const metadata = parseRawFolderMetadata(entry.name); const metadata = parseRawFolderMetadata(entry.name);
const detectedMediaType = detectOrphanMediaType(rawPath);
orphanRows.push({ orphanRows.push({
rawPath, rawPath,
folderName: entry.name, folderName: entry.name,
@@ -1406,7 +1441,10 @@ class HistoryService {
imdbId: metadata.imdbId, imdbId: metadata.imdbId,
folderJobId: metadata.folderJobId, folderJobId: metadata.folderJobId,
entryCount: Number(dirInfo.entryCount || 0), entryCount: Number(dirInfo.entryCount || 0),
hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')), detectedMediaType,
hasBlurayStructure: detectedMediaType === 'bluray',
hasDvdStructure: detectedMediaType === 'dvd',
hasCdStructure: detectedMediaType === 'cd',
lastModifiedAt: stat.mtime.toISOString() lastModifiedAt: stat.mtime.toISOString()
}); });
seenOrphanPaths.add(normalizedPath); seenOrphanPaths.add(normalizedPath);
@@ -1543,6 +1581,7 @@ class HistoryService {
} }
} }
const detectedMediaType = detectOrphanMediaType(finalRawPath);
const orphanPosterUrl = omdbById?.poster || null; const orphanPosterUrl = omdbById?.poster || null;
await this.updateJob(created.id, { await this.updateJob(created.id, {
status: 'FINISHED', status: 'FINISHED',
@@ -1567,7 +1606,8 @@ class HistoryService {
status: 'SUCCESS', status: 'SUCCESS',
source: 'orphan_raw_import', source: 'orphan_raw_import',
importedAt, importedAt,
rawPath: finalRawPath rawPath: finalRawPath,
mediaProfile: detectedMediaType
}) })
}); });
@@ -1585,8 +1625,8 @@ class HistoryService {
created.id, created.id,
'SYSTEM', 'SYSTEM',
renameSteps.length > 0 renameSteps.length > 0
? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}` ? `Historieneintrag aus RAW erstellt (Medientyp: ${detectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}`
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath}` : `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${detectedMediaType})`
); );
if (metadata.imdbId) { if (metadata.imdbId) {
await this.appendLog( await this.appendLog(
@@ -1600,7 +1640,8 @@ class HistoryService {
logger.info('job:import-orphan-raw', { logger.info('job:import-orphan-raw', {
jobId: created.id, jobId: created.id,
rawPath: absRawPath rawPath: absRawPath,
detectedMediaType
}); });
const imported = await this.getJobById(created.id); const imported = await this.getJobById(created.id);

View File

@@ -10377,10 +10377,10 @@ class PipelineService extends EventEmitter {
logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) }); logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) });
throw error; throw error;
} finally { } finally {
await historyService.closeProcessLog(jobId);
this.activeProcesses.delete(Number(normalizedJobId)); this.activeProcesses.delete(Number(normalizedJobId));
this.syncPrimaryActiveProcess();
this.cancelRequestedByJob.delete(Number(normalizedJobId)); this.cancelRequestedByJob.delete(Number(normalizedJobId));
this.syncPrimaryActiveProcess();
await historyService.closeProcessLog(jobId);
await this.emitQueueChanged(); await this.emitQueueChanged();
void this.pumpQueue(); void this.pumpQueue();
} }
@@ -10430,16 +10430,16 @@ class PipelineService extends EventEmitter {
hasRawPath = false; hasRawPath = false;
} }
if (normalizedStage === 'ENCODING' && hasConfirmedPlan) { if (normalizedStage === 'ENCODING' && hasConfirmedPlan && !isCancelled) {
try { try {
await historyService.appendLog( await historyService.appendLog(
jobId, jobId,
'SYSTEM', 'SYSTEM',
`${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.` `Fehler in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.`
); );
await this.restartEncodeWithLastSettings(jobId, { await this.restartEncodeWithLastSettings(jobId, {
immediate: true, immediate: true,
triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode' triggerReason: 'failed_encode'
}); });
this.cancelRequestedByJob.delete(Number(jobId)); this.cancelRequestedByJob.delete(Number(jobId));
return; return;
@@ -10992,20 +10992,22 @@ class PipelineService extends EventEmitter {
const cdOutputTemplate = String( const cdOutputTemplate = String(
settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE
).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE; ).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
const cdBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR; const cdRawBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR;
const cdOutputOwner = String(settings.raw_dir_owner || '').trim(); const cdOutputBaseDir = String(settings.movie_dir || '').trim() || cdRawBaseDir;
const cdRawOwner = String(settings.raw_dir_owner || '').trim();
const cdOutputOwner = String(settings.movie_dir_owner || settings.raw_dir_owner || '').trim();
const cdMetadataBase = buildRawMetadataBase({ const cdMetadataBase = buildRawMetadataBase({
title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null, title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null,
year: effectiveSelectedMeta?.year || null year: effectiveSelectedMeta?.year || null
}, activeJobId); }, activeJobId);
const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE });
const rawJobDir = path.join(cdBaseDir, rawDirName); const rawJobDir = path.join(cdRawBaseDir, rawDirName);
const rawWavDir = rawJobDir; const rawWavDir = rawJobDir;
const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdBaseDir, cdOutputTemplate); const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate);
ensureDir(cdBaseDir); ensureDir(cdRawBaseDir);
ensureDir(rawJobDir); ensureDir(rawJobDir);
ensureDir(outputDir); ensureDir(outputDir);
chownRecursive(rawJobDir, cdOutputOwner); chownRecursive(rawJobDir, cdRawOwner);
chownRecursive(outputDir, cdOutputOwner); chownRecursive(outputDir, cdOutputOwner);
const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1; const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1;
const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`); const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`);
@@ -11108,12 +11110,13 @@ class PipelineService extends EventEmitter {
devicePath, devicePath,
cdparanoiaCmd, cdparanoiaCmd,
rawWavDir, rawWavDir,
rawBaseDir: cdBaseDir, rawBaseDir: cdRawBaseDir,
cdMetadataBase, cdMetadataBase,
outputDir, outputDir,
format, format,
formatOptions, formatOptions,
outputTemplate: cdOutputTemplate, outputTemplate: cdOutputTemplate,
rawOwner: cdRawOwner,
outputOwner: cdOutputOwner, outputOwner: cdOutputOwner,
selectedTrackPositions: effectiveSelectedTrackPositions, selectedTrackPositions: effectiveSelectedTrackPositions,
tocTracks: mergedTracks, tocTracks: mergedTracks,
@@ -11142,6 +11145,7 @@ class PipelineService extends EventEmitter {
format, format,
formatOptions, formatOptions,
outputTemplate, outputTemplate,
rawOwner,
outputOwner, outputOwner,
selectedTrackPositions, selectedTrackPositions,
tocTracks, tocTracks,
@@ -11397,7 +11401,7 @@ class PipelineService extends EventEmitter {
await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {}); await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {});
} }
chownRecursive(activeRawDir, outputOwner); chownRecursive(activeRawDir, rawOwner || outputOwner);
chownRecursive(outputDir, outputOwner); chownRecursive(outputDir, outputOwner);
await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`); await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`);
const finishedStatusText = postEncodeScriptsSummary.failed > 0 const finishedStatusText = postEncodeScriptsSummary.failed > 0

View File

@@ -52,11 +52,13 @@ const PROFILED_SETTINGS = {
}, },
movie_dir: { movie_dir: {
bluray: 'movie_dir_bluray', bluray: 'movie_dir_bluray',
dvd: 'movie_dir_dvd' dvd: 'movie_dir_dvd',
cd: 'movie_dir_cd'
}, },
movie_dir_owner: { movie_dir_owner: {
bluray: 'movie_dir_bluray_owner', bluray: 'movie_dir_bluray_owner',
dvd: 'movie_dir_dvd_owner' dvd: 'movie_dir_dvd_owner',
cd: 'movie_dir_cd_owner'
}, },
mediainfo_extra_args: { mediainfo_extra_args: {
bluray: 'mediainfo_extra_args_bluray', bluray: 'mediainfo_extra_args_bluray',
@@ -690,7 +692,7 @@ class SettingsService {
if (legacyKey === 'raw_dir') { if (legacyKey === 'raw_dir') {
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR; resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR;
} else if (legacyKey === 'movie_dir') { } else if (legacyKey === 'movie_dir') {
resolvedValue = DEFAULT_MOVIE_DIR; resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_MOVIE_DIR;
} }
} }
effective[legacyKey] = resolvedValue; effective[legacyKey] = resolvedValue;
@@ -725,7 +727,7 @@ class SettingsService {
return { return {
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir }, bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir }, dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
cd: { raw: cd.raw_dir }, cd: { raw: cd.raw_dir, movies: cd.movie_dir },
defaults: { defaults: {
raw: DEFAULT_RAW_DIR, raw: DEFAULT_RAW_DIR,
movies: DEFAULT_MOVIE_DIR, movies: DEFAULT_MOVIE_DIR,

View File

@@ -373,13 +373,21 @@ VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} -
-- Pfade CD -- Pfade CD
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für CD-Rips. Enthält die WAV-Rohdaten (RAW) sowie den encodierten Audio-Output. Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104); VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL);
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045); VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD RAW-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL);
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL);
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL);
-- Metadaten -- Metadaten
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400); VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);

View File

@@ -160,7 +160,7 @@ function buildToolSections(settings) {
// Path keys per medium — _owner keys are rendered inline // Path keys per medium — _owner keys are rendered inline
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray']; const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd']; const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
const CD_PATH_KEYS = ['raw_dir_cd', 'cd_output_template']; const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
const LOG_PATH_KEYS = ['log_dir']; const LOG_PATH_KEYS = ['log_dir'];
function buildSectionsForCategory(categoryName, settings) { function buildSectionsForCategory(categoryName, settings) {
@@ -381,6 +381,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const dvdRaw = ep.dvd?.raw || defaultRaw; const dvdRaw = ep.dvd?.raw || defaultRaw;
const dvdMovies = ep.dvd?.movies || defaultMovies; const dvdMovies = ep.dvd?.movies || defaultMovies;
const cdRaw = ep.cd?.raw || defaultCd; const cdRaw = ep.cd?.raw || defaultCd;
const cdMovies = ep.cd?.movies || cdRaw;
const isDefault = (path, def) => path === def; const isDefault = (path, def) => path === def;
@@ -424,11 +425,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
</td> </td>
</tr> </tr>
<tr> <tr>
<td><strong>CD / Audio (RAW + Output)</strong></td> <td><strong>CD / Audio</strong></td>
<td colSpan={2}> <td>
<code>{cdRaw}</code> <code>{cdRaw}</code>
{isDefault(cdRaw, defaultCd) && <span className="path-default-badge">Standard</span>} {isDefault(cdRaw, defaultCd) && <span className="path-default-badge">Standard</span>}
</td> </td>
<td>
<code>{cdMovies}</code>
{isDefault(cdMovies, cdRaw) && <span className="path-default-badge">Standard</span>}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -768,6 +768,20 @@ export default function DashboardPage({
const memoryMetrics = monitoringSample?.memory || null; const memoryMetrics = monitoringSample?.memory || null;
const gpuMetrics = monitoringSample?.gpu || null; const gpuMetrics = monitoringSample?.gpu || null;
const storageMetrics = Array.isArray(monitoringSample?.storage) ? monitoringSample.storage : []; const storageMetrics = Array.isArray(monitoringSample?.storage) ? monitoringSample.storage : [];
const storageGroups = useMemo(() => {
const groups = [];
const mountMap = new Map();
for (const entry of storageMetrics) {
const groupKey = entry?.mountPoint || `__no_mount_${entry?.key}`;
if (!mountMap.has(groupKey)) {
const group = { mountPoint: entry?.mountPoint || null, entries: [], representative: entry };
mountMap.set(groupKey, group);
groups.push(group);
}
mountMap.get(groupKey).entries.push(entry);
}
return groups;
}, [storageMetrics]);
const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : []; const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : []; const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
@@ -1854,57 +1868,55 @@ export default function DashboardPage({
<section className="hardware-monitor-block"> <section className="hardware-monitor-block">
<h4>Freier Speicher in Pfaden</h4> <h4>Freier Speicher in Pfaden</h4>
<div className="hardware-storage-list"> <div className="hardware-storage-list">
{storageMetrics.map((entry) => { {storageGroups.map((group) => {
const tone = getStorageUsageTone(entry?.usagePercent); const rep = group.representative;
const usagePercent = Number(entry?.usagePercent); const tone = getStorageUsageTone(rep?.usagePercent);
const usagePercent = Number(rep?.usagePercent);
const barValue = Number.isFinite(usagePercent) const barValue = Number.isFinite(usagePercent)
? Math.max(0, Math.min(100, usagePercent)) ? Math.max(0, Math.min(100, usagePercent))
: 0; : 0;
const hasError = Boolean(entry?.error); const hasError = group.entries.every((e) => e?.error);
const entryKey = entry?.key || entry?.path || 'storage-entry'; const groupKey = group.mountPoint || group.entries.map((e) => e?.key).join('-');
return ( return (
<div <div
key={`storage-entry-${entryKey}`} key={`storage-group-${groupKey}`}
className={`hardware-storage-item compact${hasError ? ' has-error' : ''}`} className={`hardware-storage-item compact${hasError ? ' has-error' : ''}`}
> >
<div className="hardware-storage-head"> <div className="hardware-storage-head">
<strong>{entry?.label || entry?.key || 'Pfad'}</strong> <strong>{group.entries.map((e) => e?.label || e?.key || 'Pfad').join(' · ')}</strong>
<span className={`hardware-storage-percent tone-${tone}`}> <span className={`hardware-storage-percent tone-${tone}`}>
{hasError ? 'Fehler' : formatPercent(entry?.usagePercent)} {hasError ? 'Fehler' : formatPercent(rep?.usagePercent)}
</span> </span>
</div> </div>
{hasError ? ( {hasError ? (
<small className="error-text">{entry?.error}</small> <small className="error-text">{rep?.error}</small>
) : ( ) : (
<> <>
<div className={`hardware-storage-bar tone-${tone}`}> <div className={`hardware-storage-bar tone-${tone}`}>
<ProgressBar value={barValue} showValue={false} /> <ProgressBar value={barValue} showValue={false} />
</div> </div>
<div className="hardware-storage-summary"> <div className="hardware-storage-summary">
<small>Frei: {formatBytes(entry?.freeBytes)}</small> <small>Frei: {formatBytes(rep?.freeBytes)}</small>
<small>Gesamt: {formatBytes(entry?.totalBytes)}</small> <small>Gesamt: {formatBytes(rep?.totalBytes)}</small>
</div> </div>
</> </>
)} )}
<div className="hardware-storage-paths"> {group.entries.map((entry) => (
<small className="hardware-storage-label-tag">Pfad:</small> <div key={entry?.key} className="hardware-storage-paths">
<small className="hardware-storage-label-tag">{entry?.label || entry?.key}:</small>
<small className="hardware-storage-path" title={entry?.path || '-'}> <small className="hardware-storage-path" title={entry?.path || '-'}>
{entry?.path || '-'} {entry?.path || '-'}
</small> </small>
{entry?.mountPoint ? (
<small className="hardware-storage-path" title={entry.mountPoint}>
Mount: {entry.mountPoint}
</small>
) : null}
{entry?.queryPath && entry.queryPath !== entry.path ? ( {entry?.queryPath && entry.queryPath !== entry.path ? (
<small className="hardware-storage-path" title={entry.queryPath}> <small className="hardware-storage-path" title={entry.queryPath}>
Parent: {entry.queryPath} (Parent: {entry.queryPath})
</small> </small>
) : null} ) : null}
{entry?.note ? <small className="hardware-storage-path">{entry.note}</small> : null} {entry?.note ? <small className="hardware-storage-path">{entry.note}</small> : null}
</div> </div>
))}
</div> </div>
); );
})} })}