chunker and ui
This commit is contained in:
245
ui/chunker/src/App.tsx
Normal file
245
ui/chunker/src/App.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import "./App.css";
|
||||
import { createChunkJob, getAssets, scanMediaFolder } from "./api";
|
||||
import { ChunkGrid } from "./components/ChunkGrid";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { ErrorLog } from "./components/ErrorLog";
|
||||
import { PipelineDiagram } from "./components/PipelineDiagram";
|
||||
import { QueueGauge } from "./components/QueueGauge";
|
||||
import { StatsPanel } from "./components/StatsPanel";
|
||||
import { WorkerPanel } from "./components/WorkerPanel";
|
||||
import { useEventStream } from "./hooks/useEventStream";
|
||||
import type {
|
||||
ChunkInfo,
|
||||
ErrorEntry,
|
||||
MediaAsset,
|
||||
PipelineConfig,
|
||||
PipelineStats,
|
||||
WorkerInfo,
|
||||
} from "./types";
|
||||
|
||||
export default function App() {
|
||||
const [jobId, setJobId] = 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);
|
||||
|
||||
const { events, connected, done } = useEventStream(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"));
|
||||
}, []);
|
||||
|
||||
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 events
|
||||
const { chunks, workers, stats, errors, activeStage, 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 stage = "pending";
|
||||
|
||||
for (const evt of events) {
|
||||
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 (evt.sequence !== undefined) {
|
||||
const existing = chunkMap.get(evt.sequence) || {
|
||||
sequence: evt.sequence,
|
||||
state: "pending" as const,
|
||||
};
|
||||
|
||||
if (evt.status === "chunking" || evt.status === "pending") {
|
||||
existing.state = "queued";
|
||||
} else if (evt.status === "processing") {
|
||||
existing.state = "processing";
|
||||
if (evt.worker_id) existing.worker_id = evt.worker_id;
|
||||
} else if (evt.status === "completed") {
|
||||
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") {
|
||||
existing.state = "error";
|
||||
if (evt.error) existing.error = evt.error;
|
||||
}
|
||||
|
||||
if (evt.size) existing.size = evt.size;
|
||||
chunkMap.set(evt.sequence, existing);
|
||||
}
|
||||
|
||||
// Track workers
|
||||
if (evt.worker_id) {
|
||||
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";
|
||||
}
|
||||
|
||||
if (evt.success !== undefined) {
|
||||
if (evt.success) w.processed++;
|
||||
else 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: evt.status || "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
activeStage: stage,
|
||||
queueSize,
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
const handleStart = useCallback(async (config: PipelineConfig) => {
|
||||
setError(null);
|
||||
setRunning(true);
|
||||
try {
|
||||
const result = await createChunkJob(config);
|
||||
setJobId(result.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to start");
|
||||
setRunning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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}
|
||||
running={running}
|
||||
assets={assets}
|
||||
selectedAsset={selectedAsset}
|
||||
onSelectAsset={setSelectedAsset}
|
||||
onScan={handleScan}
|
||||
scanning={scanning}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
<div className="main-right">
|
||||
<WorkerPanel workers={workers} />
|
||||
<StatsPanel stats={stats} />
|
||||
<ErrorLog errors={errors} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user