347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
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<string | null>(null);
|
|
const [celeryTaskId, setCeleryTaskId] = useState<string | null>(null);
|
|
const [running, setRunning] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Asset state
|
|
const [assets, setAssets] = useState<MediaAsset[]>([]);
|
|
const [selectedAsset, setSelectedAsset] = useState<MediaAsset | null>(null);
|
|
const [scanning, setScanning] = useState(false);
|
|
|
|
// Output files
|
|
const [outputFiles, setOutputFiles] = useState<ChunkOutputFile[]>([]);
|
|
|
|
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<number, ChunkInfo>();
|
|
const workerMap = new Map<string, WorkerInfo>();
|
|
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 (
|
|
<div className="app">
|
|
<header className="header">
|
|
<h1>MPR Chunker Pipeline</h1>
|
|
<div className="connection-status">
|
|
{jobId && (
|
|
<span className={`dot ${connected ? "connected" : ""}`} />
|
|
)}
|
|
<span className="status-text">
|
|
{!jobId
|
|
? "Configure and launch"
|
|
: connected
|
|
? "Streaming"
|
|
: done
|
|
? "Complete"
|
|
: "Connecting..."}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
{error && <div className="error-banner">{error}</div>}
|
|
|
|
<div className="layout">
|
|
<aside className="sidebar">
|
|
<ConfigPanel
|
|
onStart={handleStart}
|
|
onStop={handleStop}
|
|
onReset={handleReset}
|
|
running={running}
|
|
done={done}
|
|
assets={assets}
|
|
selectedAsset={selectedAsset}
|
|
onSelectAsset={setSelectedAsset}
|
|
onScan={handleScan}
|
|
scanning={scanning}
|
|
/>
|
|
</aside>
|
|
|
|
<main className="main">
|
|
<div className="main-grid">
|
|
<div className="main-left">
|
|
<ChunkGrid chunks={chunks} totalChunks={stats.total_chunks} />
|
|
<QueueGauge current={queueSize} max={10} buffered={0} />
|
|
{done && outputFiles.length > 0 && (
|
|
<OutputFiles files={outputFiles} />
|
|
)}
|
|
</div>
|
|
<div className="main-right">
|
|
<WorkerPanel workers={workers} />
|
|
<StatsPanel stats={stats} />
|
|
<ErrorLog errors={errors} />
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|