chunker ui redo

This commit is contained in:
2026-03-15 16:03:53 -03:00
parent d5a3372d6b
commit b40bd68411
62 changed files with 5460 additions and 1493 deletions

View File

@@ -1,16 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import "./App.css";
import { createChunkJob, getAssets, scanMediaFolder } from "./api";
import {
cancelChunkJob,
createChunkJob,
getAssets,
getChunkOutputFiles,
scanMediaFolder,
} from "./api";
import { ChunkGrid } from "./components/ChunkGrid";
import { ConfigPanel } from "./components/ConfigPanel";
import { ErrorLog } from "./components/ErrorLog";
import { PipelineDiagram } from "./components/PipelineDiagram";
import { OutputFiles } from "./components/OutputFiles";
import { QueueGauge } from "./components/QueueGauge";
import { StatsPanel } from "./components/StatsPanel";
import { WorkerPanel } from "./components/WorkerPanel";
import { useEventStream } from "./hooks/useEventStream";
import { useGrpcStream } from "./hooks/useGrpcStream";
import type {
ChunkInfo,
ChunkOutputFile,
ErrorEntry,
MediaAsset,
PipelineConfig,
@@ -20,6 +27,7 @@ import type {
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);
@@ -28,15 +36,36 @@ export default function App() {
const [selectedAsset, setSelectedAsset] = useState<MediaAsset | null>(null);
const [scanning, setScanning] = useState(false);
const { events, connected, done } = useEventStream(jobId);
// 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"));
.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);
@@ -51,8 +80,8 @@ export default function App() {
}
}, []);
// Derive state from events
const { chunks, workers, stats, errors, activeStage, queueSize } =
// Derive state from raw events
const { chunks, workers, stats, errors, queueSize } =
useMemo(() => {
const chunkMap = new Map<number, ChunkInfo>();
const workerMap = new Map<string, WorkerInfo>();
@@ -64,45 +93,54 @@ export default function App() {
let elapsed = 0;
let throughput = 0;
let queueSize = 0;
let stage = "pending";
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 (evt.status && evt.status !== "waiting") stage = evt.status;
// Track chunks
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 (evt.status === "chunking" || evt.status === "pending") {
if (evtType === "chunk_queued") {
existing.state = "queued";
} else if (evt.status === "processing") {
} else if (evtType === "chunk_processing") {
existing.state = "processing";
if (evt.worker_id) existing.worker_id = evt.worker_id;
} else if (evt.status === "completed") {
} 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 (evt.status === "failed") {
} 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
if (evt.worker_id) {
// 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,
@@ -119,12 +157,38 @@ export default function App() {
w.current_chunk = undefined;
} else if (evt.state === "stopped") {
w.state = "stopped";
w.current_chunk = undefined;
}
if (evt.success !== undefined) {
if (evt.success) w.processed++;
else w.errors++;
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;
@@ -141,11 +205,19 @@ export default function App() {
worker_id: evt.worker_id,
error: evt.error,
retries: evt.retries,
event_type: evt.status || "error",
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,
@@ -158,12 +230,11 @@ export default function App() {
return {
chunks: Array.from(chunkMap.values()).sort(
(a, b) => a.sequence - b.sequence
(a, b) => a.sequence - b.sequence,
),
workers: Array.from(workerMap.values()),
stats: statsObj,
errors: errorList,
activeStage: stage,
queueSize,
};
}, [events]);
@@ -171,15 +242,45 @@ export default function App() {
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);
@@ -197,10 +298,10 @@ export default function App() {
{!jobId
? "Configure and launch"
: connected
? "Streaming"
: done
? "Complete"
: "Connecting..."}
? "Streaming"
: done
? "Complete"
: "Connecting..."}
</span>
</div>
</header>
@@ -211,7 +312,10 @@ export default function App() {
<aside className="sidebar">
<ConfigPanel
onStart={handleStart}
onStop={handleStop}
onReset={handleReset}
running={running}
done={done}
assets={assets}
selectedAsset={selectedAsset}
onSelectAsset={setSelectedAsset}
@@ -221,16 +325,13 @@ export default function App() {
</aside>
<main className="main">
<PipelineDiagram activeStage={activeStage} />
<div className="main-grid">
<div className="main-left">
<ChunkGrid chunks={chunks} totalChunks={stats.total_chunks} />
<QueueGauge
current={queueSize}
max={10}
buffered={0}
/>
<QueueGauge current={queueSize} max={10} buffered={0} />
{done && outputFiles.length > 0 && (
<OutputFiles files={outputFiles} />
)}
</div>
<div className="main-right">
<WorkerPanel workers={workers} />