// 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 (
{row.celery_task_id}