Update
This commit is contained in:
@@ -195,9 +195,37 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
'/queue/reorder',
|
'/queue/reorder',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const orderedJobIds = Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : [];
|
// Accept orderedEntryIds (new) or orderedJobIds (legacy fallback for job-only queues).
|
||||||
logger.info('post:queue:reorder', { reqId: req.reqId, orderedJobIds });
|
const orderedEntryIds = Array.isArray(req.body?.orderedEntryIds)
|
||||||
const queue = await pipelineService.reorderQueue(orderedJobIds);
|
? 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 });
|
res.json({ queue });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2181,7 +2181,10 @@ class PipelineService extends EventEmitter {
|
|||||||
const maxParallelJobs = await this.getMaxParallelJobs();
|
const maxParallelJobs = await this.getMaxParallelJobs();
|
||||||
const runningJobs = await historyService.getRunningJobs();
|
const runningJobs = await historyService.getRunningJobs();
|
||||||
const runningEncodeCount = runningJobs.filter((job) => job.status === 'ENCODING').length;
|
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
|
const queuedRows = queuedJobIds.length > 0
|
||||||
? await historyService.getJobsByIds(queuedJobIds)
|
? await historyService.getJobsByIds(queuedJobIds)
|
||||||
: [];
|
: [];
|
||||||
@@ -2197,16 +2200,51 @@ class PipelineService extends EventEmitter {
|
|||||||
lastState: job.last_state || null
|
lastState: job.last_state || null
|
||||||
})),
|
})),
|
||||||
queuedJobs: this.queueEntries.map((entry, index) => {
|
queuedJobs: this.queueEntries.map((entry, index) => {
|
||||||
const row = queuedById.get(Number(entry.jobId));
|
const entryType = entry.type || 'job';
|
||||||
return {
|
const base = {
|
||||||
|
entryId: entry.id,
|
||||||
position: index + 1,
|
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),
|
jobId: Number(entry.jobId),
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action,
|
actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action,
|
||||||
enqueuedAt: entry.enqueuedAt,
|
|
||||||
title: row?.title || row?.detected_title || `Job #${entry.jobId}`,
|
title: row?.title || row?.detected_title || `Job #${entry.jobId}`,
|
||||||
status: row?.status || null,
|
status: row?.status || null,
|
||||||
lastState: row?.last_state || null
|
lastState: row?.last_state || null,
|
||||||
|
hasScripts,
|
||||||
|
hasChains
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
queuedCount: this.queueEntries.length,
|
queuedCount: this.queueEntries.length,
|
||||||
@@ -2225,28 +2263,101 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async reorderQueue(orderedJobIds = []) {
|
async reorderQueue(orderedEntryIds = []) {
|
||||||
const incoming = Array.isArray(orderedJobIds)
|
const incoming = Array.isArray(orderedEntryIds)
|
||||||
? orderedJobIds
|
? orderedEntryIds.map((value) => Number(value)).filter((v) => Number.isFinite(v) && v > 0)
|
||||||
.map((value) => this.normalizeQueueJobId(value))
|
|
||||||
.filter((value) => value !== null)
|
|
||||||
: [];
|
: [];
|
||||||
const currentIds = this.queueEntries.map((entry) => Number(entry.jobId));
|
if (incoming.length !== this.queueEntries.length) {
|
||||||
if (incoming.length !== currentIds.length) {
|
|
||||||
const error = new Error('Queue-Reihenfolge ungültig: Anzahl passt nicht.');
|
const error = new Error('Queue-Reihenfolge ungültig: Anzahl passt nicht.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingSet = new Set(incoming.map((id) => String(id)));
|
const currentIdSet = new Set(this.queueEntries.map((entry) => entry.id));
|
||||||
if (incomingSet.size !== incoming.length || currentIds.some((id) => !incomingSet.has(String(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.');
|
const error = new Error('Queue-Reihenfolge ungültig: IDs passen nicht zur aktuellen Queue.');
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const byId = new Map(this.queueEntries.map((entry) => [Number(entry.jobId), entry]));
|
const byEntryId = new Map(this.queueEntries.map((entry) => [entry.id, entry]));
|
||||||
this.queueEntries = incoming.map((id) => byId.get(Number(id))).filter(Boolean);
|
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();
|
await this.emitQueueChanged();
|
||||||
return this.lastQueueSnapshot;
|
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) {
|
async dispatchQueuedEntry(entry) {
|
||||||
const action = entry?.action;
|
const action = entry?.action;
|
||||||
const jobId = Number(entry?.jobId);
|
const jobId = Number(entry?.jobId);
|
||||||
@@ -2352,10 +2513,16 @@ class PipelineService extends EventEmitter {
|
|||||||
this.queuePumpRunning = true;
|
this.queuePumpRunning = true;
|
||||||
try {
|
try {
|
||||||
while (this.queueEntries.length > 0) {
|
while (this.queueEntries.length > 0) {
|
||||||
const maxParallelJobs = await this.getMaxParallelJobs();
|
const firstEntry = this.queueEntries[0];
|
||||||
const runningEncodeJobs = await historyService.getRunningEncodeJobs();
|
const isNonJob = firstEntry?.type && firstEntry.type !== 'job';
|
||||||
if (runningEncodeJobs.length >= maxParallelJobs) {
|
|
||||||
break;
|
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();
|
const entry = this.queueEntries.shift();
|
||||||
@@ -2365,6 +2532,10 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
await this.emitQueueChanged();
|
await this.emitQueueChanged();
|
||||||
try {
|
try {
|
||||||
|
if (isNonJob) {
|
||||||
|
await this.dispatchNonJobEntry(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
entry.jobId,
|
entry.jobId,
|
||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
@@ -2378,15 +2549,18 @@ class PipelineService extends EventEmitter {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
logger.error('queue:entry:failed', {
|
logger.error('queue:entry:failed', {
|
||||||
|
type: entry.type || 'job',
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
jobId: entry.jobId,
|
jobId: entry.jobId,
|
||||||
error: errorToMeta(error)
|
error: errorToMeta(error)
|
||||||
});
|
});
|
||||||
await historyService.appendLog(
|
if (entry.jobId) {
|
||||||
entry.jobId,
|
await historyService.appendLog(
|
||||||
'SYSTEM',
|
entry.jobId,
|
||||||
`Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}`
|
'SYSTEM',
|
||||||
);
|
`Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4004,6 +4178,20 @@ class PipelineService extends EventEmitter {
|
|||||||
const posterValue = poster === undefined
|
const posterValue = poster === undefined
|
||||||
? (job.poster_url || null)
|
? (job.poster_url || null)
|
||||||
: (poster || 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 = {
|
const selectedMetadata = {
|
||||||
title: effectiveTitle,
|
title: effectiveTitle,
|
||||||
year: effectiveYear,
|
year: effectiveYear,
|
||||||
@@ -4059,6 +4247,7 @@ class PipelineService extends EventEmitter {
|
|||||||
imdb_id: effectiveImdbId,
|
imdb_id: effectiveImdbId,
|
||||||
poster_url: posterValue,
|
poster_url: posterValue,
|
||||||
selected_from_omdb: selectedFromOmdb,
|
selected_from_omdb: selectedFromOmdb,
|
||||||
|
omdb_json: omdbJsonValue,
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
last_state: nextStatus,
|
last_state: nextStatus,
|
||||||
raw_path: updatedRawPath,
|
raw_path: updatedRawPath,
|
||||||
@@ -5541,6 +5730,17 @@ class PipelineService extends EventEmitter {
|
|||||||
const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection
|
const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection
|
||||||
? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || [])
|
? 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 playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
|
||||||
const selectedTitleId = playlistDecision.selectedTitleId;
|
const selectedTitleId = playlistDecision.selectedTitleId;
|
||||||
const selectedPlaylist = playlistDecision.selectedPlaylist;
|
const selectedPlaylist = playlistDecision.selectedPlaylist;
|
||||||
@@ -5726,7 +5926,10 @@ class PipelineService extends EventEmitter {
|
|||||||
await this.confirmEncodeReview(jobId, {
|
await this.confirmEncodeReview(jobId, {
|
||||||
selectedEncodeTitleId: review?.encodeInputTitleId || null,
|
selectedEncodeTitleId: review?.encodeInputTitleId || null,
|
||||||
selectedTrackSelection: preRipTrackSelectionPayload || null,
|
selectedTrackSelection: preRipTrackSelectionPayload || null,
|
||||||
selectedPostEncodeScriptIds: preRipPostEncodeScriptIds
|
selectedPostEncodeScriptIds: preRipPostEncodeScriptIds,
|
||||||
|
selectedPreEncodeScriptIds: preRipPreEncodeScriptIds,
|
||||||
|
selectedPostEncodeChainIds: preRipPostEncodeChainIds,
|
||||||
|
selectedPreEncodeChainIds: preRipPreEncodeChainIds
|
||||||
});
|
});
|
||||||
const autoStartResult = await this.startPreparedJob(jobId);
|
const autoStartResult = await this.startPreparedJob(jobId);
|
||||||
logger.info('rip:auto-encode-started', {
|
logger.info('rip:auto-encode-started', {
|
||||||
|
|||||||
@@ -169,10 +169,21 @@ export const api = {
|
|||||||
getPipelineQueue() {
|
getPipelineQueue() {
|
||||||
return request('/pipeline/queue');
|
return request('/pipeline/queue');
|
||||||
},
|
},
|
||||||
reorderPipelineQueue(orderedJobIds = []) {
|
reorderPipelineQueue(orderedEntryIds = []) {
|
||||||
return request('/pipeline/queue/reorder', {
|
return request('/pipeline/queue/reorder', {
|
||||||
method: 'POST',
|
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 = {}) {
|
getJobs(params = {}) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from 'primereact/button';
|
|||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
|
import { InputNumber } from 'primereact/inputnumber';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import PipelineStatusCard from '../components/PipelineStatusCard';
|
import PipelineStatusCard from '../components/PipelineStatusCard';
|
||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
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 list = Array.isArray(items) ? items : [];
|
||||||
const from = list.findIndex((item) => Number(item?.jobId) === Number(draggedJobId));
|
const from = list.findIndex((item) => Number(item?.entryId) === Number(draggedEntryId));
|
||||||
const to = list.findIndex((item) => Number(item?.jobId) === Number(targetJobId));
|
const to = list.findIndex((item) => Number(item?.entryId) === Number(targetEntryId));
|
||||||
if (from < 0 || to < 0 || from === to) {
|
if (from < 0 || to < 0 || from === to) {
|
||||||
return list;
|
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) {
|
function getAnalyzeContext(job) {
|
||||||
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
||||||
? job.makemkvInfo.analyzeContext
|
? job.makemkvInfo.analyzeContext
|
||||||
@@ -338,12 +353,15 @@ export default function DashboardPage({
|
|||||||
const [cancelCleanupBusy, setCancelCleanupBusy] = useState(false);
|
const [cancelCleanupBusy, setCancelCleanupBusy] = useState(false);
|
||||||
const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
|
const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
|
||||||
const [queueReorderBusy, setQueueReorderBusy] = useState(false);
|
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 [liveJobLog, setLiveJobLog] = useState('');
|
||||||
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 [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||||
|
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||||
|
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
||||||
@@ -358,6 +376,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 : [];
|
||||||
|
|
||||||
@@ -859,9 +891,9 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQueueDragEnter = (targetJobId) => {
|
const handleQueueDragEnter = (targetEntryId) => {
|
||||||
const targetId = normalizeJobId(targetJobId);
|
const targetId = Number(targetEntryId);
|
||||||
const draggedId = normalizeJobId(draggingQueueJobId);
|
const draggedId = Number(draggingQueueEntryId);
|
||||||
if (!targetId || !draggedId || targetId === draggedId || queueReorderBusy) {
|
if (!targetId || !draggedId || targetId === draggedId || queueReorderBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -876,22 +908,22 @@ export default function DashboardPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleQueueDrop = async () => {
|
const handleQueueDrop = async () => {
|
||||||
const draggedId = normalizeJobId(draggingQueueJobId);
|
const draggedId = Number(draggingQueueEntryId);
|
||||||
setDraggingQueueJobId(null);
|
setDraggingQueueEntryId(null);
|
||||||
if (!draggedId || queueReorderBusy) {
|
if (!draggedId || queueReorderBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedJobIds = (Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [])
|
const orderedEntryIds = (Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [])
|
||||||
.map((item) => normalizeJobId(item?.jobId))
|
.map((item) => Number(item?.entryId))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (orderedJobIds.length <= 1) {
|
if (orderedEntryIds.length <= 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setQueueReorderBusy(true);
|
setQueueReorderBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.reorderPipelineQueue(orderedJobIds);
|
const response = await api.reorderPipelineQueue(orderedEntryIds);
|
||||||
setQueueState(normalizeQueue(response?.queue));
|
setQueueState(normalizeQueue(response?.queue));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(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 () => {
|
const syncQueueFromServer = async () => {
|
||||||
try {
|
try {
|
||||||
const latest = await api.getPipelineQueue();
|
const latest = await api.getPipelineQueue();
|
||||||
@@ -1100,47 +1169,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 = group.entries.every((e) => e?.error);
|
||||||
|
const groupKey = group.mountPoint || group.entries.map((e) => e?.key).join('-');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`storage-${entry?.key || entry?.label || 'path'}`}
|
key={`storage-group-${groupKey}`}
|
||||||
className={`hardware-storage-item compact${entry?.error ? ' 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}`}>
|
||||||
{entry?.error ? 'Fehler' : formatPercent(entry?.usagePercent)}
|
{hasError ? 'Fehler' : formatPercent(rep?.usagePercent)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entry?.error ? (
|
{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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<small className="hardware-storage-path" title={entry?.path || '-'}>
|
{group.entries.map((entry) => (
|
||||||
Pfad: {entry?.path || '-'}
|
<div key={entry?.key} className="hardware-storage-paths">
|
||||||
</small>
|
<small className="hardware-storage-label-tag">{entry?.label || entry?.key}:</small>
|
||||||
{entry?.queryPath && entry.queryPath !== entry.path ? (
|
<small className="hardware-storage-path" title={entry?.path || '-'}>
|
||||||
<small className="hardware-storage-path" title={entry.queryPath}>
|
{entry?.path || '-'}
|
||||||
Parent: {entry.queryPath}
|
</small>
|
||||||
</small>
|
{entry?.queryPath && entry.queryPath !== entry.path ? (
|
||||||
) : null}
|
<small className="hardware-storage-path" title={entry.queryPath}>
|
||||||
{entry?.note ? <small className="hardware-storage-path">{entry.note}</small> : null}
|
(Parent: {entry.queryPath})
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
{entry?.note ? <small className="hardware-storage-path">{entry.note}</small> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1172,54 +1249,91 @@ export default function DashboardPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pipeline-queue-col">
|
<div className="pipeline-queue-col">
|
||||||
<h4>Warteschlange</h4>
|
<div className="pipeline-queue-col-header">
|
||||||
|
<h4>Warteschlange</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="queue-add-entry-btn"
|
||||||
|
title="Skript, Kette oder Wartezeit zur Queue hinzufügen"
|
||||||
|
onClick={() => void openInsertQueueDialog(null)}
|
||||||
|
>
|
||||||
|
<i className="pi pi-plus" /> Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{queuedJobs.length === 0 ? (
|
{queuedJobs.length === 0 ? (
|
||||||
<small>Queue ist leer.</small>
|
<small className="queue-empty-hint">Queue ist leer.</small>
|
||||||
) : (
|
) : (
|
||||||
queuedJobs.map((item) => {
|
<>
|
||||||
const queuedJobId = normalizeJobId(item?.jobId);
|
{queuedJobs.map((item) => {
|
||||||
const isDragging = normalizeJobId(draggingQueueJobId) === queuedJobId;
|
const entryId = Number(item?.entryId);
|
||||||
return (
|
const isNonJob = item.type && item.type !== 'job';
|
||||||
<div
|
const isDragging = Number(draggingQueueEntryId) === entryId;
|
||||||
key={`queued-${item.jobId}`}
|
return (
|
||||||
className={`pipeline-queue-item queued${isDragging ? ' dragging' : ''}`}
|
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
|
||||||
draggable={canReorderQueue}
|
<div
|
||||||
onDragStart={() => setDraggingQueueJobId(queuedJobId)}
|
className={`pipeline-queue-item queued${isDragging ? ' dragging' : ''}${isNonJob ? ' non-job' : ''}`}
|
||||||
onDragEnter={() => handleQueueDragEnter(queuedJobId)}
|
draggable={canReorderQueue}
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragStart={() => setDraggingQueueEntryId(entryId)}
|
||||||
onDrop={(event) => {
|
onDragEnter={() => handleQueueDragEnter(entryId)}
|
||||||
event.preventDefault();
|
onDragOver={(event) => event.preventDefault()}
|
||||||
void handleQueueDrop();
|
onDrop={(event) => {
|
||||||
}}
|
event.preventDefault();
|
||||||
onDragEnd={() => {
|
void handleQueueDrop();
|
||||||
setDraggingQueueJobId(null);
|
}}
|
||||||
void syncQueueFromServer();
|
onDragEnd={() => {
|
||||||
}}
|
setDraggingQueueEntryId(null);
|
||||||
>
|
void syncQueueFromServer();
|
||||||
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
|
}}
|
||||||
<i className="pi pi-bars" />
|
>
|
||||||
</span>
|
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
|
||||||
<div className="pipeline-queue-item-main">
|
<i className="pi pi-bars" />
|
||||||
<strong>{item.position || '-'} | #{item.jobId} | {item.title || `Job #${item.jobId}`}</strong>
|
</span>
|
||||||
<small>{item.actionLabel || item.action || '-'} | Status {getStatusLabel(item.status)}</small>
|
<i className={`pipeline-queue-type-icon ${queueEntryIcon(item.type)}`} title={item.type || 'job'} />
|
||||||
|
<div className="pipeline-queue-item-main">
|
||||||
|
{isNonJob ? (
|
||||||
|
<strong>{item.position || '-'}. {queueEntryLabel(item)}</strong>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>
|
||||||
|
{item.position || '-'} | #{item.jobId} | {item.title || `Job #${item.jobId}`}
|
||||||
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||||
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
|
</strong>
|
||||||
|
<small>{item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
className="pipeline-queue-remove-btn"
|
||||||
|
disabled={queueReorderBusy}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isNonJob) {
|
||||||
|
void handleRemoveQueueEntry(entryId);
|
||||||
|
} else {
|
||||||
|
void handleRemoveQueuedJob(item.jobId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="queue-insert-btn"
|
||||||
|
title="Eintrag danach einfügen"
|
||||||
|
onClick={() => void openInsertQueueDialog(entryId)}
|
||||||
|
>
|
||||||
|
<i className="pi pi-plus" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
);
|
||||||
icon="pi pi-times"
|
})}
|
||||||
severity="danger"
|
</>
|
||||||
text
|
|
||||||
rounded
|
|
||||||
size="small"
|
|
||||||
className="pipeline-queue-remove-btn"
|
|
||||||
disabled={queueReorderBusy}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void handleRemoveQueuedJob(queuedJobId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1453,6 +1567,81 @@ export default function DashboardPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
header="Queue-Eintrag einfügen"
|
||||||
|
visible={insertQueueDialog.visible}
|
||||||
|
onHide={() => setInsertQueueDialog({ visible: false, afterEntryId: null })}
|
||||||
|
style={{ width: '28rem', maxWidth: '96vw' }}
|
||||||
|
modal
|
||||||
|
>
|
||||||
|
<div className="queue-insert-dialog-body">
|
||||||
|
<p className="queue-insert-dialog-hint">
|
||||||
|
{insertQueueDialog.afterEntryId
|
||||||
|
? 'Eintrag wird nach dem ausgewählten Element eingefügt.'
|
||||||
|
: 'Eintrag wird am Ende der Queue eingefügt.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{queueCatalog.scripts.length > 0 ? (
|
||||||
|
<div className="queue-insert-section">
|
||||||
|
<strong><i className="pi pi-code" /> Skript</strong>
|
||||||
|
<div className="queue-insert-options">
|
||||||
|
{queueCatalog.scripts.map((script) => (
|
||||||
|
<button
|
||||||
|
key={`qi-script-${script.id}`}
|
||||||
|
type="button"
|
||||||
|
className="queue-insert-option"
|
||||||
|
onClick={() => void handleAddQueueEntry('script', { scriptId: script.id })}
|
||||||
|
>
|
||||||
|
{script.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{queueCatalog.chains.length > 0 ? (
|
||||||
|
<div className="queue-insert-section">
|
||||||
|
<strong><i className="pi pi-link" /> Skriptkette</strong>
|
||||||
|
<div className="queue-insert-options">
|
||||||
|
{queueCatalog.chains.map((chain) => (
|
||||||
|
<button
|
||||||
|
key={`qi-chain-${chain.id}`}
|
||||||
|
type="button"
|
||||||
|
className="queue-insert-option"
|
||||||
|
onClick={() => void handleAddQueueEntry('chain', { chainId: chain.id })}
|
||||||
|
>
|
||||||
|
{chain.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="queue-insert-section">
|
||||||
|
<strong><i className="pi pi-clock" /> Warten</strong>
|
||||||
|
<div className="queue-insert-wait-row">
|
||||||
|
<InputNumber
|
||||||
|
value={insertWaitSeconds}
|
||||||
|
onValueChange={(e) => setInsertWaitSeconds(e.value ?? 30)}
|
||||||
|
min={1}
|
||||||
|
max={3600}
|
||||||
|
suffix="s"
|
||||||
|
style={{ width: '7rem' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Einfügen"
|
||||||
|
icon="pi pi-check"
|
||||||
|
onClick={() => void handleAddQueueEntry('wait', { waitSeconds: insertWaitSeconds })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{queueCatalog.scripts.length === 0 && queueCatalog.chains.length === 0 ? (
|
||||||
|
<small className="muted-inline">Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,6 +477,20 @@ body {
|
|||||||
color: var(--rip-muted);
|
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 {
|
.pipeline-queue-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -505,6 +519,39 @@ body {
|
|||||||
margin: 0;
|
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 {
|
.pipeline-queue-item {
|
||||||
border: 1px dashed var(--rip-border);
|
border: 1px dashed var(--rip-border);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
@@ -519,11 +566,116 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pipeline-queue-item.queued {
|
.pipeline-queue-item.queued {
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
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 {
|
.pipeline-queue-item.queued.dragging {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user