0.10.2-3 Layout

This commit is contained in:
2026-03-16 07:15:17 +00:00
parent 3c694d06df
commit fbd439f318
2 changed files with 426 additions and 389 deletions

View File

@@ -2058,7 +2058,9 @@ export default function DashboardPage({
<div className="page-grid"> <div className="page-grid">
<Toast ref={toastRef} /> <Toast ref={toastRef} />
<Card title="Hardware Monitoring" subTitle="CPU (inkl. Temperatur), RAM, GPU und freier Speicher in den konfigurierten Pfaden."> <div className="dashboard-3col-grid">
<div className="dashboard-col dashboard-col-left">
<Card title="Hardware Monitoring" subTitle="CPU (inkl. Temperatur), RAM, GPU und freier Speicher in den konfigurierten Pfaden.">
<div className="hardware-monitor-head"> <div className="hardware-monitor-head">
<Tag <Tag
value={monitoringState.enabled ? 'Aktiv' : 'Deaktiviert'} value={monitoringState.enabled ? 'Aktiv' : 'Deaktiviert'}
@@ -2235,369 +2237,57 @@ export default function DashboardPage({
)} )}
</Card> </Card>
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, analysieren und danach Format/Qualität vor dem Start auswählen."> <Card title="Disk-Information">
<div className="actions-row"> <div className="actions-row">
<input
key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
type="file"
accept=".aax"
onChange={(event) => {
const nextFile = event.target?.files?.[0] || null;
setAudiobookUploadFile(nextFile);
}}
disabled={audiobookUploadBusy}
/>
<Button <Button
label="Audiobook hochladen" label="Laufwerk neu lesen"
icon="pi pi-upload" icon="pi pi-refresh"
onClick={() => {
void handleAudiobookUpload();
}}
loading={audiobookUploadBusy}
disabled={!audiobookUploadFile}
/>
</div>
{audiobookUploadPhase !== 'idle' ? (
<div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
<div className="audiobook-upload-status-head">
<strong>{audiobookUploadStatusLabel}</strong>
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
</div>
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
{audiobookUploadFileName ? (
<small className="audiobook-upload-file" title={audiobookUploadFileName}>
Datei: {audiobookUploadFileName}
</small>
) : null}
<div
className="dashboard-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.round(audiobookUploadProgress)} Prozent`}
>
<ProgressBar value={audiobookUploadProgress} showValue={false} />
<small>
{audiobookUploadPhase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: audiobookUploadTotalBytes > 0
? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
: `${Math.round(audiobookUploadProgress)}%`}
</small>
</div>
</div>
) : null}
<small>
{audiobookUploadFileName && audiobookUploadPhase === 'idle'
? `Ausgewählt: ${audiobookUploadFileName}`
: 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
</small>
</Card>
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<div className="pipeline-queue-meta">
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
</div>
<div className="pipeline-queue-grid">
<div className="pipeline-queue-col">
<h4>Laufende Jobs</h4>
{queueRunningJobs.length === 0 ? (
<small>Keine laufenden Jobs.</small>
) : (
queueRunningJobs.map((item) => {
const hasScriptSummary = hasQueueScriptSummary(item);
const detailKey = buildRunningQueueScriptKey(item?.jobId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
return (
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
<div className="pipeline-queue-item running queue-job-item">
<div className="pipeline-queue-item-main">
<strong>
#{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>{getStatusLabel(item.status)}</small>
</div>
{hasScriptSummary ? (
<button
type="button"
className="queue-job-expand-btn"
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
aria-expanded={detailsExpanded}
onClick={() => toggleQueueScriptDetails(detailKey)}
>
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
</button>
) : null}
</div>
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
</div>
);
})
)}
</div>
<div className="pipeline-queue-col">
<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 ? (
<small className="queue-empty-hint">Queue ist leer.</small>
) : (
<>
{queuedJobs.map((item) => {
const entryId = Number(item?.entryId);
const isNonJob = item.type && item.type !== 'job';
const isDragging = Number(draggingQueueEntryId) === entryId;
const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
const detailKey = buildQueuedQueueScriptKey(entryId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
return (
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
<div
className={`pipeline-queue-item queued${isDragging ? ' dragging' : ''}${isNonJob ? ' non-job' : ''}`}
draggable={canReorderQueue}
onDragStart={() => setDraggingQueueEntryId(entryId)}
onDragEnter={() => handleQueueDragEnter(entryId)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void handleQueueDrop();
}}
onDragEnd={() => {
setDraggingQueueEntryId(null);
void syncQueueFromServer();
}}
>
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
<i className="pi pi-bars" />
</span>
<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>
<div className="pipeline-queue-item-actions">
{hasScriptSummary ? (
<button
type="button"
className="queue-job-expand-btn"
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
aria-expanded={detailsExpanded}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleQueueScriptDetails(detailKey);
}}
>
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
</button>
) : null}
<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>
</div>
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
<button
type="button"
className="queue-insert-btn"
title="Eintrag danach einfügen"
onClick={() => void openInsertQueueDialog(entryId)}
>
<i className="pi pi-plus" />
</button>
</div>
);
})}
</>
)}
</div>
</div>
</Card>
<Card title="Skript- / Cron-Status" subTitle="Laufende und zuletzt abgeschlossene Skript-, Ketten- und Cron-Ausführungen.">
<div className="runtime-activity-meta pipeline-queue-meta">
<Tag value={`Laufend: ${runtimeActiveItems.length}`} severity={runtimeActiveItems.length > 0 ? 'warning' : 'success'} />
<Tag value={`Zuletzt: ${runtimeRecentItems.length}`} severity="info" />
<Tag value={`Update: ${formatUpdatedAt(runtimeActivities?.updatedAt)}`} severity="secondary" />
<Button
type="button"
icon="pi pi-trash"
label="Liste leeren"
severity="secondary" severity="secondary"
outlined onClick={handleRescan}
size="small" loading={busy}
onClick={() => { disabled={!canRescan}
void handleClearRuntimeRecent(); />
}} <Button
disabled={runtimeRecentItems.length === 0} label="Disk neu analysieren"
loading={runtimeRecentClearing} icon="pi pi-search"
severity="warning"
onClick={handleReanalyze}
loading={busy}
disabled={!canReanalyze}
/>
<Button
label="Metadaten-Modal öffnen"
icon="pi pi-list"
onClick={() => handleOpenMetadataDialog()}
disabled={!canOpenMetadataModal}
/> />
</div> </div>
{device ? (
{runtimeLoading && runtimeActiveItems.length === 0 && runtimeRecentItems.length === 0 ? ( <div className="device-meta">
<p>Aktivitäten werden geladen ...</p> <div>
) : ( <strong>Pfad:</strong> {device.path || '-'}
<div className="runtime-activity-grid pipeline-queue-grid">
<div className="runtime-activity-col pipeline-queue-col">
<h4>Aktiv</h4>
{runtimeActiveItems.length === 0 ? (
<small className="queue-empty-hint">Keine laufenden Skript-/Ketten-/Cron-Ausführungen.</small>
) : (
<div className="runtime-activity-list">
{runtimeActiveItems.map((item, index) => {
const statusMeta = runtimeStatusMeta(item?.status);
const canCancel = Boolean(item?.canCancel);
const canNextStep = String(item?.type || '').trim().toLowerCase() === 'chain' && Boolean(item?.canNextStep);
const cancelBusy = isRuntimeActionBusy(item?.id, 'cancel');
const nextStepBusy = isRuntimeActionBusy(item?.id, 'next-step');
return (
<div key={`runtime-active-${item?.id || index}`} className="runtime-activity-item running">
<div className="runtime-activity-head">
<strong>{item?.name || '-'}</strong>
<div className="runtime-activity-tags">
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
<Tag value={statusMeta.label} severity={statusMeta.severity} />
</div>
</div>
<small>
Quelle: {item?.source || '-'}
{item?.jobId ? ` | Job #${item.jobId}` : ''}
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
</small>
{item?.currentStep ? <small>Schritt: {item.currentStep}</small> : null}
{item?.currentScriptName ? <small>Laufendes Skript: {item.currentScriptName}</small> : null}
{item?.message ? <small>{item.message}</small> : null}
<RuntimeActivityDetails
item={item}
summary="Live-Ausgabe anzeigen"
/>
<small>Gestartet: {formatUpdatedAt(item?.startedAt)}</small>
{canCancel || canNextStep ? (
<div className="runtime-activity-actions">
{canNextStep ? (
<Button
type="button"
icon="pi pi-step-forward"
label="Nächster Schritt"
outlined
severity="secondary"
size="small"
loading={nextStepBusy}
disabled={cancelBusy}
onClick={() => {
void handleRuntimeControl(item, 'next-step');
}}
/>
) : null}
{canCancel ? (
<Button
type="button"
icon="pi pi-stop"
label="Abbrechen"
outlined
severity="danger"
size="small"
loading={cancelBusy}
disabled={nextStepBusy}
onClick={() => {
void handleRuntimeControl(item, 'cancel');
}}
/>
) : null}
</div>
) : null}
</div>
);
})}
</div>
)}
</div> </div>
<div>
<div className="runtime-activity-col pipeline-queue-col"> <strong>Modell:</strong> {device.model || '-'}
<h4>Zuletzt abgeschlossen</h4> </div>
{runtimeRecentItems.length === 0 ? ( <div>
<small className="queue-empty-hint">Keine abgeschlossenen Einträge vorhanden.</small> <strong>Disk-Label:</strong> {device.discLabel || '-'}
) : ( </div>
<div className="runtime-activity-list"> <div>
{runtimeRecentItems.map((item, index) => { <strong>Laufwerks-Label:</strong> {device.label || '-'}
const outcomeMeta = runtimeOutcomeMeta(item?.outcome, item?.status); </div>
return ( <div>
<div key={`runtime-recent-${item?.id || index}`} className="runtime-activity-item done"> <strong>Mount:</strong> {device.mountpoint || '-'}
<div className="runtime-activity-head">
<strong>{item?.name || '-'}</strong>
<div className="runtime-activity-tags">
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
<Tag value={outcomeMeta.label} severity={outcomeMeta.severity} />
</div>
</div>
<small>
Quelle: {item?.source || '-'}
{item?.jobId ? ` | Job #${item.jobId}` : ''}
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
</small>
{Number.isFinite(Number(item?.exitCode)) ? <small>Exit-Code: {item.exitCode}</small> : null}
{item?.message ? <small>{item.message}</small> : null}
{item?.errorMessage ? <small className="error-text">{item.errorMessage}</small> : null}
{hasRuntimeOutputDetails(item) ? (
<RuntimeActivityDetails
item={item}
summary="Details anzeigen"
/>
) : null}
<small>
Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)}
{item?.durationMs != null ? ` | Dauer: ${formatDurationMs(item.durationMs)}` : ''}
</small>
</div>
);
})}
</div>
)}
</div> </div>
</div> </div>
) : (
<p>Aktuell keine Disk erkannt.</p>
)} )}
</Card> </Card>
</div>
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs"> <div className="dashboard-col dashboard-col-center">
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
{jobsLoading ? ( {jobsLoading ? (
<p>Jobs werden geladen ...</p> <p>Jobs werden geladen ...</p>
) : dashboardJobs.length === 0 ? ( ) : dashboardJobs.length === 0 ? (
@@ -2815,54 +2505,372 @@ export default function DashboardPage({
</div> </div>
)} )}
</Card> </Card>
</div>
<Card title="Disk-Information"> <div className="dashboard-col dashboard-col-right">
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, analysieren und danach Format/Qualität vor dem Start auswählen.">
<div className="actions-row"> <div className="actions-row">
<Button <input
label="Laufwerk neu lesen" key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
icon="pi pi-refresh" type="file"
severity="secondary" accept=".aax"
onClick={handleRescan} onChange={(event) => {
loading={busy} const nextFile = event.target?.files?.[0] || null;
disabled={!canRescan} setAudiobookUploadFile(nextFile);
}}
disabled={audiobookUploadBusy}
/> />
<Button <Button
label="Disk neu analysieren" label="Audiobook hochladen"
icon="pi pi-search" icon="pi pi-upload"
severity="warning" onClick={() => {
onClick={handleReanalyze} void handleAudiobookUpload();
loading={busy} }}
disabled={!canReanalyze} loading={audiobookUploadBusy}
/> disabled={!audiobookUploadFile}
<Button
label="Metadaten-Modal öffnen"
icon="pi pi-list"
onClick={() => handleOpenMetadataDialog()}
disabled={!canOpenMetadataModal}
/> />
</div> </div>
{device ? ( {audiobookUploadPhase !== 'idle' ? (
<div className="device-meta"> <div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
<div> <div className="audiobook-upload-status-head">
<strong>Pfad:</strong> {device.path || '-'} <strong>{audiobookUploadStatusLabel}</strong>
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
</div> </div>
<div> {audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
<strong>Modell:</strong> {device.model || '-'} {audiobookUploadFileName ? (
</div> <small className="audiobook-upload-file" title={audiobookUploadFileName}>
<div> Datei: {audiobookUploadFileName}
<strong>Disk-Label:</strong> {device.discLabel || '-'} </small>
</div> ) : null}
<div> <div
<strong>Laufwerks-Label:</strong> {device.label || '-'} className="dashboard-job-row-progress audiobook-upload-progress"
</div> aria-label={`Audiobook Upload ${Math.round(audiobookUploadProgress)} Prozent`}
<div> >
<strong>Mount:</strong> {device.mountpoint || '-'} <ProgressBar value={audiobookUploadProgress} showValue={false} />
<small>
{audiobookUploadPhase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: audiobookUploadTotalBytes > 0
? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
: `${Math.round(audiobookUploadProgress)}%`}
</small>
</div> </div>
</div> </div>
) : null}
<small>
{audiobookUploadFileName && audiobookUploadPhase === 'idle'
? `Ausgewählt: ${audiobookUploadFileName}`
: 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
</small>
</Card>
<Card title="Job Queue" subTitle="Starts werden nach Typ- und Gesamtlimit abgearbeitet. Queue-Elemente können per Drag-and-Drop umsortiert werden.">
<div className="pipeline-queue-meta">
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
</div>
<div className="pipeline-queue-grid">
<div className="pipeline-queue-col">
<h4>Laufende Jobs</h4>
{queueRunningJobs.length === 0 ? (
<small>Keine laufenden Jobs.</small>
) : (
queueRunningJobs.map((item) => {
const hasScriptSummary = hasQueueScriptSummary(item);
const detailKey = buildRunningQueueScriptKey(item?.jobId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
return (
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
<div className="pipeline-queue-item running queue-job-item">
<div className="pipeline-queue-item-main">
<strong>
#{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>{getStatusLabel(item.status)}</small>
</div>
{hasScriptSummary ? (
<button
type="button"
className="queue-job-expand-btn"
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
aria-expanded={detailsExpanded}
onClick={() => toggleQueueScriptDetails(detailKey)}
>
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
</button>
) : null}
</div>
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
</div>
);
})
)}
</div>
<div className="pipeline-queue-col">
<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 ? (
<small className="queue-empty-hint">Queue ist leer.</small>
) : (
<>
{queuedJobs.map((item) => {
const entryId = Number(item?.entryId);
const isNonJob = item.type && item.type !== 'job';
const isDragging = Number(draggingQueueEntryId) === entryId;
const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
const detailKey = buildQueuedQueueScriptKey(entryId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
return (
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
<div
className={`pipeline-queue-item queued${isDragging ? ' dragging' : ''}${isNonJob ? ' non-job' : ''}`}
draggable={canReorderQueue}
onDragStart={() => setDraggingQueueEntryId(entryId)}
onDragEnter={() => handleQueueDragEnter(entryId)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void handleQueueDrop();
}}
onDragEnd={() => {
setDraggingQueueEntryId(null);
void syncQueueFromServer();
}}
>
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
<i className="pi pi-bars" />
</span>
<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>
<div className="pipeline-queue-item-actions">
{hasScriptSummary ? (
<button
type="button"
className="queue-job-expand-btn"
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
aria-expanded={detailsExpanded}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleQueueScriptDetails(detailKey);
}}
>
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
</button>
) : null}
<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>
</div>
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
<button
type="button"
className="queue-insert-btn"
title="Eintrag danach einfügen"
onClick={() => void openInsertQueueDialog(entryId)}
>
<i className="pi pi-plus" />
</button>
</div>
);
})}
</>
)}
</div>
</div>
</Card>
<Card title="Skript- / Cron-Status" subTitle="Laufende und zuletzt abgeschlossene Skript-, Ketten- und Cron-Ausführungen.">
<div className="runtime-activity-meta pipeline-queue-meta">
<Tag value={`Laufend: ${runtimeActiveItems.length}`} severity={runtimeActiveItems.length > 0 ? 'warning' : 'success'} />
<Tag value={`Zuletzt: ${runtimeRecentItems.length}`} severity="info" />
<Tag value={`Update: ${formatUpdatedAt(runtimeActivities?.updatedAt)}`} severity="secondary" />
<Button
type="button"
icon="pi pi-trash"
label="Liste leeren"
severity="secondary"
outlined
size="small"
onClick={() => {
void handleClearRuntimeRecent();
}}
disabled={runtimeRecentItems.length === 0}
loading={runtimeRecentClearing}
/>
</div>
{runtimeLoading && runtimeActiveItems.length === 0 && runtimeRecentItems.length === 0 ? (
<p>Aktivitäten werden geladen ...</p>
) : ( ) : (
<p>Aktuell keine Disk erkannt.</p> <div className="runtime-activity-grid pipeline-queue-grid">
<div className="runtime-activity-col pipeline-queue-col">
<h4>Aktiv</h4>
{runtimeActiveItems.length === 0 ? (
<small className="queue-empty-hint">Keine laufenden Skript-/Ketten-/Cron-Ausführungen.</small>
) : (
<div className="runtime-activity-list">
{runtimeActiveItems.map((item, index) => {
const statusMeta = runtimeStatusMeta(item?.status);
const canCancel = Boolean(item?.canCancel);
const canNextStep = String(item?.type || '').trim().toLowerCase() === 'chain' && Boolean(item?.canNextStep);
const cancelBusy = isRuntimeActionBusy(item?.id, 'cancel');
const nextStepBusy = isRuntimeActionBusy(item?.id, 'next-step');
return (
<div key={`runtime-active-${item?.id || index}`} className="runtime-activity-item running">
<div className="runtime-activity-head">
<strong>{item?.name || '-'}</strong>
<div className="runtime-activity-tags">
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
<Tag value={statusMeta.label} severity={statusMeta.severity} />
</div>
</div>
<small>
Quelle: {item?.source || '-'}
{item?.jobId ? ` | Job #${item.jobId}` : ''}
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
</small>
{item?.currentStep ? <small>Schritt: {item.currentStep}</small> : null}
{item?.currentScriptName ? <small>Laufendes Skript: {item.currentScriptName}</small> : null}
{item?.message ? <small>{item.message}</small> : null}
<RuntimeActivityDetails
item={item}
summary="Live-Ausgabe anzeigen"
/>
<small>Gestartet: {formatUpdatedAt(item?.startedAt)}</small>
{canCancel || canNextStep ? (
<div className="runtime-activity-actions">
{canNextStep ? (
<Button
type="button"
icon="pi pi-step-forward"
label="Nächster Schritt"
outlined
severity="secondary"
size="small"
loading={nextStepBusy}
disabled={cancelBusy}
onClick={() => {
void handleRuntimeControl(item, 'next-step');
}}
/>
) : null}
{canCancel ? (
<Button
type="button"
icon="pi pi-stop"
label="Abbrechen"
outlined
severity="danger"
size="small"
loading={cancelBusy}
disabled={nextStepBusy}
onClick={() => {
void handleRuntimeControl(item, 'cancel');
}}
/>
) : null}
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
<div className="runtime-activity-col pipeline-queue-col">
<h4>Zuletzt abgeschlossen</h4>
{runtimeRecentItems.length === 0 ? (
<small className="queue-empty-hint">Keine abgeschlossenen Einträge vorhanden.</small>
) : (
<div className="runtime-activity-list">
{runtimeRecentItems.map((item, index) => {
const outcomeMeta = runtimeOutcomeMeta(item?.outcome, item?.status);
return (
<div key={`runtime-recent-${item?.id || index}`} className="runtime-activity-item done">
<div className="runtime-activity-head">
<strong>{item?.name || '-'}</strong>
<div className="runtime-activity-tags">
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
<Tag value={outcomeMeta.label} severity={outcomeMeta.severity} />
</div>
</div>
<small>
Quelle: {item?.source || '-'}
{item?.jobId ? ` | Job #${item.jobId}` : ''}
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
</small>
{Number.isFinite(Number(item?.exitCode)) ? <small>Exit-Code: {item.exitCode}</small> : null}
{item?.message ? <small>{item.message}</small> : null}
{item?.errorMessage ? <small className="error-text">{item.errorMessage}</small> : null}
{hasRuntimeOutputDetails(item) ? (
<RuntimeActivityDetails
item={item}
summary="Details anzeigen"
/>
) : null}
<small>
Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)}
{item?.durationMs != null ? ` | Dauer: ${formatDurationMs(item.durationMs)}` : ''}
</small>
</div>
);
})}
</div>
)}
</div>
</div>
)} )}
</Card> </Card>
</div>
</div>
<MetadataSelectionDialog <MetadataSelectionDialog
visible={metadataDialogVisible} visible={metadataDialogVisible}

View File

@@ -314,6 +314,35 @@ body {
min-width: 0; min-width: 0;
} }
.dashboard-3col-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.dashboard-col {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
@media (max-width: 1100px) {
.dashboard-3col-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
}
.dashboard-col-right {
grid-column: 1 / -1;
}
}
@media (max-width: 700px) {
.dashboard-3col-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.status-row { .status-row {
display: flex; display: flex;
align-items: center; align-items: center;