chunker and ui

This commit is contained in:
2026-03-13 14:29:38 -03:00
parent 3eeedebb15
commit ccc478fbaa
69 changed files with 6481 additions and 282 deletions

12
ui/chunker/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MPR Chunker Pipeline</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1729
ui/chunker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
ui/chunker/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "mpr-chunker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

735
ui/chunker/src/App.css Normal file
View File

@@ -0,0 +1,735 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Fira Code", monospace, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
font-size: 14px;
}
/* ---- Layout ---- */
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
}
.header h1 {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #666;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #555;
}
.dot.connected {
background: #10b981;
box-shadow: 0 0 6px #10b981;
}
.error-banner {
padding: 0.5rem 1.25rem;
background: #7f1d1d;
color: #fca5a5;
font-size: 0.85rem;
}
.layout {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 300px;
background: #141414;
border-right: 1px solid #2a2a2a;
overflow-y: auto;
}
.main {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.main-left,
.main-right {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ---- Panel shared ---- */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.panel-header h2 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #888;
}
.badge-row {
display: flex;
gap: 0.25rem;
}
/* ---- Topic Badge ---- */
.topic-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
font-size: 0.65rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.topic-badge:hover {
border-color: #3b82f6;
}
.topic-badge.expanded {
flex-direction: column;
align-items: flex-start;
border-radius: 8px;
padding: 0.5rem;
position: relative;
z-index: 10;
background: #1e293b;
}
.topic-number {
color: #3b82f6;
font-weight: 700;
}
.topic-title {
color: #94a3b8;
}
.topic-detail {
margin-top: 0.25rem;
font-size: 0.7rem;
line-height: 1.4;
}
.topic-detail p {
color: #cbd5e1;
margin-bottom: 0.25rem;
}
.topic-detail code {
color: #10b981;
font-size: 0.65rem;
}
/* ---- Asset List ---- */
.scan-button {
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
background: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.scan-button:hover:not(:disabled) {
background: #334155;
color: #e0e0e0;
}
.scan-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.asset-list {
list-style: none;
max-height: 200px;
overflow-y: auto;
margin-bottom: 0.75rem;
}
.asset-item {
padding: 0.4rem 0.5rem;
cursor: pointer;
border-left: 2px solid transparent;
transition: all 0.15s;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.asset-item:hover {
background: #1a1a1a;
}
.asset-item.selected {
background: #1e293b;
border-left-color: #3b82f6;
}
.asset-filename {
font-size: 0.8rem;
color: #e0e0e0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.asset-meta {
font-size: 0.65rem;
color: #555;
}
.asset-empty {
font-size: 0.8rem;
color: #444;
padding: 0.75rem 0.5rem;
text-align: center;
}
.selected-asset-info {
padding: 0.5rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 4px;
margin-bottom: 0.75rem;
}
.asset-detail {
display: block;
font-size: 0.8rem;
color: #e0e0e0;
font-weight: 500;
}
.asset-detail-meta {
display: block;
font-size: 0.65rem;
color: #64748b;
margin-top: 0.15rem;
}
/* ---- Config Panel ---- */
.config-panel {
padding: 1rem;
}
.config-field {
margin-bottom: 0.75rem;
}
.config-field label {
display: block;
font-size: 0.75rem;
color: #888;
margin-bottom: 0.25rem;
}
.config-field .default {
color: #555;
font-style: italic;
}
.config-field input,
.config-field select {
width: 100%;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
background: #222;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 4px;
}
.config-field input:focus,
.config-field select:focus {
outline: none;
border-color: #3b82f6;
}
.start-button {
width: 100%;
padding: 0.5rem;
font-size: 0.85rem;
background: #10b981;
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
transition: background 0.2s;
}
.start-button:hover:not(:disabled) {
background: #059669;
}
.start-button:disabled {
background: #333;
color: #666;
cursor: not-allowed;
}
/* ---- Pipeline Diagram ---- */
.pipeline-diagram {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 1rem;
}
.stage-flow {
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
}
.stage-wrapper {
display: flex;
align-items: center;
}
.stage {
padding: 0.5rem 0.75rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
text-align: center;
min-width: 120px;
transition: all 0.3s;
}
.stage.active {
border-color: #3b82f6;
background: #1e293b;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.2);
}
.stage-label {
font-size: 0.8rem;
font-weight: 600;
color: #e0e0e0;
}
.stage-sub {
font-size: 0.65rem;
color: #666;
margin-top: 0.15rem;
}
.stage-arrow {
width: 24px;
height: 2px;
background: #444;
position: relative;
}
.stage-arrow::after {
content: "";
position: absolute;
right: 0;
top: -3px;
border: 4px solid transparent;
border-left: 6px solid #444;
}
.processor-hierarchy {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #222;
}
.hierarchy-title {
font-size: 0.7rem;
color: #666;
margin-bottom: 0.35rem;
font-style: italic;
}
.hierarchy-children {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.hierarchy-node {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
color: #94a3b8;
}
/* ---- Chunk Grid ---- */
.chunk-grid-panel {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 1rem;
}
.chunk-count {
font-size: 0.7rem;
color: #555;
font-weight: 400;
}
.chunk-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
gap: 3px;
max-height: 200px;
overflow-y: auto;
}
.chunk-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.55rem;
color: rgba(255, 255, 255, 0.6);
border-radius: 3px;
transition: background 0.3s;
}
.chunk-legend {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
color: #888;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
/* ---- Worker Panel ---- */
.worker-panel {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 1rem;
}
.worker-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.worker-card {
padding: 0.5rem 0.75rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 6px;
}
.worker-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.worker-name {
font-size: 0.8rem;
font-weight: 500;
}
.worker-state {
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 600;
}
.worker-chunk {
font-size: 0.7rem;
color: #555;
margin-top: 0.15rem;
}
.worker-stats {
display: flex;
gap: 0.75rem;
font-size: 0.65rem;
color: #555;
margin-top: 0.25rem;
}
.worker-empty {
font-size: 0.8rem;
color: #444;
text-align: center;
padding: 1rem;
}
/* ---- Queue Gauge ---- */
.queue-gauge {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 1rem;
}
.gauge-row {
margin-bottom: 0.5rem;
}
.gauge-label {
font-size: 0.75rem;
color: #888;
margin-bottom: 0.25rem;
}
.gauge-value {
color: #e0e0e0;
font-weight: 600;
}
.gauge-bar {
height: 8px;
background: #222;
border-radius: 4px;
overflow: hidden;
}
.gauge-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s, background 0.3s;
}
.gauge-note {
font-size: 0.65rem;
color: #555;
}
/* ---- Stats Panel ---- */
.stats-panel {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat {
text-align: center;
padding: 0.5rem;
background: #1a1a1a;
border-radius: 6px;
}
.stat-value {
font-size: 1.1rem;
font-weight: 700;
color: #e0e0e0;
}
.stat-label {
font-size: 0.6rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.15rem;
}
.test-info {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid #222;
display: flex;
align-items: center;
gap: 0.5rem;
}
.test-badge {
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
background: #10b981;
color: #000;
border-radius: 3px;
font-weight: 600;
}
.test-note {
font-size: 0.65rem;
color: #555;
}
/* ---- Error Log ---- */
.error-log {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 1rem;
}
.error-count {
font-size: 0.7rem;
background: #7f1d1d;
color: #fca5a5;
padding: 0.1rem 0.4rem;
border-radius: 8px;
font-weight: 400;
}
.exception-tree {
margin-bottom: 0.75rem;
padding: 0.5rem;
background: #1a1a1a;
border-radius: 6px;
font-size: 0.7rem;
font-family: "Fira Code", monospace;
}
.tree-node {
color: #94a3b8;
padding: 0.1rem 0;
}
.tree-node.root {
color: #f59e0b;
font-weight: 600;
}
.tree-node.leaf {
color: #64748b;
}
.tree-children {
padding-left: 1rem;
border-left: 1px solid #333;
margin-left: 0.5rem;
}
.tree-grandchildren {
padding-left: 1rem;
border-left: 1px solid #333;
margin-left: 0.5rem;
}
.error-entries {
max-height: 150px;
overflow-y: auto;
}
.error-empty {
font-size: 0.8rem;
color: #444;
text-align: center;
padding: 0.5rem;
}
.error-entry {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
border-bottom: 1px solid #1a1a1a;
font-size: 0.7rem;
flex-wrap: wrap;
}
.error-type {
color: #ef4444;
font-weight: 500;
}
.error-seq {
color: #f59e0b;
}
.error-worker {
color: #3b82f6;
}
.error-msg {
color: #888;
flex: 1;
}
.error-retries {
color: #f97316;
font-size: 0.65rem;
}

245
ui/chunker/src/App.tsx Normal file
View 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>
);
}

72
ui/chunker/src/api.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* GraphQL API client for the chunker UI.
*/
import type { MediaAsset } from "./types";
const GRAPHQL_URL = "/api/graphql";
async function gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const response = await fetch(GRAPHQL_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
const json = await response.json();
if (json.errors?.length) {
throw new Error(json.errors[0].message);
}
return json.data as T;
}
/** Fetch all media assets. */
export async function getAssets(): Promise<MediaAsset[]> {
const data = await gql<{ assets: MediaAsset[] }>(`
query {
assets {
id filename file_path status error_message file_size duration
video_codec audio_codec width height framerate bitrate
properties comments tags created_at updated_at
}
}
`);
return data.assets;
}
/** Scan media/in/ folder for new files. */
export async function scanMediaFolder(): Promise<{
found: number;
registered: number;
skipped: number;
files: string[];
}> {
const data = await gql<{ scan_media_folder: { found: number; registered: number; skipped: number; files: string[] } }>(`
mutation {
scan_media_folder { found registered skipped files }
}
`);
return data.scan_media_folder;
}
/** Create a chunk job via GraphQL mutation. */
export async function createChunkJob(config: {
source_asset_id: string;
chunk_duration: number;
num_workers: number;
max_retries: number;
processor_type: string;
}): Promise<{ id: string }> {
const data = await gql<{ create_chunk_job: { id: string; status: string } }>(`
mutation CreateChunkJob($input: CreateChunkJobInput!) {
create_chunk_job(input: $input) {
id
status
}
}
`, { input: config });
return data.create_chunk_job;
}

View File

@@ -0,0 +1,59 @@
import type { ChunkInfo } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
chunks: ChunkInfo[];
totalChunks: number;
}
const STATE_COLORS: Record<string, string> = {
pending: "#333",
queued: "#f59e0b",
processing: "#3b82f6",
done: "#10b981",
error: "#ef4444",
retry: "#f97316",
};
/**
* Grid of chunks colored by processing state.
* Chunks appear incrementally as the generator yields them.
* Interview Topic 3: Generators & iteration.
*/
export function ChunkGrid({ chunks, totalChunks }: Props) {
return (
<div className="chunk-grid-panel">
<div className="panel-header">
<h2>
Chunks{" "}
<span className="chunk-count">
{chunks.length} / {totalChunks || "?"}
</span>
</h2>
<TopicBadge topic={TOPICS.iteration} />
</div>
<div className="chunk-grid">
{chunks.map((chunk) => (
<div
key={chunk.sequence}
className="chunk-cell"
style={{ background: STATE_COLORS[chunk.state] || "#333" }}
title={`#${chunk.sequence}${chunk.state}${
chunk.worker_id ? ` (${chunk.worker_id})` : ""
}${chunk.retries ? ` retries: ${chunk.retries}` : ""}`}
>
{chunk.sequence}
</div>
))}
</div>
<div className="chunk-legend">
{Object.entries(STATE_COLORS).map(([state, color]) => (
<span key={state} className="legend-item">
<span className="legend-dot" style={{ background: color }} />
{state}
</span>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import type { MediaAsset, PipelineConfig } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
onStart: (config: PipelineConfig) => void;
running: boolean;
assets: MediaAsset[];
selectedAsset: MediaAsset | null;
onSelectAsset: (asset: MediaAsset) => void;
onScan: () => void;
scanning: boolean;
}
/**
* Pipeline configuration form with file browser.
* Each parameter shows its default — Interview Topic 1: Function params & defaults.
*/
export function ConfigPanel({
onStart,
running,
assets,
selectedAsset,
onSelectAsset,
onScan,
scanning,
}: Props) {
const [chunkDuration, setChunkDuration] = useState(10.0);
const [numWorkers, setNumWorkers] = useState(4);
const [maxRetries, setMaxRetries] = useState(3);
const [processorType, setProcessorType] = useState<
"ffmpeg" | "checksum" | "simulated_decode" | "composite"
>("ffmpeg");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedAsset) return;
onStart({
source_asset_id: selectedAsset.id,
chunk_duration: chunkDuration,
num_workers: numWorkers,
max_retries: maxRetries,
processor_type: processorType,
});
};
const formatSize = (bytes: number | null) => {
if (!bytes) return "—";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const formatDuration = (seconds: number | null) => {
if (!seconds) return "—";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div className="config-panel">
{/* Asset Browser */}
<div className="panel-header">
<h2>Assets</h2>
<button
onClick={onScan}
disabled={scanning}
className="scan-button"
>
{scanning ? "Scanning..." : "Scan Folder"}
</button>
</div>
<ul className="asset-list">
{assets.length === 0 ? (
<li className="asset-empty">No assets click Scan Folder</li>
) : (
assets.map((asset) => (
<li
key={asset.id}
className={`asset-item ${selectedAsset?.id === asset.id ? "selected" : ""}`}
onClick={() => onSelectAsset(asset)}
title={asset.filename}
>
<span className="asset-filename">{asset.filename}</span>
<span className="asset-meta">
{formatSize(asset.file_size)} · {formatDuration(asset.duration)}
</span>
</li>
))
)}
</ul>
{selectedAsset && (
<div className="selected-asset-info">
<span className="asset-detail">{selectedAsset.filename}</span>
<span className="asset-detail-meta">
{selectedAsset.video_codec} · {selectedAsset.width}x{selectedAsset.height} · {formatDuration(selectedAsset.duration)}
</span>
</div>
)}
{/* Pipeline Config */}
<div className="panel-header" style={{ marginTop: "1rem" }}>
<h2>Pipeline Config</h2>
<TopicBadge topic={TOPICS.params} />
</div>
<form onSubmit={handleSubmit}>
<div className="config-field">
<label>
Chunk Duration <span className="default">default: 10s</span>
</label>
<select
value={chunkDuration}
onChange={(e) => setChunkDuration(Number(e.target.value))}
>
<option value={5}>5 seconds</option>
<option value={10}>10 seconds</option>
<option value={15}>15 seconds</option>
<option value={30}>30 seconds</option>
<option value={60}>60 seconds</option>
</select>
</div>
<div className="config-field">
<label>
Workers <span className="default">default: 4</span>
</label>
<input
type="number"
min={1}
max={16}
value={numWorkers}
onChange={(e) => setNumWorkers(Number(e.target.value))}
/>
</div>
<div className="config-field">
<label>
Max Retries <span className="default">default: 3</span>
</label>
<input
type="number"
min={0}
max={10}
value={maxRetries}
onChange={(e) => setMaxRetries(Number(e.target.value))}
/>
</div>
<div className="config-field">
<label>
Processor <span className="default">default: ffmpeg</span>
</label>
<select
value={processorType}
onChange={(e) =>
setProcessorType(
e.target.value as "ffmpeg" | "checksum" | "simulated_decode" | "composite"
)
}
>
<option value="ffmpeg">FFmpegExtractProcessor</option>
<option value="checksum">ChecksumProcessor</option>
<option value="simulated_decode">SimulatedDecodeProcessor</option>
<option value="composite">CompositeProcessor</option>
</select>
</div>
<button type="submit" className="start-button" disabled={running || !selectedAsset}>
{running ? "Running..." : "Launch Pipeline"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import type { ErrorEntry } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
errors: ErrorEntry[];
}
/**
* Error and retry event log.
* Shows exception types, retry counts, backoff delays.
* Interview Topic 7: Exception handling & resilient code.
*/
export function ErrorLog({ errors }: Props) {
return (
<div className="error-log">
<div className="panel-header">
<h2>
Errors & Retries{" "}
<span className="error-count">{errors.length}</span>
</h2>
<TopicBadge topic={TOPICS.exceptions} />
</div>
<div className="exception-tree">
<div className="tree-node root">PipelineError</div>
<div className="tree-children">
<div className="tree-node">ChunkError</div>
<div className="tree-grandchildren">
<div className="tree-node leaf">ChunkReadError</div>
<div className="tree-node leaf">ChunkChecksumError</div>
</div>
<div className="tree-node">ProcessingError</div>
<div className="tree-grandchildren">
<div className="tree-node leaf">ProcessorTimeoutError</div>
<div className="tree-node leaf">ProcessorFailureError</div>
</div>
<div className="tree-node">ReassemblyError</div>
</div>
</div>
<div className="error-entries">
{errors.length === 0 && (
<div className="error-empty">No errors recorded</div>
)}
{errors.map((entry, i) => (
<div key={i} className="error-entry">
<span className="error-type">{entry.event_type}</span>
{entry.sequence !== undefined && (
<span className="error-seq">chunk #{entry.sequence}</span>
)}
{entry.worker_id && (
<span className="error-worker">{entry.worker_id}</span>
)}
<span className="error-msg">{entry.error}</span>
{entry.retries !== undefined && entry.retries > 0 && (
<span className="error-retries">
{entry.retries} retries
</span>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
activeStage: string;
}
const STAGES = [
{ id: "chunking", label: "Chunker", sub: "File -> Chunks (generator)" },
{ id: "queued", label: "ChunkQueue", sub: "Bounded queue (backpressure)" },
{ id: "processing", label: "WorkerPool", sub: "ThreadPoolExecutor" },
{ id: "collecting", label: "ResultCollector", sub: "heapq reassembly" },
{ id: "completed", label: "PipelineResult", sub: "Aggregate stats" },
];
/**
* Visual flow diagram of pipeline stages.
* Highlights the currently active stage.
* Interview Topic 4: OOP design — shows class hierarchy.
*/
export function PipelineDiagram({ activeStage }: Props) {
return (
<div className="pipeline-diagram">
<div className="panel-header">
<h2>Pipeline Flow</h2>
<TopicBadge topic={TOPICS.oop} />
</div>
<div className="stage-flow">
{STAGES.map((stage, i) => (
<div key={stage.id} className="stage-wrapper">
<div
className={`stage ${activeStage === stage.id ? "active" : ""}`}
>
<div className="stage-label">{stage.label}</div>
<div className="stage-sub">{stage.sub}</div>
</div>
{i < STAGES.length - 1 && <div className="stage-arrow" />}
</div>
))}
</div>
<div className="processor-hierarchy">
<div className="hierarchy-title">Processor ABC</div>
<div className="hierarchy-children">
<span className="hierarchy-node">ChecksumProcessor</span>
<span className="hierarchy-node">SimulatedDecodeProcessor</span>
<span className="hierarchy-node">CompositeProcessor</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
current: number;
max: number;
buffered: number;
}
/**
* Queue fill level gauge + collector heap buffer.
* Interview Topic 5: Data structures — queue.Queue, heapq, deque.
*/
export function QueueGauge({ current, max, buffered }: Props) {
const fillPct = max > 0 ? Math.min((current / max) * 100, 100) : 0;
return (
<div className="queue-gauge">
<div className="panel-header">
<h2>Queue & Buffer</h2>
<TopicBadge topic={TOPICS.datastructures} />
</div>
<div className="gauge-row">
<div className="gauge-label">
Queue <span className="gauge-value">{current}/{max}</span>
</div>
<div className="gauge-bar">
<div
className="gauge-fill"
style={{
width: `${fillPct}%`,
background: fillPct > 80 ? "#ef4444" : "#3b82f6",
}}
/>
</div>
</div>
<div className="gauge-row">
<div className="gauge-label">
Heap Buffer <span className="gauge-value">{buffered}</span>
</div>
<div className="gauge-note">
Out-of-order results waiting for gaps to fill
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import type { PipelineStats } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
stats: PipelineStats;
}
/**
* Throughput, timing, and error stats.
* Interview Topic 6: Algorithms — throughput calculation over sliding window.
* Interview Topic 8: TDD — test count and coverage.
*/
export function StatsPanel({ stats }: Props) {
return (
<div className="stats-panel">
<div className="panel-header">
<h2>Stats</h2>
<div className="badge-row">
<TopicBadge topic={TOPICS.algorithms} />
<TopicBadge topic={TOPICS.testing} />
</div>
</div>
<div className="stats-grid">
<div className="stat">
<div className="stat-value">{stats.total_chunks}</div>
<div className="stat-label">Total Chunks</div>
</div>
<div className="stat">
<div className="stat-value">{stats.processed}</div>
<div className="stat-label">Processed</div>
</div>
<div className="stat">
<div className="stat-value">{stats.failed}</div>
<div className="stat-label">Failed</div>
</div>
<div className="stat">
<div className="stat-value">{stats.retries}</div>
<div className="stat-label">Retries</div>
</div>
<div className="stat">
<div className="stat-value">
{stats.throughput_mbps.toFixed(2)}
</div>
<div className="stat-label">MB/s</div>
</div>
<div className="stat">
<div className="stat-value">{stats.elapsed.toFixed(2)}s</div>
<div className="stat-label">Elapsed</div>
</div>
</div>
<div className="test-info">
<span className="test-badge">64 tests</span>
<span className="test-note">
7 test files &middot; pytest &middot; parametrized
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState } from "react";
import type { InterviewTopic } from "../types";
/**
* Expandable pill badge annotating an interview topic.
* Click to expand and see description + code reference.
*/
export function TopicBadge({ topic }: { topic: InterviewTopic }) {
const [expanded, setExpanded] = useState(false);
return (
<div
className={`topic-badge ${expanded ? "expanded" : ""}`}
onClick={() => setExpanded(!expanded)}
>
<span className="topic-number">#{topic.number}</span>
<span className="topic-title">{topic.title}</span>
{expanded && (
<div className="topic-detail">
<p>{topic.description}</p>
<code>{topic.code_ref}</code>
</div>
)}
</div>
);
}
/** Pre-defined topics mapped to pipeline components. */
export const TOPICS: Record<string, InterviewTopic> = {
params: {
number: 1,
title: "Function Params & Defaults",
description:
"Each pipeline parameter has a sensible default (chunk_duration=10s, num_workers=4, max_retries=3). Tweaking them changes pipeline behavior.",
code_ref: "core/chunker/pipeline.py — Pipeline.__init__()",
},
concurrency: {
number: 2,
title: "Concurrency (Threading)",
description:
"Workers run in a ThreadPoolExecutor. The queue coordinates work between producer and consumer threads.",
code_ref: "core/chunker/pool.py — WorkerPool, ThreadPoolExecutor",
},
iteration: {
number: 3,
title: "Generators & Iteration",
description:
"Chunks are yielded lazily via a generator — the file is never fully loaded into memory.",
code_ref: "core/chunker/chunker.py — Chunker.chunks() generator",
},
oop: {
number: 4,
title: "OOP Design (ABC)",
description:
"Processor is an abstract base class. ChecksumProcessor, SimulatedDecodeProcessor, and CompositeProcessor inherit from it.",
code_ref: "core/chunker/processor.py — Processor ABC hierarchy",
},
datastructures: {
number: 5,
title: "Data Structures",
description:
"Bounded queue.Queue for backpressure, heapq min-heap for ordered reassembly, deque for sliding-window throughput.",
code_ref: "core/chunker/queue.py, collector.py, models.py",
},
algorithms: {
number: 6,
title: "Algorithms & Sorting",
description:
"ResultCollector uses a min-heap to reassemble chunks in sequence order, even when they arrive out of order.",
code_ref: "core/chunker/collector.py — heapq-based reassembly",
},
exceptions: {
number: 7,
title: "Exception Handling",
description:
"PipelineError hierarchy with typed exceptions. Workers retry with exponential backoff before giving up.",
code_ref: "core/chunker/exceptions.py, worker.py — retry logic",
},
testing: {
number: 8,
title: "TDD & Unit Testing",
description:
"64 tests covering every module. Parametrized tests, fixtures, edge cases, concurrency tests.",
code_ref: "tests/chunker/ — 7 test files, pytest",
},
};

View File

@@ -0,0 +1,55 @@
import type { WorkerInfo } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
workers: WorkerInfo[];
}
const STATE_COLORS: Record<string, string> = {
idle: "#6b7280",
processing: "#3b82f6",
retry: "#f97316",
stopped: "#ef4444",
};
/**
* Worker thread status cards.
* Shows each worker's real-time state and which chunk it's processing.
* Interview Topic 2: Concurrency (threading).
*/
export function WorkerPanel({ workers }: Props) {
return (
<div className="worker-panel">
<div className="panel-header">
<h2>Workers</h2>
<TopicBadge topic={TOPICS.concurrency} />
</div>
<div className="worker-cards">
{workers.map((w) => (
<div key={w.worker_id} className="worker-card">
<div className="worker-header">
<span className="worker-name">{w.worker_id}</span>
<span
className="worker-state"
style={{ color: STATE_COLORS[w.state] || "#888" }}
>
{w.state}
</span>
</div>
{w.current_chunk !== undefined && (
<div className="worker-chunk">chunk #{w.current_chunk}</div>
)}
<div className="worker-stats">
<span>done: {w.processed}</span>
<span>err: {w.errors}</span>
<span>retry: {w.retries}</span>
</div>
</div>
))}
{workers.length === 0 && (
<div className="worker-empty">No workers started</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { PipelineEvent } from "../types";
/**
* SSE hook — connects to /api/chunker/stream/{jobId} via native EventSource.
*
* Demonstrates: real-time event streaming from backend to UI.
*/
export function useEventStream(jobId: string | null) {
const [events, setEvents] = useState<PipelineEvent[]>([]);
const [connected, setConnected] = useState(false);
const [done, setDone] = useState(false);
const esRef = useRef<EventSource | null>(null);
const close = useCallback(() => {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
setConnected(false);
}
}, []);
useEffect(() => {
if (!jobId) return;
setEvents([]);
setDone(false);
const es = new EventSource(`/api/chunker/stream/${jobId}`);
esRef.current = es;
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
const handleEvent = (eventType: string) => (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as PipelineEvent;
setEvents((prev) => [...prev, { ...data, status: eventType }]);
} catch {
// ignore parse errors
}
};
// Listen to all chunker event types
const eventTypes = [
"waiting",
"pending",
"chunking",
"processing",
"collecting",
"completed",
"failed",
"cancelled",
"done",
"timeout",
];
for (const type of eventTypes) {
es.addEventListener(type, handleEvent(type));
}
es.addEventListener("done", () => {
setDone(true);
es.close();
setConnected(false);
});
es.addEventListener("timeout", () => {
setDone(true);
es.close();
setConnected(false);
});
return () => {
es.close();
esRef.current = null;
};
}, [jobId]);
return { events, connected, done, close };
}

9
ui/chunker/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("app")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

114
ui/chunker/src/types.ts Normal file
View File

@@ -0,0 +1,114 @@
/** Pipeline configuration sent to the backend. */
export interface PipelineConfig {
source_asset_id: string;
chunk_duration: number;
num_workers: number;
max_retries: number;
processor_type: "ffmpeg" | "checksum" | "simulated_decode" | "composite";
}
/** Media asset from the backend. */
export interface MediaAsset {
id: string;
filename: string;
file_path: string;
status: string;
error_message: string | null;
file_size: number | null;
duration: number | null;
video_codec: string | null;
audio_codec: string | null;
width: number | null;
height: number | null;
framerate: number | null;
bitrate: number | null;
properties: Record<string, unknown>;
comments: string;
tags: string[];
created_at: string | null;
updated_at: string | null;
}
/** State of an individual chunk. */
export type ChunkState =
| "pending"
| "queued"
| "processing"
| "done"
| "error"
| "retry";
/** Tracked chunk in the UI grid. */
export interface ChunkInfo {
sequence: number;
state: ChunkState;
size?: number;
worker_id?: string;
retries?: number;
processing_time?: number;
error?: string;
}
/** Worker thread status. */
export interface WorkerInfo {
worker_id: string;
state: "idle" | "processing" | "retry" | "stopped";
current_chunk?: number;
processed: number;
errors: number;
retries: number;
}
/** SSE event from the backend. */
export interface PipelineEvent {
job_id: string;
status?: string;
progress?: number;
total_chunks?: number;
processed_chunks?: number;
failed_chunks?: number;
throughput_mbps?: number;
elapsed?: number;
error?: string;
// Chunk-level fields
sequence?: number;
size?: number;
worker_id?: string;
success?: boolean;
processing_time?: number;
retries?: number;
queue_size?: number;
// Worker-level fields
state?: string;
attempt?: number;
backoff?: number;
}
/** Aggregate pipeline stats. */
export interface PipelineStats {
total_chunks: number;
processed: number;
failed: number;
retries: number;
elapsed: number;
throughput_mbps: number;
queue_size: number;
}
/** Error log entry. */
export interface ErrorEntry {
timestamp: number;
sequence?: number;
worker_id?: string;
error: string;
retries?: number;
event_type: string;
}
/** Interview topic for annotation badges. */
export interface InterviewTopic {
number: number;
title: string;
description: string;
code_ref: string;
}

1
ui/chunker/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
ui/chunker/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

21
ui/chunker/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5174,
allowedHosts: process.env.VITE_ALLOWED_HOSTS?.split(",") || [],
proxy: {
"/api": {
target: "http://fastapi:8702",
changeOrigin: true,
},
"/graphql": {
target: "http://fastapi:8702",
changeOrigin: true,
},
},
},
});

View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

@@ -6,6 +6,7 @@
export type AssetStatus = "pending" | "ready" | "error";
export type JobStatus = "pending" | "processing" | "completed" | "failed" | "cancelled";
export type ChunkJobStatus = "pending" | "chunking" | "processing" | "collecting" | "completed" | "failed" | "cancelled";
export interface MediaAsset {
id: string;
@@ -73,6 +74,29 @@ export interface TranscodeJob {
completed_at: string | null;
}
export interface ChunkJob {
id: string;
source_asset_id: string;
chunk_duration: number;
num_workers: number;
max_retries: number;
processor_type: string;
status: ChunkJobStatus;
progress: number;
total_chunks: number;
processed_chunks: number;
failed_chunks: number;
retry_count: number;
error_message: string | null;
throughput_mbps: number | null;
elapsed_seconds: number | null;
celery_task_id: string | null;
priority: number;
created_at: string | null;
started_at: string | null;
completed_at: string | null;
}
export interface CreateJobRequest {
source_asset_id: string;
preset_id: string | null;