import { useCallback, useEffect, useMemo, useState } from "react"; import "./App.css"; import { cancelChunkJob, createChunkJob, getAssets, getChunkOutputFiles, scanMediaFolder, } from "./api"; import { ChunkGrid } from "./components/ChunkGrid"; import { ConfigPanel } from "./components/ConfigPanel"; import { ErrorLog } from "./components/ErrorLog"; import { OutputFiles } from "./components/OutputFiles"; import { QueueGauge } from "./components/QueueGauge"; import { StatsPanel } from "./components/StatsPanel"; import { WorkerPanel } from "./components/WorkerPanel"; import { useGrpcStream } from "./hooks/useGrpcStream"; import type { ChunkInfo, ChunkOutputFile, ErrorEntry, MediaAsset, PipelineConfig, PipelineStats, WorkerInfo, } from "./types"; export default function App() { const [jobId, setJobId] = useState(null); const [celeryTaskId, setCeleryTaskId] = useState(null); const [running, setRunning] = useState(false); const [error, setError] = useState(null); // Asset state const [assets, setAssets] = useState([]); const [selectedAsset, setSelectedAsset] = useState(null); const [scanning, setScanning] = useState(false); // Output files const [outputFiles, setOutputFiles] = useState([]); const { events, connected, done, reset: resetStream, } = useGrpcStream(jobId); // Load assets on mount useEffect(() => { getAssets() .then((data) => setAssets(data.sort((a, b) => a.filename.localeCompare(b.filename))), ) .catch((e) => setError(e instanceof Error ? e.message : "Failed to load assets"), ); }, []); // Fetch output files when job completes useEffect(() => { if (done && jobId) { getChunkOutputFiles(jobId) .then(setOutputFiles) .catch(() => setOutputFiles([])); } }, [done, jobId]); const handleScan = useCallback(async () => { setScanning(true); setError(null); try { await scanMediaFolder(); const data = await getAssets(); setAssets(data.sort((a, b) => a.filename.localeCompare(b.filename))); } catch (e) { setError(e instanceof Error ? e.message : "Scan failed"); } finally { setScanning(false); } }, []); // Derive state from raw events const { chunks, workers, stats, errors, queueSize } = useMemo(() => { const chunkMap = new Map(); const workerMap = new Map(); const errorList: ErrorEntry[] = []; let totalChunks = 0; let processed = 0; let failed = 0; let retries = 0; let elapsed = 0; let throughput = 0; let queueSize = 0; let pipelineDone = false; for (const evt of events) { const evtType = evt.event_type || ""; if (evt.total_chunks) totalChunks = evt.total_chunks; if (evt.processed_chunks) processed = evt.processed_chunks; if (evt.failed_chunks) failed = evt.failed_chunks; if (evt.elapsed) elapsed = evt.elapsed; if (evt.throughput_mbps) throughput = evt.throughput_mbps; if (evt.queue_size !== undefined) queueSize = evt.queue_size; if (evtType === "pipeline_complete" || evtType === "pipeline_error") { pipelineDone = true; queueSize = 0; } // Track chunks by raw event type if (evt.sequence !== undefined) { const existing = chunkMap.get(evt.sequence) || { sequence: evt.sequence, state: "pending" as const, }; if (evtType === "chunk_queued") { existing.state = "queued"; } else if (evtType === "chunk_processing") { existing.state = "processing"; if (evt.worker_id) existing.worker_id = evt.worker_id; } else if (evtType === "chunk_done") { existing.state = "done"; if (evt.processing_time) existing.processing_time = evt.processing_time; if (evt.retries) existing.retries = evt.retries; } else if (evtType === "chunk_error") { existing.state = "error"; if (evt.error) existing.error = evt.error; } else if (evtType === "chunk_retry") { existing.state = "retry"; if (evt.retries) existing.retries = evt.retries; } if (evt.size) existing.size = evt.size; chunkMap.set(evt.sequence, existing); } // Track workers from worker_status events if (evt.worker_id && evtType === "worker_status") { const w = workerMap.get(evt.worker_id) || { worker_id: evt.worker_id, state: "idle" as const, processed: 0, errors: 0, retries: 0, }; if (evt.state === "processing") { w.state = "processing"; w.current_chunk = evt.sequence; } else if (evt.state === "idle") { w.state = "idle"; w.current_chunk = undefined; } else if (evt.state === "stopped") { w.state = "stopped"; w.current_chunk = undefined; } workerMap.set(evt.worker_id, w); } // Also update workers from chunk lifecycle events if ( evt.worker_id && (evtType === "chunk_processing" || evtType === "chunk_done" || evtType === "chunk_error") ) { const w = workerMap.get(evt.worker_id) || { worker_id: evt.worker_id, state: "idle" as const, processed: 0, errors: 0, retries: 0, }; if (evtType === "chunk_processing") { w.state = "processing"; w.current_chunk = evt.sequence; } else if (evtType === "chunk_done") { w.processed++; w.state = "idle"; w.current_chunk = undefined; } else if (evtType === "chunk_error") { w.errors++; } if (evt.retries) { retries += evt.retries; w.retries += evt.retries; } workerMap.set(evt.worker_id, w); } // Track errors if (evt.error) { errorList.push({ timestamp: Date.now(), sequence: evt.sequence, worker_id: evt.worker_id, error: evt.error, retries: evt.retries, event_type: evtType, }); } } // When pipeline is done, mark all workers as stopped if (pipelineDone) { for (const w of workerMap.values()) { w.state = "stopped"; w.current_chunk = undefined; } } const statsObj: PipelineStats = { total_chunks: totalChunks, processed, failed, retries, elapsed, throughput_mbps: throughput, queue_size: queueSize, }; return { chunks: Array.from(chunkMap.values()).sort( (a, b) => a.sequence - b.sequence, ), workers: Array.from(workerMap.values()), stats: statsObj, errors: errorList, queueSize, }; }, [events]); const handleStart = useCallback(async (config: PipelineConfig) => { setError(null); setRunning(true); setOutputFiles([]); try { const result = await createChunkJob(config); setJobId(result.id); setCeleryTaskId(result.celery_task_id); } catch (e) { setError(e instanceof Error ? e.message : "Failed to start"); setRunning(false); } }, []); const handleStop = useCallback(async () => { if (!celeryTaskId) { setError("No task ID to cancel"); return; } try { const result = await cancelChunkJob(celeryTaskId); if (result.ok) { resetStream(); setRunning(false); setError(null); } else { setError(result.message || "Failed to cancel"); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to cancel"); } }, [celeryTaskId, resetStream]); const handleReset = useCallback(() => { setJobId(null); setCeleryTaskId(null); setRunning(false); setError(null); setOutputFiles([]); resetStream(); }, [resetStream]); // Reset running state when done if (done && running) { setRunning(false); } return (

MPR Chunker Pipeline

{jobId && ( )} {!jobId ? "Configure and launch" : connected ? "Streaming" : done ? "Complete" : "Connecting..."}
{error &&
{error}
}
{done && outputFiles.length > 0 && ( )}
); }