some pload
This commit is contained in:
@@ -5,6 +5,7 @@ import { api } from './api/client';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import DatabasePage from './pages/DatabasePage';
|
||||
|
||||
function App() {
|
||||
@@ -122,7 +123,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/history" element={<DatabasePage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/database" element={<DatabasePage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -48,6 +48,14 @@ export const api = {
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
},
|
||||
reorderScripts(orderedScriptIds = []) {
|
||||
return request('/settings/scripts/reorder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderedScriptIds: Array.isArray(orderedScriptIds) ? orderedScriptIds : []
|
||||
})
|
||||
});
|
||||
},
|
||||
updateScript(scriptId, payload = {}) {
|
||||
return request(`/settings/scripts/${encodeURIComponent(scriptId)}`, {
|
||||
method: 'PUT',
|
||||
@@ -73,6 +81,14 @@ export const api = {
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
reorderScriptChains(orderedChainIds = []) {
|
||||
return request('/settings/script-chains/reorder', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderedChainIds: Array.isArray(orderedChainIds) ? orderedChainIds : []
|
||||
})
|
||||
});
|
||||
},
|
||||
updateScriptChain(chainId, payload = {}) {
|
||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}`, {
|
||||
method: 'PUT',
|
||||
@@ -84,6 +100,11 @@ export const api = {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
testScriptChain(chainId) {
|
||||
return request(`/settings/script-chains/${encodeURIComponent(chainId)}/test`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
updateSetting(key, value) {
|
||||
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
@@ -243,6 +264,45 @@ export const api = {
|
||||
}
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request(`/history/${jobId}${suffix}`);
|
||||
},
|
||||
|
||||
// ── Cron Jobs ──────────────────────────────────────────────────────────────
|
||||
getCronJobs() {
|
||||
return request('/crons');
|
||||
},
|
||||
getCronJob(id) {
|
||||
return request(`/crons/${encodeURIComponent(id)}`);
|
||||
},
|
||||
createCronJob(payload = {}) {
|
||||
return request('/crons', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
updateCronJob(id, payload = {}) {
|
||||
return request(`/crons/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
deleteCronJob(id) {
|
||||
return request(`/crons/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
getCronJobLogs(id, limit = 20) {
|
||||
return request(`/crons/${encodeURIComponent(id)}/logs?limit=${limit}`);
|
||||
},
|
||||
runCronJobNow(id) {
|
||||
return request(`/crons/${encodeURIComponent(id)}/run`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
validateCronExpression(cronExpression) {
|
||||
return request('/crons/validate-expression', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cronExpression })
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
554
frontend/src/components/CronJobsTab.jsx
Normal file
554
frontend/src/components/CronJobsTab.jsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
|
||||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
||||
|
||||
function formatDateTime(iso) {
|
||||
if (!iso) return '–';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
} catch (_) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
if (!status) return <span className="cron-status cron-status--none">–</span>;
|
||||
const map = {
|
||||
success: { label: 'Erfolg', cls: 'success' },
|
||||
error: { label: 'Fehler', cls: 'error' },
|
||||
running: { label: 'Läuft…', cls: 'running' }
|
||||
};
|
||||
const info = map[status] || { label: status, cls: 'none' };
|
||||
return <span className={`cron-status cron-status--${info.cls}`}>{info.label}</span>;
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
cronExpression: '',
|
||||
sourceType: 'script',
|
||||
sourceId: null,
|
||||
enabled: true,
|
||||
pushoverEnabled: true
|
||||
};
|
||||
|
||||
// ── Hauptkomponente ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function CronJobsTab({ onWsMessage }) {
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scripts, setScripts] = useState([]);
|
||||
const [chains, setChains] = useState([]);
|
||||
|
||||
// Editor-Dialog
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState('create'); // 'create' | 'edit'
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Cron-Validierung
|
||||
const [exprValidation, setExprValidation] = useState(null); // { valid, error, nextRunAt }
|
||||
const [exprValidating, setExprValidating] = useState(false);
|
||||
const exprValidateTimer = useRef(null);
|
||||
|
||||
// Logs-Dialog
|
||||
const [logsJob, setLogsJob] = useState(null);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
|
||||
// Aktionen Busy-State per Job-ID
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
|
||||
// ── Daten laden ──────────────────────────────────────────────────────────────
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [cronResp, scriptsResp, chainsResp] = await Promise.allSettled([
|
||||
api.getCronJobs(),
|
||||
api.getScripts(),
|
||||
api.getScriptChains()
|
||||
]);
|
||||
if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []);
|
||||
if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []);
|
||||
if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
// WebSocket: Cronjob-Updates empfangen
|
||||
useEffect(() => {
|
||||
if (!onWsMessage) return;
|
||||
// onWsMessage ist eine Funktion, die wir anmelden
|
||||
}, [onWsMessage]);
|
||||
|
||||
// ── Cron-Ausdruck validieren (debounced) ─────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const expr = form.cronExpression.trim();
|
||||
if (!expr) {
|
||||
setExprValidation(null);
|
||||
return;
|
||||
}
|
||||
if (exprValidateTimer.current) clearTimeout(exprValidateTimer.current);
|
||||
setExprValidating(true);
|
||||
exprValidateTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await api.validateCronExpression(expr);
|
||||
setExprValidation(result);
|
||||
} catch (_) {
|
||||
setExprValidation({ valid: false, error: 'Validierung fehlgeschlagen.' });
|
||||
} finally {
|
||||
setExprValidating(false);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(exprValidateTimer.current);
|
||||
}, [form.cronExpression]);
|
||||
|
||||
// ── Editor öffnen/schließen ──────────────────────────────────────────────────
|
||||
|
||||
function openCreate() {
|
||||
setForm(EMPTY_FORM);
|
||||
setExprValidation(null);
|
||||
setEditorMode('create');
|
||||
setEditingId(null);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(job) {
|
||||
setForm({
|
||||
name: job.name || '',
|
||||
cronExpression: job.cronExpression || '',
|
||||
sourceType: job.sourceType || 'script',
|
||||
sourceId: job.sourceId || null,
|
||||
enabled: job.enabled !== false,
|
||||
pushoverEnabled: job.pushoverEnabled !== false
|
||||
});
|
||||
setExprValidation(null);
|
||||
setEditorMode('edit');
|
||||
setEditingId(job.id);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditorOpen(false);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
// ── Speichern ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const name = form.name.trim();
|
||||
const cronExpression = form.cronExpression.trim();
|
||||
|
||||
if (!name) { toastRef.current?.show({ severity: 'warn', summary: 'Name fehlt', life: 3000 }); return; }
|
||||
if (!cronExpression) { toastRef.current?.show({ severity: 'warn', summary: 'Cron-Ausdruck fehlt', life: 3000 }); return; }
|
||||
if (exprValidation && !exprValidation.valid) { toastRef.current?.show({ severity: 'warn', summary: 'Ungültiger Cron-Ausdruck', life: 3000 }); return; }
|
||||
if (!form.sourceId) { toastRef.current?.show({ severity: 'warn', summary: 'Quelle fehlt', life: 3000 }); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { ...form, name, cronExpression };
|
||||
if (editorMode === 'create') {
|
||||
await api.createCronJob(payload);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Cronjob erstellt', life: 3000 });
|
||||
} else {
|
||||
await api.updateCronJob(editingId, payload);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Cronjob gespeichert', life: 3000 });
|
||||
}
|
||||
closeEditor();
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 5000 });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle enabled/pushover ──────────────────────────────────────────────────
|
||||
|
||||
async function handleToggle(job, field) {
|
||||
setBusyId(job.id);
|
||||
try {
|
||||
const updated = await api.updateCronJob(job.id, { [field]: !job[field] });
|
||||
setJobs((prev) => prev.map((j) => j.id === job.id ? updated.job : j));
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Löschen ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDelete(job) {
|
||||
if (!window.confirm(`Cronjob "${job.name}" wirklich löschen?`)) return;
|
||||
setBusyId(job.id);
|
||||
try {
|
||||
await api.deleteCronJob(job.id);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', life: 3000 });
|
||||
setJobs((prev) => prev.filter((j) => j.id !== job.id));
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manuell ausführen ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleRunNow(job) {
|
||||
setBusyId(job.id);
|
||||
try {
|
||||
await api.runCronJobNow(job.id);
|
||||
toastRef.current?.show({ severity: 'info', summary: `"${job.name}" gestartet`, life: 3000 });
|
||||
// Kurz warten und dann neu laden
|
||||
setTimeout(() => loadAll(), 1500);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function openLogs(job) {
|
||||
setLogsJob(job);
|
||||
setLogs([]);
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const resp = await api.getCronJobLogs(job.id, 30);
|
||||
setLogs(resp.logs || []);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Logs konnten nicht geladen werden', detail: error.message, life: 4000 });
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Source-Optionen ──────────────────────────────────────────────────────────
|
||||
|
||||
const sourceOptions = form.sourceType === 'script'
|
||||
? scripts.map((s) => ({ label: s.name, value: s.id }))
|
||||
: chains.map((c) => ({ label: c.name, value: c.id }));
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="cron-tab">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
label="Neuer Cronjob"
|
||||
icon="pi pi-plus"
|
||||
onClick={openCreate}
|
||||
/>
|
||||
<Button
|
||||
label="Aktualisieren"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={loadAll}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 && !loading && (
|
||||
<p className="cron-empty-hint">Keine Cronjobs vorhanden. Klicke auf “Neuer Cronjob”, um einen anzulegen.</p>
|
||||
)}
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<div className="cron-list">
|
||||
{jobs.map((job) => {
|
||||
const isBusy = busyId === job.id;
|
||||
return (
|
||||
<div key={job.id} className={`cron-item${job.enabled ? '' : ' cron-item--disabled'}`}>
|
||||
<div className="cron-item-header">
|
||||
<span className="cron-item-name">{job.name}</span>
|
||||
<code className="cron-item-expr">{job.cronExpression}</code>
|
||||
</div>
|
||||
|
||||
<div className="cron-item-meta">
|
||||
<span className="cron-meta-entry">
|
||||
<span className="cron-meta-label">Quelle:</span>
|
||||
<span className="cron-meta-value">
|
||||
{job.sourceType === 'chain' ? '⛓ ' : '📜 '}
|
||||
{job.sourceName || `#${job.sourceId}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="cron-meta-entry">
|
||||
<span className="cron-meta-label">Letzter Lauf:</span>
|
||||
<span className="cron-meta-value">
|
||||
{formatDateTime(job.lastRunAt)}
|
||||
{job.lastRunStatus && <StatusBadge status={job.lastRunStatus} />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="cron-meta-entry">
|
||||
<span className="cron-meta-label">Nächster Lauf:</span>
|
||||
<span className="cron-meta-value">{formatDateTime(job.nextRunAt)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="cron-item-toggles">
|
||||
<label className="cron-toggle-label">
|
||||
<InputSwitch
|
||||
checked={job.enabled}
|
||||
disabled={isBusy}
|
||||
onChange={() => handleToggle(job, 'enabled')}
|
||||
/>
|
||||
<span>Aktiviert</span>
|
||||
</label>
|
||||
<label className="cron-toggle-label">
|
||||
<InputSwitch
|
||||
checked={job.pushoverEnabled}
|
||||
disabled={isBusy}
|
||||
onChange={() => handleToggle(job, 'pushoverEnabled')}
|
||||
/>
|
||||
<span>Pushover</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="cron-item-actions">
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
tooltip="Jetzt ausführen"
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
size="small"
|
||||
severity="success"
|
||||
outlined
|
||||
loading={isBusy && busyId === job.id}
|
||||
disabled={isBusy}
|
||||
onClick={() => handleRunNow(job)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-list"
|
||||
tooltip="Logs anzeigen"
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
size="small"
|
||||
severity="info"
|
||||
outlined
|
||||
disabled={isBusy}
|
||||
onClick={() => openLogs(job)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
tooltip="Bearbeiten"
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
size="small"
|
||||
outlined
|
||||
disabled={isBusy}
|
||||
onClick={() => openEdit(job)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
tooltip="Löschen"
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
disabled={isBusy}
|
||||
onClick={() => handleDelete(job)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Editor-Dialog ──────────────────────────────────────────────────── */}
|
||||
<Dialog
|
||||
header={editorMode === 'create' ? 'Neuer Cronjob' : 'Cronjob bearbeiten'}
|
||||
visible={editorOpen}
|
||||
onHide={closeEditor}
|
||||
style={{ width: '520px' }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" severity="secondary" outlined onClick={closeEditor} disabled={saving} />
|
||||
<Button label="Speichern" icon="pi pi-save" onClick={handleSave} loading={saving} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="cron-editor-fields">
|
||||
|
||||
{/* Name */}
|
||||
<div className="cron-editor-field">
|
||||
<label className="cron-editor-label">Name</label>
|
||||
<InputText
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="z.B. Tägliche Bereinigung"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cron-Ausdruck */}
|
||||
<div className="cron-editor-field">
|
||||
<label className="cron-editor-label">
|
||||
Cron-Ausdruck
|
||||
<a
|
||||
href="https://crontab.guru/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cron-help-link"
|
||||
title="crontab.guru öffnen"
|
||||
>
|
||||
<i className="pi pi-question-circle" />
|
||||
</a>
|
||||
</label>
|
||||
<InputText
|
||||
value={form.cronExpression}
|
||||
onChange={(e) => setForm((f) => ({ ...f, cronExpression: e.target.value }))}
|
||||
placeholder="Minute Stunde Tag Monat Wochentag – z.B. 0 2 * * *"
|
||||
className={`w-full${exprValidation && !exprValidation.valid ? ' p-invalid' : ''}`}
|
||||
/>
|
||||
{exprValidating && (
|
||||
<small className="cron-expr-hint cron-expr-hint--checking">Wird geprüft…</small>
|
||||
)}
|
||||
{!exprValidating && exprValidation && exprValidation.valid && (
|
||||
<small className="cron-expr-hint cron-expr-hint--ok">
|
||||
✓ Gültig – nächste Ausführung: {formatDateTime(exprValidation.nextRunAt)}
|
||||
</small>
|
||||
)}
|
||||
{!exprValidating && exprValidation && !exprValidation.valid && (
|
||||
<small className="cron-expr-hint cron-expr-hint--err">✗ {exprValidation.error}</small>
|
||||
)}
|
||||
<div className="cron-expr-examples">
|
||||
{[
|
||||
{ label: 'Stündlich', expr: '0 * * * *' },
|
||||
{ label: 'Täglich 2 Uhr', expr: '0 2 * * *' },
|
||||
{ label: 'Wöchentlich Mo', expr: '0 3 * * 1' },
|
||||
{ label: 'Monatlich 1.', expr: '0 4 1 * *' }
|
||||
].map(({ label, expr }) => (
|
||||
<button
|
||||
key={expr}
|
||||
type="button"
|
||||
className="cron-expr-chip"
|
||||
onClick={() => setForm((f) => ({ ...f, cronExpression: expr }))}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quell-Typ */}
|
||||
<div className="cron-editor-field">
|
||||
<label className="cron-editor-label">Quell-Typ</label>
|
||||
<div className="cron-source-type-row">
|
||||
{[
|
||||
{ value: 'script', label: '📜 Skript' },
|
||||
{ value: 'chain', label: '⛓ Skriptkette' }
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`cron-source-type-btn${form.sourceType === value ? ' active' : ''}`}
|
||||
onClick={() => setForm((f) => ({ ...f, sourceType: value, sourceId: null }))}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quelle auswählen */}
|
||||
<div className="cron-editor-field">
|
||||
<label className="cron-editor-label">
|
||||
{form.sourceType === 'script' ? 'Skript' : 'Skriptkette'}
|
||||
</label>
|
||||
<Dropdown
|
||||
value={form.sourceId}
|
||||
options={sourceOptions}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sourceId: e.value }))}
|
||||
placeholder={`${form.sourceType === 'script' ? 'Skript' : 'Skriptkette'} wählen…`}
|
||||
className="w-full"
|
||||
emptyMessage={form.sourceType === 'script' ? 'Keine Skripte vorhanden' : 'Keine Ketten vorhanden'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="cron-editor-toggles">
|
||||
<label className="cron-toggle-label">
|
||||
<InputSwitch
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.value }))}
|
||||
/>
|
||||
<span>Aktiviert</span>
|
||||
</label>
|
||||
<label className="cron-toggle-label">
|
||||
<InputSwitch
|
||||
checked={form.pushoverEnabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pushoverEnabled: e.value }))}
|
||||
/>
|
||||
<span>Pushover-Benachrichtigung</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Logs-Dialog ──────────────────────────────────────────────────────── */}
|
||||
<Dialog
|
||||
header={logsJob ? `Logs: ${logsJob.name}` : 'Logs'}
|
||||
visible={Boolean(logsJob)}
|
||||
onHide={() => setLogsJob(null)}
|
||||
style={{ width: '720px' }}
|
||||
footer={
|
||||
<Button
|
||||
label="Schließen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => setLogsJob(null)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{logsLoading && <p>Lade Logs…</p>}
|
||||
{!logsLoading && logs.length === 0 && (
|
||||
<p className="cron-empty-hint">Noch keine Ausführungen protokolliert.</p>
|
||||
)}
|
||||
{!logsLoading && logs.length > 0 && (
|
||||
<div className="cron-log-list">
|
||||
{logs.map((log) => (
|
||||
<details key={log.id} className="cron-log-entry">
|
||||
<summary className="cron-log-summary">
|
||||
<StatusBadge status={log.status} />
|
||||
<span className="cron-log-time">{formatDateTime(log.startedAt)}</span>
|
||||
{log.finishedAt && (
|
||||
<span className="cron-log-duration">
|
||||
{Math.round((new Date(log.finishedAt) - new Date(log.startedAt)) / 1000)}s
|
||||
</span>
|
||||
)}
|
||||
{log.errorMessage && (
|
||||
<span className="cron-log-errmsg">{log.errorMessage}</span>
|
||||
)}
|
||||
</summary>
|
||||
{log.output && (
|
||||
<pre className="cron-log-output">{log.output}</pre>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,12 +55,27 @@ function ScriptSummarySection({ title, summary }) {
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||
if (raw === 'bluray') {
|
||||
return 'bluray';
|
||||
}
|
||||
if (raw === 'dvd' || raw === 'disc') {
|
||||
return 'dvd';
|
||||
const candidates = [
|
||||
job?.mediaType,
|
||||
job?.media_type,
|
||||
job?.mediaProfile,
|
||||
job?.media_profile,
|
||||
job?.encodePlan?.mediaProfile,
|
||||
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
job?.makemkvInfo?.mediaProfile,
|
||||
job?.mediainfoInfo?.mediaProfile
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@@ -149,7 +164,7 @@ export default function JobDetailDialog({
|
||||
reencodeBusy = false,
|
||||
deleteEntryBusy = false
|
||||
}) {
|
||||
const mkDone = !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||
const showFinalLog = !running;
|
||||
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
|
||||
@@ -167,7 +182,8 @@ export default function JobDetailDialog({
|
||||
const hasRestartInput = Boolean(job?.encode_input_path || job?.raw_path || job?.encodePlan?.encodeInputPath);
|
||||
const canRestartEncode = Boolean(hasConfirmedPlan && hasRestartInput && !running);
|
||||
const canRestartReview = Boolean(
|
||||
(job?.rawStatus?.exists || job?.raw_path)
|
||||
job?.rawStatus?.exists
|
||||
&& job?.rawStatus?.isEmpty !== true
|
||||
&& !running
|
||||
&& typeof onRestartReview === 'function'
|
||||
);
|
||||
|
||||
@@ -179,12 +179,27 @@ function getAnalyzeContext(job) {
|
||||
}
|
||||
|
||||
function resolveMediaType(job) {
|
||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||
if (raw === 'bluray') {
|
||||
return 'bluray';
|
||||
}
|
||||
if (raw === 'dvd' || raw === 'disc') {
|
||||
return 'dvd';
|
||||
const candidates = [
|
||||
job?.mediaType,
|
||||
job?.media_type,
|
||||
job?.mediaProfile,
|
||||
job?.media_profile,
|
||||
job?.encodePlan?.mediaProfile,
|
||||
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
job?.makemkvInfo?.mediaProfile,
|
||||
job?.mediainfoInfo?.mediaProfile
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -21,12 +21,27 @@ import {
|
||||
} from '../utils/statusPresentation';
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||
if (raw === 'bluray') {
|
||||
return 'bluray';
|
||||
}
|
||||
if (raw === 'dvd' || raw === 'disc') {
|
||||
return 'dvd';
|
||||
const candidates = [
|
||||
row?.mediaType,
|
||||
row?.media_type,
|
||||
row?.mediaProfile,
|
||||
row?.media_profile,
|
||||
row?.encodePlan?.mediaProfile,
|
||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
row?.makemkvInfo?.mediaProfile,
|
||||
row?.mediainfoInfo?.mediaProfile
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@@ -666,7 +681,10 @@ export default function DatabasePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="RAW ohne Historie" subTitle="Ordner in raw_dir ohne zugehörigen Job können hier importiert werden">
|
||||
<Card
|
||||
title="RAW ohne Historie"
|
||||
subTitle="Ordner in den konfigurierten RAW-Pfaden (raw_dir sowie raw_dir_{bluray,dvd,other}) ohne zugehörigen Job können hier importiert werden"
|
||||
>
|
||||
<div className="table-filters">
|
||||
<Button
|
||||
label="RAW prüfen"
|
||||
|
||||
@@ -25,36 +25,39 @@ const MEDIA_FILTER_OPTIONS = [
|
||||
{ label: 'Sonstiges', value: 'other' }
|
||||
];
|
||||
|
||||
const BASE_SORT_FIELD_OPTIONS = [
|
||||
{ label: 'Startzeit', value: 'start_time' },
|
||||
{ label: 'Endzeit', value: 'end_time' },
|
||||
{ label: 'Titel', value: 'title' },
|
||||
{ label: 'Medium', value: 'mediaType' }
|
||||
const SORT_OPTIONS = [
|
||||
{ label: 'Startzeit: Neu -> Alt', value: '!start_time' },
|
||||
{ label: 'Startzeit: Alt -> Neu', value: 'start_time' },
|
||||
{ label: 'Endzeit: Neu -> Alt', value: '!end_time' },
|
||||
{ label: 'Endzeit: Alt -> Neu', value: 'end_time' },
|
||||
{ label: 'Titel: A -> Z', value: 'sortTitle' },
|
||||
{ label: 'Titel: Z -> A', value: '!sortTitle' },
|
||||
{ label: 'Medium: A -> Z', value: 'sortMediaType' },
|
||||
{ label: 'Medium: Z -> A', value: '!sortMediaType' }
|
||||
];
|
||||
|
||||
const OPTIONAL_SORT_FIELD_OPTIONS = [
|
||||
{ label: 'Keine', value: '' },
|
||||
...BASE_SORT_FIELD_OPTIONS
|
||||
];
|
||||
|
||||
const SORT_DIRECTION_OPTIONS = [
|
||||
{ label: 'Aufsteigend', value: 1 },
|
||||
{ label: 'Absteigend', value: -1 }
|
||||
];
|
||||
|
||||
const MEDIA_SORT_RANK = {
|
||||
bluray: 0,
|
||||
dvd: 1,
|
||||
other: 2
|
||||
};
|
||||
|
||||
function resolveMediaType(row) {
|
||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||
if (raw === 'bluray') {
|
||||
return 'bluray';
|
||||
}
|
||||
if (raw === 'dvd' || raw === 'disc') {
|
||||
return 'dvd';
|
||||
const candidates = [
|
||||
row?.mediaType,
|
||||
row?.media_type,
|
||||
row?.mediaProfile,
|
||||
row?.media_profile,
|
||||
row?.encodePlan?.mediaProfile,
|
||||
row?.makemkvInfo?.analyzeContext?.mediaProfile,
|
||||
row?.makemkvInfo?.mediaProfile,
|
||||
row?.mediainfoInfo?.mediaProfile
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const raw = String(candidate || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
||||
return 'bluray';
|
||||
}
|
||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||
return 'dvd';
|
||||
}
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
@@ -101,57 +104,6 @@ function normalizeSortText(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
}
|
||||
|
||||
function normalizeSortDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const ts = new Date(value).getTime();
|
||||
return Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
function compareSortValues(a, b) {
|
||||
const aMissing = a === null || a === undefined || a === '';
|
||||
const bMissing = b === null || b === undefined || b === '';
|
||||
if (aMissing && bMissing) {
|
||||
return 0;
|
||||
}
|
||||
if (aMissing) {
|
||||
return 1;
|
||||
}
|
||||
if (bMissing) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
return a > b ? 1 : -1;
|
||||
}
|
||||
|
||||
return String(a).localeCompare(String(b), 'de', {
|
||||
sensitivity: 'base',
|
||||
numeric: true
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSortValue(row, field) {
|
||||
switch (field) {
|
||||
case 'start_time':
|
||||
return normalizeSortDate(row?.start_time);
|
||||
case 'end_time':
|
||||
return normalizeSortDate(row?.end_time);
|
||||
case 'title':
|
||||
return normalizeSortText(row?.title || row?.detected_title || '');
|
||||
case 'mediaType': {
|
||||
const mediaType = resolveMediaType(row);
|
||||
return MEDIA_SORT_RANK[mediaType] ?? MEDIA_SORT_RANK.other;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRating(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw || raw.toUpperCase() === 'N/A') {
|
||||
@@ -211,18 +163,16 @@ export default function HistoryPage() {
|
||||
const [status, setStatus] = useState('');
|
||||
const [mediumFilter, setMediumFilter] = useState('');
|
||||
const [layout, setLayout] = useState('list');
|
||||
const [sortPrimaryField, setSortPrimaryField] = useState('start_time');
|
||||
const [sortPrimaryOrder, setSortPrimaryOrder] = useState(-1);
|
||||
const [sortSecondaryField, setSortSecondaryField] = useState('title');
|
||||
const [sortSecondaryOrder, setSortSecondaryOrder] = useState(1);
|
||||
const [sortTertiaryField, setSortTertiaryField] = useState('mediaType');
|
||||
const [sortTertiaryOrder, setSortTertiaryOrder] = useState(1);
|
||||
const [sortKey, setSortKey] = useState('!start_time');
|
||||
const [sortField, setSortField] = useState('start_time');
|
||||
const [sortOrder, setSortOrder] = useState(-1);
|
||||
const [selectedJob, setSelectedJob] = useState(null);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const toastRef = useRef(null);
|
||||
@@ -238,51 +188,21 @@ export default function HistoryPage() {
|
||||
return next;
|
||||
}, [queuedJobIds]);
|
||||
|
||||
const sortDescriptors = useMemo(() => {
|
||||
const seen = new Set();
|
||||
const rawDescriptors = [
|
||||
{ field: String(sortPrimaryField || '').trim(), order: Number(sortPrimaryOrder || -1) >= 0 ? 1 : -1 },
|
||||
{ field: String(sortSecondaryField || '').trim(), order: Number(sortSecondaryOrder || -1) >= 0 ? 1 : -1 },
|
||||
{ field: String(sortTertiaryField || '').trim(), order: Number(sortTertiaryOrder || -1) >= 0 ? 1 : -1 }
|
||||
];
|
||||
const preparedJobs = useMemo(
|
||||
() => jobs.map((job) => ({
|
||||
...job,
|
||||
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
||||
sortMediaType: resolveMediaType(job)
|
||||
})),
|
||||
[jobs]
|
||||
);
|
||||
|
||||
const descriptors = [];
|
||||
for (const descriptor of rawDescriptors) {
|
||||
if (!descriptor.field || seen.has(descriptor.field)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(descriptor.field);
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
return descriptors;
|
||||
}, [sortPrimaryField, sortPrimaryOrder, sortSecondaryField, sortSecondaryOrder, sortTertiaryField, sortTertiaryOrder]);
|
||||
|
||||
const visibleJobs = useMemo(() => {
|
||||
const filtered = mediumFilter
|
||||
? jobs.filter((job) => resolveMediaType(job) === mediumFilter)
|
||||
: [...jobs];
|
||||
|
||||
if (sortDescriptors.length === 0) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
for (const descriptor of sortDescriptors) {
|
||||
const valueA = resolveSortValue(a, descriptor.field);
|
||||
const valueB = resolveSortValue(b, descriptor.field);
|
||||
const compared = compareSortValues(valueA, valueB);
|
||||
if (compared !== 0) {
|
||||
return compared * descriptor.order;
|
||||
}
|
||||
}
|
||||
|
||||
const idA = Number(a?.id || 0);
|
||||
const idB = Number(b?.id || 0);
|
||||
return idB - idA;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [jobs, mediumFilter, sortDescriptors]);
|
||||
const visibleJobs = useMemo(
|
||||
() => (mediumFilter
|
||||
? preparedJobs.filter((job) => job.sortMediaType === mediumFilter)
|
||||
: preparedJobs),
|
||||
[preparedJobs, mediumFilter]
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -297,7 +217,9 @@ export default function HistoryPage() {
|
||||
setJobs([]);
|
||||
}
|
||||
if (queueResponse.status === 'fulfilled') {
|
||||
const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs) ? queueResponse.value.queue.queuedJobs : [];
|
||||
const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
|
||||
? queueResponse.value.queue.queuedJobs
|
||||
: [];
|
||||
const queuedIds = queuedRows
|
||||
.map((item) => normalizeJobId(item?.jobId))
|
||||
.filter(Boolean);
|
||||
@@ -320,6 +242,25 @@ export default function HistoryPage() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, status]);
|
||||
|
||||
const onSortChange = (event) => {
|
||||
const value = String(event.value || '').trim();
|
||||
if (!value) {
|
||||
setSortKey('!start_time');
|
||||
setSortField('start_time');
|
||||
setSortOrder(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('!')) {
|
||||
setSortOrder(-1);
|
||||
setSortField(value.substring(1));
|
||||
} else {
|
||||
setSortOrder(1);
|
||||
setSortField(value);
|
||||
}
|
||||
setSortKey(value);
|
||||
};
|
||||
|
||||
const openDetail = async (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
@@ -474,6 +415,57 @@ export default function HistoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartReview = async (row) => {
|
||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionBusy(true);
|
||||
try {
|
||||
await api.restartReviewFromRaw(row.id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Review-Neustart',
|
||||
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
await refreshDetailIfOpen(row.id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (row) => {
|
||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||
const confirmed = window.confirm(`Historieneintrag für "${title}" wirklich löschen?\nDateien werden NICHT gelöscht.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteEntryBusy(true);
|
||||
try {
|
||||
await api.deleteJobEntry(row.id, 'none');
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Eintrag gelöscht',
|
||||
detail: `"${title}" wurde aus der Historie entfernt.`,
|
||||
life: 3500
|
||||
});
|
||||
setDetailVisible(false);
|
||||
setSelectedJob(null);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
||||
} finally {
|
||||
setDeleteEntryBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromQueue = async (row) => {
|
||||
const jobId = normalizeJobId(row?.id || row);
|
||||
if (!jobId) {
|
||||
@@ -535,6 +527,7 @@ export default function HistoryPage() {
|
||||
if (ratings.length === 0) {
|
||||
return <span className="history-dv-subtle">Keine Ratings</span>;
|
||||
}
|
||||
|
||||
return ratings.map((rating) => (
|
||||
<span key={`${row?.id}-${rating.key}`} className="history-dv-rating-chip">
|
||||
<strong>{rating.label}</strong>
|
||||
@@ -550,77 +543,74 @@ export default function HistoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderListItem = (row) => {
|
||||
const listItem = (row) => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const title = row?.title || row?.detected_title || '-';
|
||||
const imdb = row?.imdb_id || '-';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="history-dv-item history-dv-item-list"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => onItemKeyDown(event, row)}
|
||||
onClick={() => {
|
||||
void openDetail(row);
|
||||
}}
|
||||
>
|
||||
<div className="history-dv-poster-wrap">
|
||||
{renderPoster(row)}
|
||||
</div>
|
||||
<div className="col-12" key={row.id}>
|
||||
<div
|
||||
className="history-dv-item history-dv-item-list"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => onItemKeyDown(event, row)}
|
||||
onClick={() => {
|
||||
void openDetail(row);
|
||||
}}
|
||||
>
|
||||
<div className="history-dv-poster-wrap">
|
||||
{renderPoster(row)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-main">
|
||||
<div className="history-dv-head">
|
||||
<div className="history-dv-title-block">
|
||||
<strong className="history-dv-title">{title}</strong>
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {imdb}
|
||||
</small>
|
||||
<div className="history-dv-main">
|
||||
<div className="history-dv-head">
|
||||
<div className="history-dv-title-block">
|
||||
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||
</small>
|
||||
</div>
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
{renderStatusTag(row)}
|
||||
|
||||
<div className="history-dv-meta-row">
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
|
||||
<span>{mediaMeta.label}</span>
|
||||
</span>
|
||||
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
||||
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-meta-row">
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
|
||||
<span>{mediaMeta.label}</span>
|
||||
</span>
|
||||
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
||||
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
||||
<div className="history-dv-actions">
|
||||
<Button
|
||||
label="Details"
|
||||
icon="pi pi-search"
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void openDetail(row);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">
|
||||
{renderRatings(row)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-actions">
|
||||
<Button
|
||||
label="Details"
|
||||
icon="pi pi-search"
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void openDetail(row);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGridItem = (row) => {
|
||||
const gridItem = (row) => {
|
||||
const mediaMeta = resolveMediaTypeMeta(row);
|
||||
const title = row?.title || row?.detected_title || '-';
|
||||
|
||||
return (
|
||||
<div className="history-dv-grid-cell">
|
||||
<div className="col-12 md-col-6 xl-col-4" key={row.id}>
|
||||
<div
|
||||
className="history-dv-item history-dv-item-grid"
|
||||
role="button"
|
||||
@@ -630,37 +620,36 @@ export default function HistoryPage() {
|
||||
void openDetail(row);
|
||||
}}
|
||||
>
|
||||
<div className="history-dv-grid-head">
|
||||
{renderPoster(row, 'history-dv-poster-lg')}
|
||||
<div className="history-dv-grid-title-wrap">
|
||||
<strong className="history-dv-title">{title}</strong>
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||
</small>
|
||||
<div className="history-dv-grid-poster-wrap">
|
||||
{renderPoster(row, 'history-dv-poster-grid')}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-grid-main">
|
||||
<div className="history-dv-head">
|
||||
<strong className="history-dv-title">{row?.title || row?.detected_title || '-'}</strong>
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
|
||||
<small className="history-dv-subtle">
|
||||
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||
</small>
|
||||
|
||||
<div className="history-dv-meta-row">
|
||||
<span className="job-step-cell">
|
||||
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
|
||||
<span>{mediaMeta.label}</span>
|
||||
</span>
|
||||
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
||||
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-grid-status-row">
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-grid-time-row">
|
||||
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
||||
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-ratings-row">
|
||||
{renderRatings(row)}
|
||||
<div className="history-dv-ratings-row">{renderRatings(row)}</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-actions history-dv-actions-grid">
|
||||
@@ -683,107 +672,48 @@ export default function HistoryPage() {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
if (currentLayout === 'grid') {
|
||||
return renderGridItem(row);
|
||||
}
|
||||
return renderListItem(row);
|
||||
return currentLayout === 'list' ? listItem(row) : gridItem(row);
|
||||
};
|
||||
|
||||
const dataViewHeader = (
|
||||
<div>
|
||||
<div className="history-dv-toolbar">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel oder IMDb"
|
||||
/>
|
||||
<Dropdown
|
||||
value={status}
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatus(event.value)}
|
||||
placeholder="Status"
|
||||
/>
|
||||
<Dropdown
|
||||
value={mediumFilter}
|
||||
options={MEDIA_FILTER_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setMediumFilter(event.value || '')}
|
||||
placeholder="Medium"
|
||||
/>
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
<div className="history-dv-layout-toggle">
|
||||
<DataViewLayoutOptions
|
||||
layout={layout}
|
||||
onChange={(event) => setLayout(event.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
const header = (
|
||||
<div className="history-dv-toolbar">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel oder IMDb"
|
||||
/>
|
||||
|
||||
<div className="history-dv-sortbar">
|
||||
<div className="history-dv-sort-rule">
|
||||
<strong>1.</strong>
|
||||
<Dropdown
|
||||
value={sortPrimaryField}
|
||||
options={BASE_SORT_FIELD_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setSortPrimaryField(event.value || 'start_time')}
|
||||
placeholder="Primär"
|
||||
/>
|
||||
<Dropdown
|
||||
value={sortPrimaryOrder}
|
||||
options={SORT_DIRECTION_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setSortPrimaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
||||
placeholder="Richtung"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
value={status}
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatus(event.value)}
|
||||
placeholder="Status"
|
||||
/>
|
||||
|
||||
<div className="history-dv-sort-rule">
|
||||
<strong>2.</strong>
|
||||
<Dropdown
|
||||
value={sortSecondaryField}
|
||||
options={OPTIONAL_SORT_FIELD_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setSortSecondaryField(event.value || '')}
|
||||
placeholder="Sekundär"
|
||||
/>
|
||||
<Dropdown
|
||||
value={sortSecondaryOrder}
|
||||
options={SORT_DIRECTION_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setSortSecondaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
||||
placeholder="Richtung"
|
||||
disabled={!sortSecondaryField}
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
value={mediumFilter}
|
||||
options={MEDIA_FILTER_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setMediumFilter(event.value || '')}
|
||||
placeholder="Medium"
|
||||
/>
|
||||
|
||||
<div className="history-dv-sort-rule">
|
||||
<strong>3.</strong>
|
||||
<Dropdown
|
||||
value={sortTertiaryField}
|
||||
options={OPTIONAL_SORT_FIELD_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setSortTertiaryField(event.value || '')}
|
||||
placeholder="Tertiär"
|
||||
/>
|
||||
<Dropdown
|
||||
value={sortTertiaryOrder}
|
||||
options={SORT_DIRECTION_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setSortTertiaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
||||
placeholder="Richtung"
|
||||
disabled={!sortTertiaryField}
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
value={sortKey}
|
||||
options={SORT_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={onSortChange}
|
||||
placeholder="Sortieren"
|
||||
/>
|
||||
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
|
||||
<div className="history-dv-layout-toggle">
|
||||
<DataViewLayoutOptions layout={layout} onChange={(event) => setLayout(event.value)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -792,15 +722,17 @@ export default function HistoryPage() {
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Historie" subTitle="DataView mit Poster, Status, Dateiverfügbarkeit, Encode-Status und Ratings">
|
||||
<Card title="Historie" subTitle="PrimeReact DataView">
|
||||
<DataView
|
||||
value={visibleJobs}
|
||||
layout={layout}
|
||||
header={header}
|
||||
itemTemplate={itemTemplate}
|
||||
paginator
|
||||
rows={12}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
header={dataViewHeader}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
loading={loading}
|
||||
emptyMessage="Keine Einträge"
|
||||
className="history-dataview"
|
||||
@@ -814,12 +746,15 @@ export default function HistoryPage() {
|
||||
onLoadLog={handleLoadLog}
|
||||
logLoadingMode={logLoadingMode}
|
||||
onRestartEncode={handleRestartEncode}
|
||||
onRestartReview={handleRestartReview}
|
||||
onReencode={handleReencode}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
actionBusy={actionBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusy}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { api } from '../api/client';
|
||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||
import CronJobsTab from '../components/CronJobsTab';
|
||||
|
||||
function buildValuesMap(categories) {
|
||||
const next = {};
|
||||
@@ -26,6 +27,30 @@ function isSameValue(a, b) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
function reorderListById(items, sourceId, targetIndex) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const normalizedSourceId = Number(sourceId);
|
||||
const normalizedTargetIndex = Number(targetIndex);
|
||||
if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0 || !Number.isFinite(normalizedTargetIndex)) {
|
||||
return { changed: false, next: list };
|
||||
}
|
||||
const fromIndex = list.findIndex((item) => Number(item?.id) === normalizedSourceId);
|
||||
if (fromIndex < 0) {
|
||||
return { changed: false, next: list };
|
||||
}
|
||||
|
||||
const boundedTarget = Math.max(0, Math.min(Math.trunc(normalizedTargetIndex), list.length));
|
||||
const insertAt = fromIndex < boundedTarget ? boundedTarget - 1 : boundedTarget;
|
||||
if (insertAt === fromIndex) {
|
||||
return { changed: false, next: list };
|
||||
}
|
||||
|
||||
const next = [...list];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(insertAt, 0, moved);
|
||||
return { changed: true, next };
|
||||
}
|
||||
|
||||
function injectHandBrakePresetOptions(categories, presetPayload) {
|
||||
const list = Array.isArray(categories) ? categories : [];
|
||||
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
||||
@@ -103,6 +128,8 @@ export default function SettingsPage() {
|
||||
const [scripts, setScripts] = useState([]);
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||
const [scriptSaving, setScriptSaving] = useState(false);
|
||||
const [scriptReordering, setScriptReordering] = useState(false);
|
||||
const [scriptListDragSourceId, setScriptListDragSourceId] = useState(null);
|
||||
const [scriptActionBusyId, setScriptActionBusyId] = useState(null);
|
||||
const [scriptEditor, setScriptEditor] = useState({
|
||||
mode: 'none',
|
||||
@@ -117,6 +144,10 @@ export default function SettingsPage() {
|
||||
const [chains, setChains] = useState([]);
|
||||
const [chainsLoading, setChainsLoading] = useState(false);
|
||||
const [chainSaving, setChainSaving] = useState(false);
|
||||
const [chainReordering, setChainReordering] = useState(false);
|
||||
const [chainListDragSourceId, setChainListDragSourceId] = useState(null);
|
||||
const [chainActionBusyId, setChainActionBusyId] = useState(null);
|
||||
const [lastChainTestResult, setLastChainTestResult] = useState(null);
|
||||
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
|
||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||
const [chainDragSource, setChainDragSource] = useState(null);
|
||||
@@ -470,6 +501,88 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScriptListDragStart = (event, scriptId) => {
|
||||
if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
setScriptListDragSourceId(Number(scriptId));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(scriptId));
|
||||
};
|
||||
|
||||
const handleScriptListDragOver = (event) => {
|
||||
const sourceId = Number(scriptListDragSourceId);
|
||||
if (!Number.isFinite(sourceId) || sourceId <= 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleScriptListDrop = async (event, targetIndex) => {
|
||||
event.preventDefault();
|
||||
if (scriptReordering) {
|
||||
setScriptListDragSourceId(null);
|
||||
return;
|
||||
}
|
||||
const sourceId = Number(scriptListDragSourceId);
|
||||
setScriptListDragSourceId(null);
|
||||
const { changed, next } = reorderListById(scripts, sourceId, targetIndex);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedScriptIds = next
|
||||
.map((script) => Number(script?.id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
setScripts(next);
|
||||
setScriptReordering(true);
|
||||
try {
|
||||
await api.reorderScripts(orderedScriptIds);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Script-Reihenfolge',
|
||||
detail: error.message
|
||||
});
|
||||
await loadScripts({ silent: true });
|
||||
} finally {
|
||||
setScriptReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestChain = async (chain) => {
|
||||
const chainId = Number(chain?.id);
|
||||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||||
return;
|
||||
}
|
||||
setChainActionBusyId(chainId);
|
||||
setLastChainTestResult(null);
|
||||
try {
|
||||
const response = await api.testScriptChain(chainId);
|
||||
const result = response?.result || null;
|
||||
setLastChainTestResult(result);
|
||||
if (!result?.aborted) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Ketten-Test',
|
||||
detail: `"${chain?.name || chainId}" erfolgreich ausgeführt (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte).`
|
||||
});
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Ketten-Test',
|
||||
detail: `"${chain?.name || chainId}" abgebrochen (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte OK).`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message });
|
||||
} finally {
|
||||
setChainActionBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Chain editor handlers
|
||||
const openChainEditor = (chain = null) => {
|
||||
if (chain) {
|
||||
@@ -583,6 +696,57 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChainListDragStart = (event, chainId) => {
|
||||
if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
setChainListDragSourceId(Number(chainId));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(chainId));
|
||||
};
|
||||
|
||||
const handleChainListDragOver = (event) => {
|
||||
const sourceId = Number(chainListDragSourceId);
|
||||
if (!Number.isFinite(sourceId) || sourceId <= 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleChainListDrop = async (event, targetIndex) => {
|
||||
event.preventDefault();
|
||||
if (chainReordering) {
|
||||
setChainListDragSourceId(null);
|
||||
return;
|
||||
}
|
||||
const sourceId = Number(chainListDragSourceId);
|
||||
setChainListDragSourceId(null);
|
||||
const { changed, next } = reorderListById(chains, sourceId, targetIndex);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedChainIds = next
|
||||
.map((chain) => Number(chain?.id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
setChains(next);
|
||||
setChainReordering(true);
|
||||
try {
|
||||
await api.reorderScriptChains(orderedChainIds);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Ketten-Reihenfolge',
|
||||
detail: error.message
|
||||
});
|
||||
await loadChains({ silent: true });
|
||||
} finally {
|
||||
setChainReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Chain DnD handlers
|
||||
const handleChainPaletteDragStart = (event, data) => {
|
||||
setChainDragSource({ origin: 'palette', ...data });
|
||||
@@ -626,6 +790,16 @@ export default function SettingsPage() {
|
||||
event.dataTransfer.dropEffect = chainDragSource?.origin === 'palette' ? 'copy' : 'move';
|
||||
};
|
||||
|
||||
const scriptListDnDDisabled = scriptSaving
|
||||
|| scriptsLoading
|
||||
|| scriptReordering
|
||||
|| scriptEditor?.mode === 'create'
|
||||
|| Boolean(scriptActionBusyId);
|
||||
const chainListDnDDisabled = chainSaving
|
||||
|| chainsLoading
|
||||
|| chainReordering
|
||||
|| Boolean(chainActionBusyId);
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
@@ -692,7 +866,7 @@ export default function SettingsPage() {
|
||||
onClick={startCreateScript}
|
||||
severity="success"
|
||||
outlined
|
||||
disabled={scriptSaving || scriptEditor?.mode === 'create'}
|
||||
disabled={scriptSaving || scriptReordering || scriptEditor?.mode === 'create'}
|
||||
/>
|
||||
<Button
|
||||
label="Scripts neu laden"
|
||||
@@ -700,20 +874,24 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
onClick={() => loadScripts()}
|
||||
loading={scriptsLoading}
|
||||
disabled={scriptSaving}
|
||||
disabled={scriptSaving || scriptReordering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small>
|
||||
Die ausgewählten Scripts werden später pro Job nach erfolgreichem Encode in Reihenfolge ausgeführt.
|
||||
</small>
|
||||
<small className="muted-inline">
|
||||
Reihenfolge per Drag & Drop ändern.
|
||||
{scriptReordering ? ' Speichere Reihenfolge ...' : ''}
|
||||
</small>
|
||||
|
||||
<div className="script-list-box">
|
||||
<h4>Verfügbare Scripts</h4>
|
||||
{scriptsLoading ? (
|
||||
<p>Lade Scripts ...</p>
|
||||
) : (
|
||||
<div className="script-list">
|
||||
<div className="script-list script-list--reorderable">
|
||||
{scriptEditor?.mode === 'create' ? (
|
||||
<div className="script-list-item script-list-item-editing">
|
||||
<div className="script-list-main">
|
||||
@@ -761,45 +939,73 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scripts.length === 0 ? <p>Keine Scripts vorhanden.</p> : null}
|
||||
{scripts.length === 0 ? (
|
||||
<p>Keine Scripts vorhanden.</p>
|
||||
) : (
|
||||
<div className="script-order-list">
|
||||
{scripts.map((script, index) => {
|
||||
const isDragging = Number(scriptListDragSourceId) === Number(script.id);
|
||||
return (
|
||||
<div key={script.id} className="script-order-wrapper">
|
||||
<div
|
||||
className="script-order-drop-zone"
|
||||
onDragOver={handleScriptListDragOver}
|
||||
onDrop={(event) => handleScriptListDrop(event, index)}
|
||||
/>
|
||||
<div
|
||||
className={`script-list-item${isDragging ? ' script-list-item--dragging' : ''}`}
|
||||
draggable={!scriptListDnDDisabled}
|
||||
onDragStart={(event) => handleScriptListDragStart(event, script.id)}
|
||||
onDragEnd={() => setScriptListDragSourceId(null)}
|
||||
>
|
||||
<div
|
||||
className={`script-list-drag-handle${scriptListDnDDisabled ? ' disabled' : ''}`}
|
||||
title={scriptListDnDDisabled ? 'Sortierung aktuell nicht verfügbar' : 'Ziehen zum Sortieren'}
|
||||
>
|
||||
<i className="pi pi-bars" />
|
||||
</div>
|
||||
<div className="script-list-main">
|
||||
<strong className="script-id-title">{`ID #${script.id} - ${script.name}`}</strong>
|
||||
</div>
|
||||
|
||||
{scripts.map((script) => {
|
||||
return (
|
||||
<div key={script.id} className="script-list-item">
|
||||
<div className="script-list-main">
|
||||
<strong className="script-id-title">{`ID #${script.id} - ${script.name}`}</strong>
|
||||
</div>
|
||||
|
||||
<div className="script-list-actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Bearbeiten"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => startEditScript(script)}
|
||||
disabled={Boolean(scriptActionBusyId) || scriptSaving || scriptEditor?.mode === 'create'}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
label="Test"
|
||||
severity="info"
|
||||
onClick={() => handleTestScript(script)}
|
||||
loading={scriptActionBusyId === script.id}
|
||||
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
label="Löschen"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteScript(script)}
|
||||
loading={scriptActionBusyId === script.id}
|
||||
disabled={Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="script-list-actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Bearbeiten"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => startEditScript(script)}
|
||||
disabled={Boolean(scriptActionBusyId) || scriptSaving || scriptReordering || scriptEditor?.mode === 'create'}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
label="Test"
|
||||
severity="info"
|
||||
onClick={() => handleTestScript(script)}
|
||||
loading={scriptActionBusyId === script.id}
|
||||
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
label="Löschen"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteScript(script)}
|
||||
loading={scriptActionBusyId === script.id}
|
||||
disabled={scriptReordering || (Boolean(scriptActionBusyId) && scriptActionBusyId !== script.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="script-order-drop-zone script-order-drop-zone--end"
|
||||
onDragOver={handleScriptListDragOver}
|
||||
onDrop={(event) => handleScriptListDrop(event, scripts.length)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -875,6 +1081,7 @@ export default function SettingsPage() {
|
||||
severity="success"
|
||||
outlined
|
||||
onClick={() => openChainEditor()}
|
||||
disabled={chainReordering}
|
||||
/>
|
||||
<Button
|
||||
label="Ketten neu laden"
|
||||
@@ -882,6 +1089,7 @@ export default function SettingsPage() {
|
||||
severity="secondary"
|
||||
onClick={() => loadChains()}
|
||||
loading={chainsLoading}
|
||||
disabled={chainReordering}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -889,6 +1097,10 @@ export default function SettingsPage() {
|
||||
Skriptketten kombinieren einzelne Scripte und Systemblöcke (z.B. Warten) zu einer ausführbaren Sequenz.
|
||||
Ketten können an Jobs als Pre- oder Post-Encode-Aktion hinterlegt werden.
|
||||
</small>
|
||||
<small className="muted-inline">
|
||||
Reihenfolge per Drag & Drop ändern.
|
||||
{chainReordering ? ' Speichere Reihenfolge ...' : ''}
|
||||
</small>
|
||||
|
||||
<div className="script-list-box">
|
||||
<h4>Verfügbare Skriptketten</h4>
|
||||
@@ -897,45 +1109,108 @@ export default function SettingsPage() {
|
||||
) : chains.length === 0 ? (
|
||||
<p>Keine Skriptketten vorhanden.</p>
|
||||
) : (
|
||||
<div className="script-list">
|
||||
{chains.map((chain) => (
|
||||
<div key={chain.id} className="script-list-item">
|
||||
<div className="script-list-main">
|
||||
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
|
||||
<small>
|
||||
{chain.steps?.length ?? 0} Schritt(e):
|
||||
{' '}
|
||||
{(chain.steps || []).map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 ? ' → ' : ''}
|
||||
{s.stepType === 'wait'
|
||||
? `⏱ ${s.waitSeconds}s`
|
||||
: (s.scriptName || `Script #${s.scriptId}`)}
|
||||
</span>
|
||||
))}
|
||||
</small>
|
||||
</div>
|
||||
<div className="script-list-actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Bearbeiten"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => openChainEditor(chain)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
label="Löschen"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteChain(chain)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="script-list script-list--reorderable">
|
||||
<div className="script-order-list">
|
||||
{chains.map((chain, index) => {
|
||||
const isDragging = Number(chainListDragSourceId) === Number(chain.id);
|
||||
return (
|
||||
<div key={chain.id} className="script-order-wrapper">
|
||||
<div
|
||||
className="script-order-drop-zone"
|
||||
onDragOver={handleChainListDragOver}
|
||||
onDrop={(event) => handleChainListDrop(event, index)}
|
||||
/>
|
||||
<div
|
||||
className={`script-list-item${isDragging ? ' script-list-item--dragging' : ''}`}
|
||||
draggable={!chainListDnDDisabled}
|
||||
onDragStart={(event) => handleChainListDragStart(event, chain.id)}
|
||||
onDragEnd={() => setChainListDragSourceId(null)}
|
||||
>
|
||||
<div
|
||||
className={`script-list-drag-handle${chainListDnDDisabled ? ' disabled' : ''}`}
|
||||
title={chainListDnDDisabled ? 'Sortierung aktuell nicht verfügbar' : 'Ziehen zum Sortieren'}
|
||||
>
|
||||
<i className="pi pi-bars" />
|
||||
</div>
|
||||
<div className="script-list-main">
|
||||
<strong className="script-id-title">{`ID #${chain.id} - ${chain.name}`}</strong>
|
||||
<small>
|
||||
{chain.steps?.length ?? 0} Schritt(e):
|
||||
{' '}
|
||||
{(chain.steps || []).map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 ? ' → ' : ''}
|
||||
{s.stepType === 'wait'
|
||||
? `⏱ ${s.waitSeconds}s`
|
||||
: (s.scriptName || `Script #${s.scriptId}`)}
|
||||
</span>
|
||||
))}
|
||||
</small>
|
||||
</div>
|
||||
<div className="script-list-actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
label="Bearbeiten"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => openChainEditor(chain)}
|
||||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
label="Test"
|
||||
severity="info"
|
||||
onClick={() => handleTestChain(chain)}
|
||||
loading={chainActionBusyId === chain.id}
|
||||
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
label="Löschen"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteChain(chain)}
|
||||
disabled={chainReordering || Boolean(chainActionBusyId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="script-order-drop-zone script-order-drop-zone--end"
|
||||
onDragOver={handleChainListDragOver}
|
||||
onDrop={(event) => handleChainListDrop(event, chains.length)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lastChainTestResult ? (
|
||||
<div className="script-test-result">
|
||||
<h4>Letzter Ketten-Test: {lastChainTestResult.chainName}</h4>
|
||||
<small>
|
||||
Status: {lastChainTestResult.aborted ? 'Abgebrochen' : 'Erfolgreich'}
|
||||
{' | '}Schritte: {lastChainTestResult.succeeded ?? 0}/{lastChainTestResult.steps ?? 0}
|
||||
{lastChainTestResult.failed > 0 ? ` | Fehler: ${lastChainTestResult.failed}` : ''}
|
||||
</small>
|
||||
{(lastChainTestResult.results || []).map((step, i) => (
|
||||
<div key={i} className="script-test-step">
|
||||
<strong>
|
||||
{`Schritt ${i + 1}: `}
|
||||
{step.stepType === 'wait'
|
||||
? `⏱ Warten (${step.waitSeconds}s)`
|
||||
: (step.scriptName || `Script #${step.scriptId}`)}
|
||||
{' — '}
|
||||
{step.skipped ? 'Übersprungen' : (step.success ? '✓ OK' : `✗ Fehler (exit=${step.exitCode ?? 'n/a'})`)}
|
||||
</strong>
|
||||
{(step.stdout || step.stderr) ? (
|
||||
<pre>{`${step.stdout || ''}${step.stderr ? `\n${step.stderr}` : ''}`.trim()}</pre>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Chain editor dialog */}
|
||||
@@ -1099,6 +1374,10 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</Dialog>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Cronjobs">
|
||||
<CronJobsTab />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1126,6 +1126,36 @@ body {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.script-list--reorderable {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.script-order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.script-order-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.script-order-drop-zone {
|
||||
min-height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: min-height 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.script-order-drop-zone--end {
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
.script-order-drop-zone:hover,
|
||||
.script-order-drop-zone:focus-within {
|
||||
min-height: 1.2rem;
|
||||
background: var(--rip-gold-200);
|
||||
}
|
||||
|
||||
.script-list-item {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
@@ -1136,6 +1166,37 @@ body {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.script-list--reorderable .script-list-item:not(.script-list-item-editing) {
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(21rem, auto);
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.script-list-item--dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.script-list-drag-handle {
|
||||
color: var(--rip-muted);
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
min-height: 1.4rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.script-list-drag-handle:hover {
|
||||
background: var(--rip-gold-200);
|
||||
color: var(--rip-brown-700);
|
||||
}
|
||||
|
||||
.script-list-drag-handle.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.script-list-item-editing {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1225,6 +1286,15 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.script-test-step {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.script-test-step strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #9d261b;
|
||||
margin-left: 0.25rem;
|
||||
@@ -1263,9 +1333,9 @@ body {
|
||||
}
|
||||
|
||||
.history-dv-toolbar {
|
||||
margin-bottom: 0.7rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 12rem 10rem auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) 12rem 10rem 13rem auto auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1275,27 +1345,29 @@ body {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.history-dv-sortbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
.history-dataview .p-dataview-content .p-grid.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -0.35rem;
|
||||
}
|
||||
|
||||
.history-dv-sort-rule {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) 9.4rem;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
padding: 0.35rem 0.45rem;
|
||||
.history-dataview .p-dataview-content .p-grid.grid > .col-12,
|
||||
.history-dataview .p-dataview-content .p-grid.grid > .md-col-6,
|
||||
.history-dataview .p-dataview-content .p-grid.grid > .xl-col-4 {
|
||||
width: 100%;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.history-dv-sort-rule strong {
|
||||
margin: 0;
|
||||
color: var(--rip-brown-700);
|
||||
text-align: center;
|
||||
@media (min-width: 900px) {
|
||||
.history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .md-col-6 {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.history-dataview.p-dataview-grid .p-dataview-content .p-grid.grid > .xl-col-4 {
|
||||
width: 33.3333%;
|
||||
}
|
||||
}
|
||||
|
||||
.history-dv-item {
|
||||
@@ -1397,7 +1469,7 @@ body {
|
||||
}
|
||||
|
||||
.history-dv-poster,
|
||||
.history-dv-poster-lg {
|
||||
.history-dv-poster-grid {
|
||||
display: block;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
@@ -1410,9 +1482,8 @@ body {
|
||||
height: 88px;
|
||||
}
|
||||
|
||||
.history-dv-poster-lg {
|
||||
width: 66px;
|
||||
height: 96px;
|
||||
.history-dv-poster-grid {
|
||||
height: 164px;
|
||||
}
|
||||
|
||||
.history-dv-poster-fallback {
|
||||
@@ -1435,42 +1506,26 @@ body {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.history-dv-grid-cell {
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.history-dv-item-grid {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem;
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.history-dv-grid-head {
|
||||
display: grid;
|
||||
grid-template-columns: 66px minmax(0, 1fr);
|
||||
gap: 0.65rem;
|
||||
.history-dv-grid-poster-wrap {
|
||||
width: min(120px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.history-dv-grid-title-wrap {
|
||||
.history-dv-grid-main {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-dv-grid-status-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.history-dv-grid-time-row {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.history-dv-actions-grid {
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.table-scroll-wrap {
|
||||
@@ -2019,7 +2074,6 @@ body {
|
||||
.job-film-info-grid,
|
||||
.table-filters,
|
||||
.history-dv-toolbar,
|
||||
.history-dv-sortbar,
|
||||
.job-head-row,
|
||||
.job-json-grid,
|
||||
.selected-meta,
|
||||
@@ -2047,6 +2101,10 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.script-list--reorderable .script-list-item:not(.script-list-item-editing) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-script-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
@@ -2083,10 +2141,6 @@ body {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.history-dv-sort-rule {
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) 8.8rem;
|
||||
}
|
||||
|
||||
.history-dv-item-list {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
@@ -2096,13 +2150,12 @@ body {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.history-dv-grid-head {
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
.history-dv-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history-dv-poster-lg {
|
||||
width: 58px;
|
||||
height: 84px;
|
||||
.history-dv-poster-grid {
|
||||
height: 148px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2115,14 +2168,6 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.history-dv-sort-rule {
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.history-dv-sort-rule .p-dropdown:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.history-dv-item-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -2139,6 +2184,10 @@ body {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.history-dv-poster-grid {
|
||||
height: 136px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -2164,6 +2213,15 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.script-list--reorderable .script-list-item:not(.script-list-item-editing) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.script-list-drag-handle {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.script-action-spacer {
|
||||
display: none;
|
||||
}
|
||||
@@ -2446,3 +2504,261 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cronjobs Tab ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.cron-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.cron-empty-hint {
|
||||
color: var(--rip-muted, #888);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Job-Liste */
|
||||
.cron-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cron-item {
|
||||
border: 1px solid var(--surface-border, #dee2e6);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
background: var(--surface-card, #fff);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.cron-item--disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.cron-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cron-item-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cron-item-expr {
|
||||
background: var(--surface-ground, #f4f4f4);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color-secondary, #555);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.cron-item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cron-meta-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.cron-meta-label {
|
||||
color: var(--rip-muted, #777);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cron-meta-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* Status-Badge */
|
||||
.cron-status {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 99px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cron-status--success { background: #d4edda; color: #1c5e2e; }
|
||||
.cron-status--error { background: #f8d7da; color: #842029; }
|
||||
.cron-status--running { background: #d0e4ff; color: #0c3b7c; }
|
||||
.cron-status--none { background: transparent; color: var(--rip-muted, #888); }
|
||||
|
||||
/* Toggles-Zeile */
|
||||
.cron-item-toggles {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cron-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* Aktionen-Zeile */
|
||||
.cron-item-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Editor-Dialog */
|
||||
.cron-editor-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cron-editor-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.cron-editor-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.cron-help-link {
|
||||
color: var(--rip-muted, #999);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cron-help-link:hover { color: var(--primary-color, #3b82f6); }
|
||||
|
||||
.cron-expr-hint {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cron-expr-hint--ok { color: #1c5e2e; }
|
||||
.cron-expr-hint--err { color: #842029; }
|
||||
.cron-expr-hint--checking { color: var(--rip-muted, #888); }
|
||||
|
||||
.cron-expr-examples {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cron-expr-chip {
|
||||
background: var(--surface-ground, #f4f4f4);
|
||||
border: 1px solid var(--surface-border, #dee2e6);
|
||||
border-radius: 99px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.cron-expr-chip:hover { background: var(--surface-hover, #e9ecef); }
|
||||
|
||||
/* Quell-Typ-Auswahl */
|
||||
.cron-source-type-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cron-source-type-btn {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid var(--surface-border, #dee2e6);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-ground, #f4f4f4);
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.cron-source-type-btn.active {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: var(--primary-50, #eff6ff);
|
||||
color: var(--primary-color, #3b82f6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Editor-Toggles */
|
||||
.cron-editor-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Logs-Dialog */
|
||||
.cron-log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cron-log-entry {
|
||||
border: 1px solid var(--surface-border, #dee2e6);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cron-log-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
background: var(--surface-ground, #f8f9fa);
|
||||
list-style: none;
|
||||
font-size: 0.88rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cron-log-time {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cron-log-duration {
|
||||
color: var(--rip-muted, #777);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cron-log-errmsg {
|
||||
color: #842029;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.cron-log-output {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-size: 0.78rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user