diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 95da306..f620a43 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -195,9 +195,37 @@ router.get( router.post( '/queue/reorder', asyncHandler(async (req, res) => { - const orderedJobIds = Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : []; - logger.info('post:queue:reorder', { reqId: req.reqId, orderedJobIds }); - const queue = await pipelineService.reorderQueue(orderedJobIds); + // Accept orderedEntryIds (new) or orderedJobIds (legacy fallback for job-only queues). + const orderedEntryIds = Array.isArray(req.body?.orderedEntryIds) + ? req.body.orderedEntryIds + : (Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : []); + logger.info('post:queue:reorder', { reqId: req.reqId, orderedEntryIds }); + const queue = await pipelineService.reorderQueue(orderedEntryIds); + res.json({ queue }); + }) +); + +router.post( + '/queue/entry', + asyncHandler(async (req, res) => { + const { type, scriptId, chainId, waitSeconds, insertAfterEntryId } = req.body || {}; + logger.info('post:queue:entry', { reqId: req.reqId, type }); + const result = await pipelineService.enqueueNonJobEntry( + type, + { scriptId, chainId, waitSeconds }, + insertAfterEntryId ?? null + ); + const queue = await pipelineService.getQueueSnapshot(); + res.json({ result, queue }); + }) +); + +router.delete( + '/queue/entry/:entryId', + asyncHandler(async (req, res) => { + const entryId = req.params.entryId; + logger.info('delete:queue:entry', { reqId: req.reqId, entryId }); + const queue = await pipelineService.removeQueueEntry(entryId); res.json({ queue }); }) ); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index f03134b..50b1d74 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -2181,7 +2181,10 @@ class PipelineService extends EventEmitter { const maxParallelJobs = await this.getMaxParallelJobs(); const runningJobs = await historyService.getRunningJobs(); const runningEncodeCount = runningJobs.filter((job) => job.status === 'ENCODING').length; - const queuedJobIds = this.queueEntries.map((entry) => Number(entry.jobId)).filter((id) => Number.isFinite(id) && id > 0); + const queuedJobIds = this.queueEntries + .filter((entry) => !entry.type || entry.type === 'job') + .map((entry) => Number(entry.jobId)) + .filter((id) => Number.isFinite(id) && id > 0); const queuedRows = queuedJobIds.length > 0 ? await historyService.getJobsByIds(queuedJobIds) : []; @@ -2197,16 +2200,51 @@ class PipelineService extends EventEmitter { lastState: job.last_state || null })), queuedJobs: this.queueEntries.map((entry, index) => { - const row = queuedById.get(Number(entry.jobId)); - return { + const entryType = entry.type || 'job'; + const base = { + entryId: entry.id, position: index + 1, + type: entryType, + enqueuedAt: entry.enqueuedAt + }; + + if (entryType === 'script') { + return { ...base, scriptId: entry.scriptId, title: entry.scriptName || `Skript #${entry.scriptId}`, status: 'QUEUED' }; + } + if (entryType === 'chain') { + return { ...base, chainId: entry.chainId, title: entry.chainName || `Kette #${entry.chainId}`, status: 'QUEUED' }; + } + if (entryType === 'wait') { + return { ...base, waitSeconds: entry.waitSeconds, title: `Warten ${entry.waitSeconds}s`, status: 'QUEUED' }; + } + + // type === 'job' + const row = queuedById.get(Number(entry.jobId)); + let hasScripts = false; + let hasChains = false; + if (row?.encode_plan_json) { + try { + const plan = JSON.parse(row.encode_plan_json); + hasScripts = Boolean( + (Array.isArray(plan?.preEncodeScriptIds) && plan.preEncodeScriptIds.length > 0) + || (Array.isArray(plan?.postEncodeScriptIds) && plan.postEncodeScriptIds.length > 0) + ); + hasChains = Boolean( + (Array.isArray(plan?.preEncodeChainIds) && plan.preEncodeChainIds.length > 0) + || (Array.isArray(plan?.postEncodeChainIds) && plan.postEncodeChainIds.length > 0) + ); + } catch (_) { /* ignore */ } + } + return { + ...base, jobId: Number(entry.jobId), action: entry.action, actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action, - enqueuedAt: entry.enqueuedAt, title: row?.title || row?.detected_title || `Job #${entry.jobId}`, status: row?.status || null, - lastState: row?.last_state || null + lastState: row?.last_state || null, + hasScripts, + hasChains }; }), queuedCount: this.queueEntries.length, @@ -2225,28 +2263,101 @@ class PipelineService extends EventEmitter { } } - async reorderQueue(orderedJobIds = []) { - const incoming = Array.isArray(orderedJobIds) - ? orderedJobIds - .map((value) => this.normalizeQueueJobId(value)) - .filter((value) => value !== null) + async reorderQueue(orderedEntryIds = []) { + const incoming = Array.isArray(orderedEntryIds) + ? orderedEntryIds.map((value) => Number(value)).filter((v) => Number.isFinite(v) && v > 0) : []; - const currentIds = this.queueEntries.map((entry) => Number(entry.jobId)); - if (incoming.length !== currentIds.length) { + if (incoming.length !== this.queueEntries.length) { const error = new Error('Queue-Reihenfolge ungültig: Anzahl passt nicht.'); error.statusCode = 400; throw error; } - const incomingSet = new Set(incoming.map((id) => String(id))); - if (incomingSet.size !== incoming.length || currentIds.some((id) => !incomingSet.has(String(id)))) { + const currentIdSet = new Set(this.queueEntries.map((entry) => entry.id)); + const incomingSet = new Set(incoming); + if (incomingSet.size !== incoming.length || incoming.some((id) => !currentIdSet.has(id))) { const error = new Error('Queue-Reihenfolge ungültig: IDs passen nicht zur aktuellen Queue.'); error.statusCode = 400; throw error; } - const byId = new Map(this.queueEntries.map((entry) => [Number(entry.jobId), entry])); - this.queueEntries = incoming.map((id) => byId.get(Number(id))).filter(Boolean); + const byEntryId = new Map(this.queueEntries.map((entry) => [entry.id, entry])); + this.queueEntries = incoming.map((id) => byEntryId.get(id)).filter(Boolean); + await this.emitQueueChanged(); + return this.lastQueueSnapshot; + } + + async enqueueNonJobEntry(type, params = {}, insertAfterEntryId = null) { + const validTypes = new Set(['script', 'chain', 'wait']); + if (!validTypes.has(type)) { + const error = new Error(`Unbekannter Queue-Eintragstyp: ${type}`); + error.statusCode = 400; + throw error; + } + + let entry; + if (type === 'script') { + const scriptId = Number(params.scriptId); + if (!Number.isFinite(scriptId) || scriptId <= 0) { + const error = new Error('scriptId fehlt oder ist ungültig.'); + error.statusCode = 400; + throw error; + } + const scriptService = require('./scriptService'); + let script; + try { script = await scriptService.getScriptById(scriptId); } catch (_) { /* ignore */ } + entry = { id: this.queueEntrySeq++, type: 'script', scriptId, scriptName: script?.name || null, enqueuedAt: nowIso() }; + } else if (type === 'chain') { + const chainId = Number(params.chainId); + if (!Number.isFinite(chainId) || chainId <= 0) { + const error = new Error('chainId fehlt oder ist ungültig.'); + error.statusCode = 400; + throw error; + } + const scriptChainService = require('./scriptChainService'); + let chain; + try { chain = await scriptChainService.getChainById(chainId); } catch (_) { /* ignore */ } + entry = { id: this.queueEntrySeq++, type: 'chain', chainId, chainName: chain?.name || null, enqueuedAt: nowIso() }; + } else { + const waitSeconds = Math.round(Number(params.waitSeconds)); + if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) { + const error = new Error('waitSeconds muss zwischen 1 und 3600 liegen.'); + error.statusCode = 400; + throw error; + } + entry = { id: this.queueEntrySeq++, type: 'wait', waitSeconds, enqueuedAt: nowIso() }; + } + + if (insertAfterEntryId != null) { + const idx = this.queueEntries.findIndex((e) => e.id === Number(insertAfterEntryId)); + if (idx >= 0) { + this.queueEntries.splice(idx + 1, 0, entry); + } else { + this.queueEntries.push(entry); + } + } else { + this.queueEntries.push(entry); + } + + await this.emitQueueChanged(); + void this.pumpQueue(); + return { entryId: entry.id, type, position: this.queueEntries.indexOf(entry) + 1 }; + } + + async removeQueueEntry(entryId) { + const normalizedId = Number(entryId); + if (!Number.isFinite(normalizedId) || normalizedId <= 0) { + const error = new Error('Ungültige entryId.'); + error.statusCode = 400; + throw error; + } + const idx = this.queueEntries.findIndex((e) => e.id === normalizedId); + if (idx < 0) { + const error = new Error(`Queue-Eintrag #${normalizedId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + this.queueEntries.splice(idx, 1); await this.emitQueueChanged(); return this.lastQueueSnapshot; } @@ -2315,6 +2426,56 @@ class PipelineService extends EventEmitter { }; } + async dispatchNonJobEntry(entry) { + const type = entry?.type; + logger.info('queue:non-job:dispatch', { type, entryId: entry?.id }); + + if (type === 'wait') { + const seconds = Math.max(1, Number(entry.waitSeconds || 1)); + logger.info('queue:wait:start', { seconds }); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + logger.info('queue:wait:done', { seconds }); + return; + } + + if (type === 'script') { + const scriptService = require('./scriptService'); + let script; + try { script = await scriptService.getScriptById(entry.scriptId); } catch (_) { /* ignore */ } + if (!script) { + logger.warn('queue:script:not-found', { scriptId: entry.scriptId }); + return; + } + let prepared = null; + try { + prepared = await scriptService.createExecutableScriptFile(script, { source: 'queue', scriptId: script.id, scriptName: script.name }); + const { spawn } = require('child_process'); + await new Promise((resolve, reject) => { + const child = spawn(prepared.cmd, prepared.args, { env: process.env, stdio: 'ignore' }); + child.on('error', reject); + child.on('close', (code) => { + logger.info('queue:script:done', { scriptId: script.id, exitCode: code }); + resolve(); + }); + }); + } catch (err) { + logger.error('queue:script:error', { scriptId: entry.scriptId, error: errorToMeta(err) }); + } finally { + if (prepared?.cleanup) await prepared.cleanup(); + } + return; + } + + if (type === 'chain') { + const scriptChainService = require('./scriptChainService'); + try { + await scriptChainService.executeChain(entry.chainId, { source: 'queue' }); + } catch (err) { + logger.error('queue:chain:error', { chainId: entry.chainId, error: errorToMeta(err) }); + } + } + } + async dispatchQueuedEntry(entry) { const action = entry?.action; const jobId = Number(entry?.jobId); @@ -2352,10 +2513,16 @@ class PipelineService extends EventEmitter { this.queuePumpRunning = true; try { while (this.queueEntries.length > 0) { - const maxParallelJobs = await this.getMaxParallelJobs(); - const runningEncodeJobs = await historyService.getRunningEncodeJobs(); - if (runningEncodeJobs.length >= maxParallelJobs) { - break; + const firstEntry = this.queueEntries[0]; + const isNonJob = firstEntry?.type && firstEntry.type !== 'job'; + + if (!isNonJob) { + // Job entries: respect the parallel encode limit. + const maxParallelJobs = await this.getMaxParallelJobs(); + const runningEncodeJobs = await historyService.getRunningEncodeJobs(); + if (runningEncodeJobs.length >= maxParallelJobs) { + break; + } } const entry = this.queueEntries.shift(); @@ -2365,6 +2532,10 @@ class PipelineService extends EventEmitter { await this.emitQueueChanged(); try { + if (isNonJob) { + await this.dispatchNonJobEntry(entry); + continue; + } await historyService.appendLog( entry.jobId, 'SYSTEM', @@ -2378,15 +2549,18 @@ class PipelineService extends EventEmitter { break; } logger.error('queue:entry:failed', { + type: entry.type || 'job', action: entry.action, jobId: entry.jobId, error: errorToMeta(error) }); - await historyService.appendLog( - entry.jobId, - 'SYSTEM', - `Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}` - ); + if (entry.jobId) { + await historyService.appendLog( + entry.jobId, + 'SYSTEM', + `Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}` + ); + } } } } finally { @@ -4004,6 +4178,20 @@ class PipelineService extends EventEmitter { const posterValue = poster === undefined ? (job.poster_url || null) : (poster || null); + + // Fetch full OMDb details when selecting from OMDb with a valid IMDb ID. + let omdbJsonValue = job.omdb_json || null; + if (fromOmdb && effectiveImdbId) { + try { + const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId); + if (omdbFull?.raw) { + omdbJsonValue = JSON.stringify(omdbFull.raw); + } + } catch (omdbErr) { + logger.warn('metadata:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) }); + } + } + const selectedMetadata = { title: effectiveTitle, year: effectiveYear, @@ -4059,6 +4247,7 @@ class PipelineService extends EventEmitter { imdb_id: effectiveImdbId, poster_url: posterValue, selected_from_omdb: selectedFromOmdb, + omdb_json: omdbJsonValue, status: nextStatus, last_state: nextStatus, raw_path: updatedRawPath, @@ -5541,6 +5730,17 @@ class PipelineService extends EventEmitter { const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection ? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || []) : []; + const preRipPreEncodeScriptIds = hasPreRipConfirmedSelection + ? normalizeScriptIdList(preRipPlanBeforeRip?.preEncodeScriptIds || []) + : []; + const preRipPostEncodeChainIds = hasPreRipConfirmedSelection + ? (Array.isArray(preRipPlanBeforeRip?.postEncodeChainIds) ? preRipPlanBeforeRip.postEncodeChainIds : []) + .map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; + const preRipPreEncodeChainIds = hasPreRipConfirmedSelection + ? (Array.isArray(preRipPlanBeforeRip?.preEncodeChainIds) ? preRipPlanBeforeRip.preEncodeChainIds : []) + .map(Number).filter((id) => Number.isFinite(id) && id > 0) + : []; const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job); const selectedTitleId = playlistDecision.selectedTitleId; const selectedPlaylist = playlistDecision.selectedPlaylist; @@ -5726,7 +5926,10 @@ class PipelineService extends EventEmitter { await this.confirmEncodeReview(jobId, { selectedEncodeTitleId: review?.encodeInputTitleId || null, selectedTrackSelection: preRipTrackSelectionPayload || null, - selectedPostEncodeScriptIds: preRipPostEncodeScriptIds + selectedPostEncodeScriptIds: preRipPostEncodeScriptIds, + selectedPreEncodeScriptIds: preRipPreEncodeScriptIds, + selectedPostEncodeChainIds: preRipPostEncodeChainIds, + selectedPreEncodeChainIds: preRipPreEncodeChainIds }); const autoStartResult = await this.startPreparedJob(jobId); logger.info('rip:auto-encode-started', { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b1f647b..acc4257 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -169,10 +169,21 @@ export const api = { getPipelineQueue() { return request('/pipeline/queue'); }, - reorderPipelineQueue(orderedJobIds = []) { + reorderPipelineQueue(orderedEntryIds = []) { return request('/pipeline/queue/reorder', { method: 'POST', - body: JSON.stringify({ orderedJobIds: Array.isArray(orderedJobIds) ? orderedJobIds : [] }) + body: JSON.stringify({ orderedEntryIds: Array.isArray(orderedEntryIds) ? orderedEntryIds : [] }) + }); + }, + addQueueEntry(payload = {}) { + return request('/pipeline/queue/entry', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, + removeQueueEntry(entryId) { + return request(`/pipeline/queue/entry/${encodeURIComponent(entryId)}`, { + method: 'DELETE' }); }, getJobs(params = {}) { diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 9153954..94bc48e 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -5,6 +5,7 @@ import { Button } from 'primereact/button'; import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Dialog } from 'primereact/dialog'; +import { InputNumber } from 'primereact/inputnumber'; import { api } from '../api/client'; import PipelineStatusCard from '../components/PipelineStatusCard'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; @@ -140,10 +141,10 @@ function showQueuedToast(toastRef, actionLabel, result) { }); } -function reorderQueuedItems(items, draggedJobId, targetJobId) { +function reorderQueuedItems(items, draggedEntryId, targetEntryId) { const list = Array.isArray(items) ? items : []; - const from = list.findIndex((item) => Number(item?.jobId) === Number(draggedJobId)); - const to = list.findIndex((item) => Number(item?.jobId) === Number(targetJobId)); + const from = list.findIndex((item) => Number(item?.entryId) === Number(draggedEntryId)); + const to = list.findIndex((item) => Number(item?.entryId) === Number(targetEntryId)); if (from < 0 || to < 0 || from === to) { return list; } @@ -156,6 +157,20 @@ function reorderQueuedItems(items, draggedJobId, targetJobId) { })); } +function queueEntryIcon(type) { + if (type === 'script') return 'pi pi-code'; + if (type === 'chain') return 'pi pi-link'; + if (type === 'wait') return 'pi pi-clock'; + return 'pi pi-box'; +} + +function queueEntryLabel(item) { + if (item.type === 'script') return `Skript: ${item.title}`; + if (item.type === 'chain') return `Kette: ${item.title}`; + if (item.type === 'wait') return `Warten: ${item.waitSeconds}s`; + return item.title || `Job #${item.jobId}`; +} + function getAnalyzeContext(job) { return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object' ? job.makemkvInfo.analyzeContext @@ -338,12 +353,15 @@ export default function DashboardPage({ const [cancelCleanupBusy, setCancelCleanupBusy] = useState(false); const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue)); const [queueReorderBusy, setQueueReorderBusy] = useState(false); - const [draggingQueueJobId, setDraggingQueueJobId] = useState(null); + const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null); + const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null }); const [liveJobLog, setLiveJobLog] = useState(''); const [jobsLoading, setJobsLoading] = useState(false); const [dashboardJobs, setDashboardJobs] = useState([]); const [expandedJobId, setExpandedJobId] = useState(undefined); const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false); + const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] }); + const [insertWaitSeconds, setInsertWaitSeconds] = useState(30); const toastRef = useRef(null); const state = String(pipeline?.state || 'IDLE').trim().toUpperCase(); @@ -358,6 +376,20 @@ export default function DashboardPage({ const memoryMetrics = monitoringSample?.memory || null; const gpuMetrics = monitoringSample?.gpu || null; 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 gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : []; @@ -859,9 +891,9 @@ export default function DashboardPage({ } }; - const handleQueueDragEnter = (targetJobId) => { - const targetId = normalizeJobId(targetJobId); - const draggedId = normalizeJobId(draggingQueueJobId); + const handleQueueDragEnter = (targetEntryId) => { + const targetId = Number(targetEntryId); + const draggedId = Number(draggingQueueEntryId); if (!targetId || !draggedId || targetId === draggedId || queueReorderBusy) { return; } @@ -876,22 +908,22 @@ export default function DashboardPage({ }; const handleQueueDrop = async () => { - const draggedId = normalizeJobId(draggingQueueJobId); - setDraggingQueueJobId(null); + const draggedId = Number(draggingQueueEntryId); + setDraggingQueueEntryId(null); if (!draggedId || queueReorderBusy) { return; } - const orderedJobIds = (Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : []) - .map((item) => normalizeJobId(item?.jobId)) + const orderedEntryIds = (Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : []) + .map((item) => Number(item?.entryId)) .filter(Boolean); - if (orderedJobIds.length <= 1) { + if (orderedEntryIds.length <= 1) { return; } setQueueReorderBusy(true); try { - const response = await api.reorderPipelineQueue(orderedJobIds); + const response = await api.reorderPipelineQueue(orderedEntryIds); setQueueState(normalizeQueue(response?.queue)); } catch (error) { showError(error); @@ -924,6 +956,43 @@ export default function DashboardPage({ } }; + const handleRemoveQueueEntry = async (entryId) => { + if (!entryId || queueReorderBusy) { + return; + } + setQueueReorderBusy(true); + try { + const response = await api.removeQueueEntry(entryId); + setQueueState(normalizeQueue(response?.queue)); + } catch (error) { + showError(error); + } finally { + setQueueReorderBusy(false); + } + }; + + const openInsertQueueDialog = async (afterEntryId) => { + setInsertQueueDialog({ visible: true, afterEntryId: afterEntryId ?? null }); + try { + const [scriptsRes, chainsRes] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]); + setQueueCatalog({ + scripts: scriptsRes.status === 'fulfilled' ? (Array.isArray(scriptsRes.value?.scripts) ? scriptsRes.value.scripts : []) : [], + chains: chainsRes.status === 'fulfilled' ? (Array.isArray(chainsRes.value?.chains) ? chainsRes.value.chains : []) : [] + }); + } catch (_) { /* ignore */ } + }; + + const handleAddQueueEntry = async (type, params) => { + const afterEntryId = insertQueueDialog.afterEntryId; + setInsertQueueDialog({ visible: false, afterEntryId: null }); + try { + const response = await api.addQueueEntry({ type, ...params, insertAfterEntryId: afterEntryId }); + setQueueState(normalizeQueue(response?.queue)); + } catch (error) { + showError(error); + } + }; + const syncQueueFromServer = async () => { try { const latest = await api.getPipelineQueue(); @@ -1100,47 +1169,55 @@ export default function DashboardPage({

Freier Speicher in Pfaden

- {storageMetrics.map((entry) => { - const tone = getStorageUsageTone(entry?.usagePercent); - const usagePercent = Number(entry?.usagePercent); + {storageGroups.map((group) => { + const rep = group.representative; + const tone = getStorageUsageTone(rep?.usagePercent); + const usagePercent = Number(rep?.usagePercent); const barValue = Number.isFinite(usagePercent) ? Math.max(0, Math.min(100, usagePercent)) : 0; + const hasError = group.entries.every((e) => e?.error); + const groupKey = group.mountPoint || group.entries.map((e) => e?.key).join('-'); return (
- {entry?.label || entry?.key || 'Pfad'} + {group.entries.map((e) => e?.label || e?.key || 'Pfad').join(' · ')} - {entry?.error ? 'Fehler' : formatPercent(entry?.usagePercent)} + {hasError ? 'Fehler' : formatPercent(rep?.usagePercent)}
- {entry?.error ? ( - {entry.error} + {hasError ? ( + {rep?.error} ) : ( <>
- Frei: {formatBytes(entry?.freeBytes)} - Gesamt: {formatBytes(entry?.totalBytes)} + Frei: {formatBytes(rep?.freeBytes)} + Gesamt: {formatBytes(rep?.totalBytes)}
)} - - Pfad: {entry?.path || '-'} - - {entry?.queryPath && entry.queryPath !== entry.path ? ( - - Parent: {entry.queryPath} - - ) : null} - {entry?.note ? {entry.note} : null} + {group.entries.map((entry) => ( +
+ {entry?.label || entry?.key}: + + {entry?.path || '-'} + + {entry?.queryPath && entry.queryPath !== entry.path ? ( + + (Parent: {entry.queryPath}) + + ) : null} + {entry?.note ? {entry.note} : null} +
+ ))}
); })} @@ -1172,54 +1249,91 @@ export default function DashboardPage({ )}
-

Warteschlange

+
+

Warteschlange

+ +
{queuedJobs.length === 0 ? ( - Queue ist leer. + Queue ist leer. ) : ( - queuedJobs.map((item) => { - const queuedJobId = normalizeJobId(item?.jobId); - const isDragging = normalizeJobId(draggingQueueJobId) === queuedJobId; - return ( -
setDraggingQueueJobId(queuedJobId)} - onDragEnter={() => handleQueueDragEnter(queuedJobId)} - onDragOver={(event) => event.preventDefault()} - onDrop={(event) => { - event.preventDefault(); - void handleQueueDrop(); - }} - onDragEnd={() => { - setDraggingQueueJobId(null); - void syncQueueFromServer(); - }} - > - - - -
- {item.position || '-'} | #{item.jobId} | {item.title || `Job #${item.jobId}`} - {item.actionLabel || item.action || '-'} | Status {getStatusLabel(item.status)} + <> + {queuedJobs.map((item) => { + const entryId = Number(item?.entryId); + const isNonJob = item.type && item.type !== 'job'; + const isDragging = Number(draggingQueueEntryId) === entryId; + return ( +
+
setDraggingQueueEntryId(entryId)} + onDragEnter={() => handleQueueDragEnter(entryId)} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + void handleQueueDrop(); + }} + onDragEnd={() => { + setDraggingQueueEntryId(null); + void syncQueueFromServer(); + }} + > + + + + +
+ {isNonJob ? ( + {item.position || '-'}. {queueEntryLabel(item)} + ) : ( + <> + + {item.position || '-'} | #{item.jobId} | {item.title || `Job #${item.jobId}`} + {item.hasScripts ? : null} + {item.hasChains ? : null} + + {item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)} + + )} +
+
+
-
- ); - }) + ); + })} + )}
@@ -1453,6 +1567,81 @@ export default function DashboardPage({ /> + + setInsertQueueDialog({ visible: false, afterEntryId: null })} + style={{ width: '28rem', maxWidth: '96vw' }} + modal + > +
+

+ {insertQueueDialog.afterEntryId + ? 'Eintrag wird nach dem ausgewählten Element eingefügt.' + : 'Eintrag wird am Ende der Queue eingefügt.'} +

+ + {queueCatalog.scripts.length > 0 ? ( +
+ Skript +
+ {queueCatalog.scripts.map((script) => ( + + ))} +
+
+ ) : null} + + {queueCatalog.chains.length > 0 ? ( +
+ Skriptkette +
+ {queueCatalog.chains.map((chain) => ( + + ))} +
+
+ ) : null} + +
+ Warten +
+ setInsertWaitSeconds(e.value ?? 30)} + min={1} + max={3600} + suffix="s" + style={{ width: '7rem' }} + /> +
+
+ + {queueCatalog.scripts.length === 0 && queueCatalog.chains.length === 0 ? ( + Keine Skripte oder Ketten konfiguriert. In den Settings anlegen. + ) : null} +
+
); } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 480225d..8e3e4b9 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -477,6 +477,20 @@ body { color: var(--rip-muted); } +.hardware-storage-paths { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.4rem; + align-items: baseline; + margin-top: 0.1rem; +} + +.hardware-storage-label-tag { + color: var(--rip-muted); + font-weight: 600; + white-space: nowrap; +} + .pipeline-queue-meta { display: flex; gap: 0.45rem; @@ -505,6 +519,39 @@ body { margin: 0; } +.pipeline-queue-col-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.queue-add-entry-btn { + background: none; + border: 1px solid var(--rip-border); + border-radius: 0.35rem; + color: var(--rip-muted); + cursor: pointer; + font-size: 0.75rem; + padding: 0.15rem 0.5rem; + display: flex; + align-items: center; + gap: 0.25rem; + transition: color 0.12s, border-color 0.12s, background 0.12s; + white-space: nowrap; +} + +.queue-add-entry-btn:hover { + color: var(--rip-primary, #6366f1); + border-color: var(--rip-primary, #6366f1); + background: var(--rip-panel); +} + +.queue-empty-hint { + color: var(--rip-muted); + font-size: 0.82rem; +} + .pipeline-queue-item { border: 1px dashed var(--rip-border); border-radius: 0.45rem; @@ -519,11 +566,116 @@ body { } .pipeline-queue-item.queued { - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; gap: 0.45rem; } +.pipeline-queue-item.queued.non-job { + border-style: dotted; + background: var(--rip-surface); + opacity: 0.92; +} + +.pipeline-queue-type-icon { + font-size: 0.82rem; + color: var(--rip-muted); + flex-shrink: 0; +} + +.queue-job-tag { + font-size: 0.72rem; + color: var(--rip-muted); + margin-left: 0.3rem; + vertical-align: middle; +} + +.pipeline-queue-entry-wrap { + display: flex; + flex-direction: column; + gap: 0; +} + +.queue-insert-btn { + align-self: center; + background: none; + border: 1px dashed var(--rip-border); + border-radius: 999px; + color: var(--rip-muted); + cursor: pointer; + font-size: 0.7rem; + line-height: 1; + padding: 0.1rem 0.5rem; + margin: 0.15rem auto; + opacity: 0.4; + transition: opacity 0.15s; +} + +.queue-insert-btn:hover { + opacity: 1; + color: var(--rip-primary, #6366f1); + border-color: var(--rip-primary, #6366f1); +} + +.pipeline-queue-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; +} + +.queue-insert-dialog-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.queue-insert-dialog-hint { + margin: 0; + font-size: 0.82rem; + color: var(--rip-muted); +} + +.queue-insert-section { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding-top: 0.5rem; + border-top: 1px solid var(--rip-border); +} + +.queue-insert-section:first-of-type { + border-top: none; +} + +.queue-insert-options { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.queue-insert-option { + background: var(--rip-surface); + border: 1px solid var(--rip-border); + border-radius: 0.35rem; + color: inherit; + cursor: pointer; + font-size: 0.82rem; + padding: 0.3rem 0.65rem; + transition: background 0.12s; +} + +.queue-insert-option:hover { + background: var(--rip-panel); + border-color: var(--rip-primary, #6366f1); +} + +.queue-insert-wait-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + .pipeline-queue-item.queued.dragging { opacity: 0.65; }