0.10.2-3 Layout
This commit is contained in:
@@ -2058,7 +2058,9 @@ export default function DashboardPage({
|
||||
<div className="page-grid">
|
||||
<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">
|
||||
<Tag
|
||||
value={monitoringState.enabled ? 'Aktiv' : 'Deaktiviert'}
|
||||
@@ -2235,369 +2237,57 @@ export default function DashboardPage({
|
||||
)}
|
||||
</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">
|
||||
<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
|
||||
label="Audiobook hochladen"
|
||||
icon="pi pi-upload"
|
||||
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"
|
||||
label="Laufwerk neu lesen"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => {
|
||||
void handleClearRuntimeRecent();
|
||||
}}
|
||||
disabled={runtimeRecentItems.length === 0}
|
||||
loading={runtimeRecentClearing}
|
||||
onClick={handleRescan}
|
||||
loading={busy}
|
||||
disabled={!canRescan}
|
||||
/>
|
||||
<Button
|
||||
label="Disk neu analysieren"
|
||||
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>
|
||||
|
||||
{runtimeLoading && runtimeActiveItems.length === 0 && runtimeRecentItems.length === 0 ? (
|
||||
<p>Aktivitäten werden geladen ...</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>
|
||||
)}
|
||||
{device ? (
|
||||
<div className="device-meta">
|
||||
<div>
|
||||
<strong>Pfad:</strong> {device.path || '-'}
|
||||
</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>
|
||||
<strong>Modell:</strong> {device.model || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Disk-Label:</strong> {device.discLabel || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Laufwerks-Label:</strong> {device.label || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Mount:</strong> {device.mountpoint || '-'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p>Aktuell keine Disk erkannt.</p>
|
||||
)}
|
||||
</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 ? (
|
||||
<p>Jobs werden geladen ...</p>
|
||||
) : dashboardJobs.length === 0 ? (
|
||||
@@ -2815,54 +2505,372 @@ export default function DashboardPage({
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<Button
|
||||
label="Laufwerk neu lesen"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={handleRescan}
|
||||
loading={busy}
|
||||
disabled={!canRescan}
|
||||
<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
|
||||
label="Disk neu analysieren"
|
||||
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}
|
||||
label="Audiobook hochladen"
|
||||
icon="pi pi-upload"
|
||||
onClick={() => {
|
||||
void handleAudiobookUpload();
|
||||
}}
|
||||
loading={audiobookUploadBusy}
|
||||
disabled={!audiobookUploadFile}
|
||||
/>
|
||||
</div>
|
||||
{device ? (
|
||||
<div className="device-meta">
|
||||
<div>
|
||||
<strong>Pfad:</strong> {device.path || '-'}
|
||||
{audiobookUploadPhase !== 'idle' ? (
|
||||
<div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
|
||||
<div className="audiobook-upload-status-head">
|
||||
<strong>{audiobookUploadStatusLabel}</strong>
|
||||
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Modell:</strong> {device.model || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Disk-Label:</strong> {device.discLabel || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Laufwerks-Label:</strong> {device.label || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Mount:</strong> {device.mountpoint || '-'}
|
||||
{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"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MetadataSelectionDialog
|
||||
visible={metadataDialogVisible}
|
||||
|
||||
@@ -314,6 +314,35 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user