// Transcription Queue Tab — admin queue rows + max_concurrent input + drag reorder const QueueTab = ({ lang }) => { const t = lang === 'zh'; const [queue, setQueue] = React.useState({ pending: [], running: [], completed: [], failed: [], cancelled: [] }); const [settings, setSettings] = React.useState({ max_concurrent_transcriptions: 1 }); const [maxLocal, setMaxLocal] = React.useState(''); const [maxError, setMaxError] = React.useState(''); const [error, setError] = React.useState(null); const [confirmOpen, setConfirmOpen] = React.useState(false); const [confirmTarget, setConfirmTarget] = React.useState(null); const [confirmLoading, setConfirmLoading] = React.useState(false); const [actionError, setActionError] = React.useState({}); // {row_id: msg} const [draggingId, setDraggingId] = React.useState(null); const [dragInFlight, setDragInFlight] = React.useState(false); const [pendingOverride, setPendingOverride] = React.useState(null); // optimistic order array of row ids const debounceRef = React.useRef(null); // Polling React.useEffect(() => { let cancelled = false; const fetchAll = async () => { try { const [qRes, sRes] = await Promise.all([ fetch(`${API_BASE}/admin/queue`), fetch(`${API_BASE}/admin/settings`), ]); if (!qRes.ok || !sRes.ok) throw new Error(`HTTP ${qRes.status}/${sRes.status}`); const q = await qRes.json(); const s = await sRes.json(); if (cancelled) return; setQueue(q); setSettings(s); setMaxLocal(prev => prev === '' ? String(s.max_concurrent_transcriptions) : prev); setError(null); } catch (err) { if (!cancelled) setError(err.message || String(err)); } }; fetchAll(); const id = setInterval(fetchAll, 5000); return () => { cancelled = true; clearInterval(id); }; }, []); const refetch = async () => { try { const qRes = await fetch(`${API_BASE}/admin/queue`); if (qRes.ok) setQueue(await qRes.json()); } catch {} }; const setActionErr = (id, msg) => setActionError(e => ({ ...e, [id]: msg })); const clearActionErr = (id) => setActionError(e => { const c = { ...e }; delete c[id]; return c; }); // ── Cancel pending ── const cancelPending = async (row) => { clearActionErr(row.id); try { const res = await fetch(`${API_BASE}/admin/queue/${row.id}/cancel`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(row.id, body.detail || `HTTP ${res.status}`); return; } await refetch(); } catch (e) { setActionErr(row.id, e.message || String(e)); } }; // ── Force-cancel running ── const openForceCancel = (row) => { setConfirmTarget(row); setConfirmOpen(true); }; const closeConfirm = () => { if (!confirmLoading) { setConfirmOpen(false); setConfirmTarget(null); } }; const confirmForceCancel = async () => { if (!confirmTarget) return; setConfirmLoading(true); clearActionErr(confirmTarget.id); try { const res = await fetch(`${API_BASE}/admin/queue/${confirmTarget.id}/cancel?force=true`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(confirmTarget.id, body.detail || `HTTP ${res.status}`); } else { await refetch(); } } catch (e) { setActionErr(confirmTarget.id, e.message || String(e)); } finally { setConfirmLoading(false); setConfirmOpen(false); setConfirmTarget(null); } }; // ── Retry / Ignore / Unignore ── const retryRow = async (row) => { clearActionErr(row.id); try { const res = await fetch(`${API_BASE}/episodes/${row.episode_id}/transcribe`, { method: 'POST' }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(row.id, body.detail || `HTTP ${res.status}`); } } catch (e) { setActionErr(row.id, e.message || String(e)); } }; const ignoreRow = async (row) => { clearActionErr(row.id); try { const res = await fetch(`${API_BASE}/admin/queue/${row.id}/ignore`, { method: 'POST' }); if (!res.ok) setActionErr(row.id, `HTTP ${res.status}`); } catch (e) { setActionErr(row.id, e.message || String(e)); } }; const unignoreRow = async (row) => { clearActionErr(row.id); try { const res = await fetch(`${API_BASE}/admin/queue/${row.id}/unignore`, { method: 'POST' }); if (!res.ok) setActionErr(row.id, `HTTP ${res.status}`); } catch (e) { setActionErr(row.id, e.message || String(e)); } }; // ── max_concurrent input + debounce 500ms ── const onMaxChange = (e) => { const val = e.target.value; setMaxLocal(val); setMaxError(''); const num = parseInt(val, 10); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(async () => { if (Number.isNaN(num)) return; try { const res = await fetch(`${API_BASE}/admin/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ max_concurrent_transcriptions: num }), }); if (res.status === 422) { const body = await res.json().catch(() => ({})); setMaxError(typeof body.detail === 'string' ? body.detail : (t ? '數值無效(範圍 1–3)' : 'Invalid value (range 1–3)')); setMaxLocal(String(settings.max_concurrent_transcriptions)); return; } if (!res.ok) { setMaxError(`HTTP ${res.status}`); return; } const data = await res.json(); setSettings(data); setMaxLocal(String(data.max_concurrent_transcriptions)); } catch (err) { setMaxError(err.message || String(err)); } }, 500); }; const maxNum = parseInt(maxLocal, 10); const showMaxWarning = !Number.isNaN(maxNum) && (maxNum > 3 || maxNum < 1); // ── Drag reorder ── // Compute display order for pending: prefer pendingOverride array of ids if set const pendingDisplay = React.useMemo(() => { if (!pendingOverride) return queue.pending; const byId = Object.fromEntries(queue.pending.map(r => [r.id, r])); const ordered = pendingOverride.map(id => byId[id]).filter(Boolean); // include any new pending rows not in override (newly enqueued) const seen = new Set(pendingOverride); queue.pending.forEach(r => { if (!seen.has(r.id)) ordered.push(r); }); return ordered; }, [queue.pending, pendingOverride]); const onDragStart = (e, row) => { if (dragInFlight) { e.preventDefault(); return; } e.dataTransfer.setData('text/plain', row.id); e.dataTransfer.effectAllowed = 'move'; setDraggingId(row.id); }; const onDragEnd = () => { setDraggingId(null); }; const onDragOverPending = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const onDropPending = async (e, targetRow) => { e.preventDefault(); if (dragInFlight) return; const sourceId = e.dataTransfer.getData('text/plain'); if (!sourceId || sourceId === targetRow.id) { setDraggingId(null); return; } const sourceRow = queue.pending.find(r => r.id === sourceId); if (!sourceRow) { setDraggingId(null); return; } // optimistic reorder: move sourceId to targetRow's index const currentOrder = pendingDisplay.map(r => r.id); const fromIdx = currentOrder.indexOf(sourceId); const toIdx = currentOrder.indexOf(targetRow.id); if (fromIdx === -1 || toIdx === -1) { setDraggingId(null); return; } const newOrder = [...currentOrder]; newOrder.splice(fromIdx, 1); newOrder.splice(toIdx, 0, sourceId); const previousOverride = pendingOverride; setPendingOverride(newOrder); setDraggingId(null); setDragInFlight(true); try { const res = await fetch(`${API_BASE}/admin/queue/${sourceId}/position`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ position: targetRow.position }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); setActionErr(sourceId, body.detail || `HTTP ${res.status}`); setPendingOverride(previousOverride); return; } await refetch(); setPendingOverride(null); } catch (err) { setActionErr(sourceId, err.message || String(err)); setPendingOverride(previousOverride); } finally { setDragInFlight(false); } }; // ── Row rendering ── const formatTs = (iso) => { if (!iso) return '—'; const ms = new Date(iso).getTime(); if (Number.isNaN(ms)) return '—'; return formatRelativeTime(ms, lang); }; const Row = ({ row, status }) => { const isPending = status === 'pending'; const isRunning = status === 'running'; const isFailed = status === 'failed'; const ignored = row.ignored; const dragging = draggingId === row.id; const errMsg = actionError[row.id]; return (
onDragStart(e, row) : undefined} onDragEnd={isPending ? onDragEnd : undefined} onDragOver={isPending ? onDragOverPending : undefined} onDrop={isPending ? (e) => onDropPending(e, row) : undefined} style={{ background: ignored ? TOKEN.bg : TOKEN.surface, border: `1px solid ${TOKEN.surfaceBorder}`, borderRadius: 8, padding: '12px 14px', display: 'flex', gap: 12, alignItems: 'flex-start', opacity: ignored ? 0.55 : (dragging ? 0.4 : 1), cursor: isPending ? (dragInFlight ? 'wait' : 'grab') : 'default', transition: 'opacity 0.15s', }} > {isPending && ( ⋮⋮ )}
{row.episode_title || row.episode_id.slice(0, 8)} {row.show_title || ''} {status} {ignored && {t ? '已忽略' : 'Ignored'}}
{t ? '排隊:' : 'Enqueued: '}{formatTs(row.enqueued_at)} {row.started_at && {t ? '開始:' : 'Started: '}{formatTs(row.started_at)}} {row.finished_at && {t ? '完成:' : 'Finished: '}{formatTs(row.finished_at)}}
{row.error_message && (
{row.error_message}
)} {row.celery_task_id && (
{t ? 'Celery task id' : 'Celery task id'} {row.celery_task_id}
)} {errMsg && (
{errMsg}
)}
{isPending && cancelPending(row)}>{t ? '取消' : 'Cancel'}} {isRunning && openForceCancel(row)}>{t ? '強制取消' : 'Force Cancel'}} {isFailed && !ignored && ( <> retryRow(row)}>{t ? '重試' : 'Retry'} ignoreRow(row)}>{t ? '忽略' : 'Ignore'} )} {ignored && unignoreRow(row)}>{t ? '取消忽略' : 'Unignore'}}
); }; const Section = ({ title, rows, status }) => (
{title} ({rows.length})
{rows.length === 0 ? (
{t ? '空' : 'Empty'}
) : (
{rows.map(r => )}
)}
); return (
{/* Header: max_concurrent input */}
{showMaxWarning && (
{t ? '上限 3,受 worker concurrency 限制' : 'Max 3, limited by worker concurrency'}
)} {maxError && (
{maxError}
)}
{t ? `目前生效值:${settings.max_concurrent_transcriptions}` : `Currently in effect: ${settings.max_concurrent_transcriptions}`} {dragInFlight && {t ? '排序處理中…' : 'Reordering…'}}
{error && (
{error}
)}
); }; Object.assign(window, { QueueTab });