0.10.2-3 Layout
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user