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

@@ -8,10 +8,15 @@
"name": "mpr-chunker",
"version": "0.1.0",
"dependencies": {
"@protobuf-ts/runtime": "^2.11.1",
"@protobuf-ts/runtime-rpc": "^2.11.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@protobuf-ts/grpcweb-transport": "^2.11.1",
"@protobuf-ts/plugin": "^2.11.1",
"@protobuf-ts/protoc": "^2.11.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
@@ -301,6 +306,39 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bufbuild/protoplugin": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.11.0.tgz",
"integrity": "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.11.0",
"@typescript/vfs": "^1.6.2",
"typescript": "5.4.5"
}
},
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -742,6 +780,75 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@protobuf-ts/grpcweb-transport": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.11.1.tgz",
"integrity": "sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@protobuf-ts/runtime": "^2.11.1",
"@protobuf-ts/runtime-rpc": "^2.11.1"
}
},
"node_modules/@protobuf-ts/plugin": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/plugin/-/plugin-2.11.1.tgz",
"integrity": "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^2.4.0",
"@bufbuild/protoplugin": "^2.4.0",
"@protobuf-ts/protoc": "^2.11.1",
"@protobuf-ts/runtime": "^2.11.1",
"@protobuf-ts/runtime-rpc": "^2.11.1",
"typescript": "^3.9"
},
"bin": {
"protoc-gen-dump": "bin/protoc-gen-dump",
"protoc-gen-ts": "bin/protoc-gen-ts"
}
},
"node_modules/@protobuf-ts/plugin/node_modules/typescript": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/@protobuf-ts/protoc": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz",
"integrity": "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"protoc": "protoc.js"
}
},
"node_modules/@protobuf-ts/runtime": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz",
"integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@protobuf-ts/runtime-rpc": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz",
"integrity": "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==",
"license": "Apache-2.0",
"dependencies": {
"@protobuf-ts/runtime": "^2.11.1"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1179,6 +1286,19 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@typescript/vfs": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",

View File

@@ -9,10 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
"@protobuf-ts/grpcweb-transport": "^2.11.1",
"@protobuf-ts/runtime": "^2.11.1",
"@protobuf-ts/runtime-rpc": "^2.11.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@protobuf-ts/plugin": "^2.11.1",
"@protobuf-ts/protoc": "^2.11.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",

View File

@@ -1,16 +1,4 @@
* {
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;
}
@import "../../common/styles/theme.css";
/* ---- Layout ---- */
@@ -25,8 +13,8 @@ body {
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
}
.header h1 {
@@ -40,19 +28,19 @@ body {
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #666;
color: var(--text-muted);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #555;
background: var(--text-muted);
}
.dot.connected {
background: #10b981;
box-shadow: 0 0 6px #10b981;
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.error-banner {
@@ -70,8 +58,8 @@ body {
.sidebar {
width: 300px;
background: #141414;
border-right: 1px solid #2a2a2a;
background: var(--bg-surface);
border-right: 1px solid var(--border);
overflow-y: auto;
}
@@ -97,163 +85,20 @@ body {
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 ---- */
.selected-asset-info {
padding: 0.5rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 4px;
border-radius: var(--radius);
margin-bottom: 0.75rem;
}
.asset-detail {
display: block;
font-size: 0.8rem;
color: #e0e0e0;
color: var(--text-primary);
font-weight: 500;
}
@@ -277,12 +122,12 @@ body {
.config-field label {
display: block;
font-size: 0.75rem;
color: #888;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.config-field .default {
color: #555;
color: var(--text-muted);
font-style: italic;
}
@@ -291,26 +136,26 @@ body {
width: 100%;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
background: #222;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 4px;
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.config-field input:focus,
.config-field select:focus {
outline: none;
border-color: #3b82f6;
border-color: var(--accent);
}
.start-button {
width: 100%;
padding: 0.5rem;
font-size: 0.85rem;
background: #10b981;
background: var(--success);
color: #000;
border: none;
border-radius: 4px;
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
@@ -322,116 +167,86 @@ body {
}
.start-button:disabled {
background: #333;
color: #666;
background: var(--bg-input);
color: var(--text-muted);
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;
.stop-button {
width: 100%;
padding: 0.5rem;
font-size: 0.85rem;
background: var(--error);
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
color: #e0e0e0;
margin-top: 0.5rem;
transition: background 0.2s;
}
.stage-sub {
font-size: 0.65rem;
color: #666;
margin-top: 0.15rem;
.stop-button:hover {
background: #dc2626;
}
.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;
.reset-button {
width: 100%;
padding: 0.5rem;
font-size: 0.85rem;
background: #1e293b;
color: #94a3b8;
border: 1px solid #334155;
border-radius: var(--radius);
cursor: pointer;
font-weight: 600;
margin-top: 0.5rem;
transition: all 0.2s;
}
.reset-button:hover {
background: #334155;
color: var(--text-primary);
}
.range-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.range-row input {
flex: 1;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.range-row input:focus {
outline: none;
border-color: var(--accent);
}
.range-sep {
font-size: 0.75rem;
color: var(--text-muted);
}
/* ---- Chunk Grid ---- */
.chunk-grid-panel {
background: #141414;
border: 1px solid #2a2a2a;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.chunk-count {
font-size: 0.7rem;
color: #555;
color: var(--text-muted);
font-weight: 400;
}
@@ -466,7 +281,7 @@ body {
align-items: center;
gap: 0.25rem;
font-size: 0.65rem;
color: #888;
color: var(--text-secondary);
}
.legend-dot {
@@ -478,8 +293,8 @@ body {
/* ---- Worker Panel ---- */
.worker-panel {
background: #141414;
border: 1px solid #2a2a2a;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
@@ -492,8 +307,8 @@ body {
.worker-card {
padding: 0.5rem 0.75rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
}
@@ -516,7 +331,7 @@ body {
.worker-chunk {
font-size: 0.7rem;
color: #555;
color: var(--text-muted);
margin-top: 0.15rem;
}
@@ -524,13 +339,13 @@ body {
display: flex;
gap: 0.75rem;
font-size: 0.65rem;
color: #555;
color: var(--text-muted);
margin-top: 0.25rem;
}
.worker-empty {
font-size: 0.8rem;
color: #444;
color: var(--text-muted);
text-align: center;
padding: 1rem;
}
@@ -538,8 +353,8 @@ body {
/* ---- Queue Gauge ---- */
.queue-gauge {
background: #141414;
border: 1px solid #2a2a2a;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
@@ -550,38 +365,38 @@ body {
.gauge-label {
font-size: 0.75rem;
color: #888;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.gauge-value {
color: #e0e0e0;
color: var(--text-primary);
font-weight: 600;
}
.gauge-bar {
height: 8px;
background: #222;
border-radius: 4px;
background: var(--bg-input);
border-radius: var(--radius);
overflow: hidden;
}
.gauge-fill {
height: 100%;
border-radius: 4px;
border-radius: var(--radius);
transition: width 0.3s, background 0.3s;
}
.gauge-note {
font-size: 0.65rem;
color: #555;
color: var(--text-muted);
}
/* ---- Stats Panel ---- */
.stats-panel {
background: #141414;
border: 1px solid #2a2a2a;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
@@ -595,52 +410,29 @@ body {
.stat {
text-align: center;
padding: 0.5rem;
background: #1a1a1a;
background: var(--bg-panel);
border-radius: 6px;
}
.stat-value {
font-size: 1.1rem;
font-weight: 700;
color: #e0e0e0;
color: var(--text-primary);
}
.stat-label {
font-size: 0.6rem;
color: #666;
color: var(--text-muted);
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;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
@@ -654,41 +446,6 @@ body {
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;
@@ -696,7 +453,7 @@ body {
.error-empty {
font-size: 0.8rem;
color: #444;
color: var(--text-muted);
text-align: center;
padding: 0.5rem;
}
@@ -706,26 +463,26 @@ body {
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
border-bottom: 1px solid #1a1a1a;
border-bottom: 1px solid var(--bg-panel);
font-size: 0.7rem;
flex-wrap: wrap;
}
.error-type {
color: #ef4444;
color: var(--error);
font-weight: 500;
}
.error-seq {
color: #f59e0b;
color: var(--warning);
}
.error-worker {
color: #3b82f6;
color: var(--accent);
}
.error-msg {
color: #888;
color: var(--text-secondary);
flex: 1;
}
@@ -733,3 +490,15 @@ body {
color: #f97316;
font-size: 0.65rem;
}
/* ---- Output download link ---- */
.fm-download-link {
font-size: 0.7rem;
color: var(--accent);
text-decoration: none;
}
.fm-download-link:hover {
text-decoration: underline;
}

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} />

View File

@@ -1,55 +1,13 @@
/**
* GraphQL API client for the chunker UI.
* Chunker-specific API functions.
* Shared functions (getAssets, scanMediaFolder) come from common.
*/
import type { MediaAsset } from "./types";
import { gql } from "../../common/api/graphql";
import type { ChunkOutputFile } from "../../common/types/generated";
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;
}
// Re-export shared functions
export { getAssets, scanMediaFolder } from "../../common/api/media";
/** Create a chunk job via GraphQL mutation. */
export async function createChunkJob(config: {
@@ -58,15 +16,70 @@ export async function createChunkJob(config: {
num_workers: number;
max_retries: number;
processor_type: string;
}): Promise<{ id: string }> {
const data = await gql<{ create_chunk_job: { id: string; status: string } }>(`
start_time?: number | null;
end_time?: number | null;
}): Promise<{ id: string; celery_task_id: string | null }> {
const data = await gql<{
create_chunk_job: {
id: string;
status: string;
celery_task_id: string | null;
};
}>(
`
mutation CreateChunkJob($input: CreateChunkJobInput!) {
create_chunk_job(input: $input) {
id
status
celery_task_id
}
}
`, { input: config });
`,
{ input: config },
);
return data.create_chunk_job;
}
/** Cancel a running chunk job. */
export async function cancelChunkJob(
celeryTaskId: string,
): Promise<{ ok: boolean; message: string | null }> {
const data = await gql<{
cancel_chunk_job: { ok: boolean; message: string | null };
}>(
`
mutation CancelChunkJob($celery_task_id: String!) {
cancel_chunk_job(celery_task_id: $celery_task_id) {
ok
message
}
}
`,
{ celery_task_id: celeryTaskId },
);
return data.cancel_chunk_job;
}
/** Fetch output chunk files for a completed job. */
export async function getChunkOutputFiles(
jobId: string,
): Promise<ChunkOutputFile[]> {
const data = await gql<{
chunk_output_files: ChunkOutputFile[];
}>(
`
query ChunkOutputFiles($job_id: String!) {
chunk_output_files(job_id: $job_id) {
key
size
url
}
}
`,
{ job_id: jobId },
);
return data.chunk_output_files;
}

View File

@@ -1,5 +1,4 @@
import type { ChunkInfo } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
chunks: ChunkInfo[];
@@ -7,19 +6,14 @@ interface Props {
}
const STATE_COLORS: Record<string, string> = {
pending: "#333",
queued: "#f59e0b",
processing: "#3b82f6",
done: "#10b981",
error: "#ef4444",
pending: "var(--bg-input)",
queued: "var(--warning)",
processing: "var(--processing)",
done: "var(--success)",
error: "var(--error)",
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">
@@ -30,14 +24,13 @@ export function ChunkGrid({ chunks, totalChunks }: Props) {
{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" }}
style={{ background: STATE_COLORS[chunk.state] || "var(--bg-input)" }}
title={`#${chunk.sequence}${chunk.state}${
chunk.worker_id ? ` (${chunk.worker_id})` : ""
}${chunk.retries ? ` retries: ${chunk.retries}` : ""}`}

View File

@@ -1,10 +1,15 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { FileManager } from "../../../common/components/FileManager";
import type { FileEntry } from "../../../common/components/FileManager";
import { formatDuration, formatSize } from "../../../common/utils/format";
import type { MediaAsset, PipelineConfig } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
onStart: (config: PipelineConfig) => void;
onStop: () => void;
onReset: () => void;
running: boolean;
done: boolean;
assets: MediaAsset[];
selectedAsset: MediaAsset | null;
onSelectAsset: (asset: MediaAsset) => void;
@@ -12,13 +17,12 @@ interface Props {
scanning: boolean;
}
/**
* Pipeline configuration form with file browser.
* Each parameter shows its default — Interview Topic 1: Function params & defaults.
*/
export function ConfigPanel({
onStart,
onStop,
onReset,
running,
done,
assets,
selectedAsset,
onSelectAsset,
@@ -31,6 +35,25 @@ export function ConfigPanel({
const [processorType, setProcessorType] = useState<
"ffmpeg" | "checksum" | "simulated_decode" | "composite"
>("ffmpeg");
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
// Map assets to FileEntry for FileManager
const fileEntries: FileEntry[] = useMemo(
() =>
assets.map((a) => ({
key: a.id,
name: a.filename,
size: a.file_size ?? undefined,
meta: formatDuration(a.duration),
})),
[assets],
);
const handleFileSelect = (file: FileEntry) => {
const asset = assets.find((a) => a.id === file.key);
if (asset) onSelectAsset(asset);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -41,61 +64,31 @@ export function ConfigPanel({
num_workers: numWorkers,
max_retries: maxRetries,
processor_type: processorType,
start_time: startTime ? parseFloat(startTime) : null,
end_time: endTime ? parseFloat(endTime) : null,
});
};
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>
<FileManager
title="Assets"
files={fileEntries}
selectedKey={selectedAsset?.id ?? null}
onSelect={handleFileSelect}
onScan={onScan}
scanning={scanning}
emptyMessage="No assets — click Scan Folder"
disabled={running}
/>
{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)}
{selectedAsset.video_codec} · {selectedAsset.width}x
{selectedAsset.height} · {formatDuration(selectedAsset.duration)} ·{" "}
{formatSize(selectedAsset.file_size)}
</span>
</div>
)}
@@ -103,9 +96,35 @@ export function ConfigPanel({
{/* 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>
Time Range (seconds){" "}
<span className="default">optional limits what gets chunked</span>
</label>
<div className="range-row">
<input
type="number"
min={0}
step={1}
placeholder="start"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={running}
/>
<span className="range-sep">to</span>
<input
type="number"
min={0}
step={1}
placeholder="end"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
disabled={running}
/>
</div>
</div>
<div className="config-field">
<label>
Chunk Duration <span className="default">default: 10s</span>
@@ -113,6 +132,7 @@ export function ConfigPanel({
<select
value={chunkDuration}
onChange={(e) => setChunkDuration(Number(e.target.value))}
disabled={running}
>
<option value={5}>5 seconds</option>
<option value={10}>10 seconds</option>
@@ -131,6 +151,7 @@ export function ConfigPanel({
max={16}
value={numWorkers}
onChange={(e) => setNumWorkers(Number(e.target.value))}
disabled={running}
/>
</div>
<div className="config-field">
@@ -143,6 +164,7 @@ export function ConfigPanel({
max={10}
value={maxRetries}
onChange={(e) => setMaxRetries(Number(e.target.value))}
disabled={running}
/>
</div>
<div className="config-field">
@@ -153,9 +175,14 @@ export function ConfigPanel({
value={processorType}
onChange={(e) =>
setProcessorType(
e.target.value as "ffmpeg" | "checksum" | "simulated_decode" | "composite"
e.target.value as
| "ffmpeg"
| "checksum"
| "simulated_decode"
| "composite",
)
}
disabled={running}
>
<option value="ffmpeg">FFmpegExtractProcessor</option>
<option value="checksum">ChecksumProcessor</option>
@@ -163,10 +190,29 @@ export function ConfigPanel({
<option value="composite">CompositeProcessor</option>
</select>
</div>
<button type="submit" className="start-button" disabled={running || !selectedAsset}>
{running ? "Running..." : "Launch Pipeline"}
</button>
{!running && !done && (
<button
type="submit"
className="start-button"
disabled={!selectedAsset}
>
Launch Pipeline
</button>
)}
</form>
{running && (
<button type="button" className="stop-button" onClick={onStop}>
Stop Pipeline
</button>
)}
{done && (
<button type="button" className="reset-button" onClick={onReset}>
Reset
</button>
)}
</div>
);
}

View File

@@ -1,15 +1,9 @@
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">
@@ -18,23 +12,6 @@ export function ErrorLog({ errors }: Props) {
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 && (

View File

@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { FileManager } from "../../../common/components/FileManager";
import type { FileEntry } from "../../../common/components/FileManager";
import { formatSize } from "../../../common/utils/format";
import type { ChunkOutputFile } from "../types";
interface Props {
files: ChunkOutputFile[];
}
export function OutputFiles({ files }: Props) {
const fileEntries: FileEntry[] = useMemo(
() =>
files.map((f) => ({
key: f.key,
name: f.key.split("/").pop() || f.key,
size: f.size,
})),
[files],
);
const urlMap = useMemo(() => {
const map = new Map<string, string>();
for (const f of files) {
map.set(f.key, f.url);
}
return map;
}, [files]);
return (
<FileManager
title="Output Files"
files={fileEntries}
emptyMessage="No output files"
renderActions={(file) => {
const url = urlMap.get(file.key);
if (!url) return null;
return (
<a
href={url}
download
className="fm-download-link"
onClick={(e) => e.stopPropagation()}
>
{formatSize(file.size)}
</a>
);
}}
/>
);
}

View File

@@ -1,50 +0,0 @@
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

@@ -1,15 +1,9 @@
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;
@@ -17,7 +11,6 @@ export function QueueGauge({ current, max, buffered }: Props) {
<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">
@@ -28,7 +21,7 @@ export function QueueGauge({ current, max, buffered }: Props) {
className="gauge-fill"
style={{
width: `${fillPct}%`,
background: fillPct > 80 ? "#ef4444" : "#3b82f6",
background: fillPct > 80 ? "var(--error)" : "var(--processing)",
}}
/>
</div>

View File

@@ -1,24 +1,14 @@
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">
@@ -48,12 +38,6 @@ export function StatsPanel({ stats }: Props) {
<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

@@ -1,86 +0,0 @@
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

@@ -1,28 +1,21 @@
import type { WorkerInfo } from "../types";
import { TopicBadge, TOPICS } from "./TopicBadge";
interface Props {
workers: WorkerInfo[];
}
const STATE_COLORS: Record<string, string> = {
idle: "#6b7280",
processing: "#3b82f6",
idle: "var(--text-muted)",
processing: "var(--processing)",
retry: "#f97316",
stopped: "#ef4444",
stopped: "var(--error)",
};
/**
* 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) => (
@@ -31,7 +24,7 @@ export function WorkerPanel({ workers }: Props) {
<span className="worker-name">{w.worker_id}</span>
<span
className="worker-state"
style={{ color: STATE_COLORS[w.state] || "#888" }}
style={{ color: STATE_COLORS[w.state] || "var(--text-secondary)" }}
>
{w.state}
</span>

View File

@@ -3,8 +3,6 @@ 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[]>([]);
@@ -20,6 +18,12 @@ export function useEventStream(jobId: string | null) {
}
}, []);
const reset = useCallback(() => {
close();
setEvents([]);
setDone(false);
}, [close]);
useEffect(() => {
if (!jobId) return;
@@ -35,21 +39,28 @@ export function useEventStream(jobId: string | null) {
const handleEvent = (eventType: string) => (e: MessageEvent) => {
try {
const data = JSON.parse(e.data) as PipelineEvent;
setEvents((prev) => [...prev, { ...data, status: eventType }]);
setEvents((prev) => [...prev, { ...data, event_type: eventType }]);
} catch {
// ignore parse errors
}
};
// Listen to all chunker event types
// Listen to all raw pipeline event types
const eventTypes = [
"waiting",
"pending",
"chunking",
"processing",
"collecting",
"completed",
"failed",
"pipeline_start",
"pipeline_info",
"chunk_queued",
"chunk_processing",
"chunk_done",
"chunk_retry",
"chunk_error",
"chunk_collected",
"worker_status",
"pipeline_progress",
"pipeline_complete",
"pipeline_error",
"producer_error",
"cancelled",
"done",
"timeout",
@@ -77,5 +88,5 @@ export function useEventStream(jobId: string | null) {
};
}, [jobId]);
return { events, connected, done, close };
return { events, connected, done, close, reset };
}

View File

@@ -0,0 +1,103 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
import { WorkerServiceClient } from "../../../common/api/grpc/worker.client";
import type { ChunkPipelineEvent } from "../../../common/api/grpc/worker";
import type { PipelineEvent } from "../types";
const GRPC_WEB_URL = "/grpc-web";
function toEvent(msg: ChunkPipelineEvent): PipelineEvent {
return {
event_type: msg.eventType,
job_id: msg.jobId,
sequence: msg.sequence || undefined,
worker_id: msg.workerId || undefined,
state: msg.state || undefined,
queue_size: msg.queueSize || undefined,
elapsed: msg.elapsed || undefined,
throughput_mbps: msg.throughputMbps || undefined,
total_chunks: msg.totalChunks || undefined,
processed_chunks: msg.processedChunks || undefined,
failed_chunks: msg.failedChunks || undefined,
error: msg.error || undefined,
processing_time: msg.processingTime || undefined,
retries: msg.retries || undefined,
};
}
/**
* gRPC-Web streaming hook — connects to WorkerService.StreamChunkPipeline
* via Envoy proxy. Replaces useEventStream (SSE+Redis).
*/
export function useGrpcStream(jobId: string | null) {
const [events, setEvents] = useState<PipelineEvent[]>([]);
const [connected, setConnected] = useState(false);
const [done, setDone] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const close = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setConnected(false);
}
}, []);
const reset = useCallback(() => {
close();
setEvents([]);
setDone(false);
}, [close]);
useEffect(() => {
if (!jobId) return;
setEvents([]);
setDone(false);
const abort = new AbortController();
abortRef.current = abort;
const transport = new GrpcWebFetchTransport({
baseUrl: GRPC_WEB_URL,
abort: abort.signal,
});
const client = new WorkerServiceClient(transport);
const stream = client.streamChunkPipeline({ jobId });
setConnected(true);
(async () => {
try {
for await (const msg of stream.responses) {
const evt = toEvent(msg);
setEvents((prev) => [...prev, evt]);
if (
evt.event_type === "pipeline_complete" ||
evt.event_type === "pipeline_error"
) {
setDone(true);
setConnected(false);
break;
}
}
} catch (err) {
if (!abort.signal.aborted) {
setConnected(false);
}
} finally {
setConnected(false);
}
})();
return () => {
abort.abort();
abortRef.current = null;
};
}, [jobId]);
return { events, connected, done, close, reset };
}

View File

@@ -1,3 +1,19 @@
/**
* Chunker UI types.
*
* Domain types (MediaAsset, ChunkEvent, etc.) come from generated schema.
* This file holds UI-only types: state enums, SSE envelope, derived views.
*/
// Re-export generated types used by this app
export type {
MediaAsset,
ChunkEvent,
WorkerEvent,
PipelineStats,
ChunkOutputFile,
} from "../../common/types/generated";
/** Pipeline configuration sent to the backend. */
export interface PipelineConfig {
source_asset_id: string;
@@ -5,31 +21,11 @@ export interface PipelineConfig {
num_workers: number;
max_retries: number;
processor_type: "ffmpeg" | "checksum" | "simulated_decode" | "composite";
start_time?: number | null;
end_time?: number | null;
}
/** 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. */
/** UI state of an individual chunk in the grid. */
export type ChunkState =
| "pending"
| "queued"
@@ -38,7 +34,7 @@ export type ChunkState =
| "error"
| "retry";
/** Tracked chunk in the UI grid. */
/** Tracked chunk in the UI grid (derived from events). */
export interface ChunkInfo {
sequence: number;
state: ChunkState;
@@ -49,7 +45,7 @@ export interface ChunkInfo {
error?: string;
}
/** Worker thread status. */
/** Worker thread status (derived from events). */
export interface WorkerInfo {
worker_id: string;
state: "idle" | "processing" | "retry" | "stopped";
@@ -59,9 +55,14 @@ export interface WorkerInfo {
retries: number;
}
/** SSE event from the backend. */
/**
* Raw SSE event envelope from the backend.
* The event_type field is set by useEventStream from the SSE event name.
* All other fields are optional — presence depends on event_type.
*/
export interface PipelineEvent {
job_id: string;
job_id?: string;
event_type?: string;
status?: string;
progress?: number;
total_chunks?: number;
@@ -84,18 +85,7 @@ export interface PipelineEvent {
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. */
/** Error log entry (derived from events). */
export interface ErrorEntry {
timestamp: number;
sequence?: number;
@@ -104,11 +94,3 @@ export interface ErrorEntry {
retries?: number;
event_type: string;
}
/** Interview topic for annotation badges. */
export interface InterviewTopic {
number: number;
title: string;
description: string;
code_ref: string;
}

View File

@@ -14,8 +14,13 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"rootDir": "..",
"typeRoots": ["./node_modules/@types"],
"paths": {
"@protobuf-ts/*": ["./node_modules/@protobuf-ts/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts", "src/**/*.tsx", "../common/**/*.ts", "../common/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,9 +1,26 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "/chunker/",
plugins: [react()],
resolve: {
alias: {
"@protobuf-ts/runtime": path.resolve(
__dirname,
"node_modules/@protobuf-ts/runtime",
),
"@protobuf-ts/runtime-rpc": path.resolve(
__dirname,
"node_modules/@protobuf-ts/runtime-rpc",
),
"@protobuf-ts/grpcweb-transport": path.resolve(
__dirname,
"node_modules/@protobuf-ts/grpcweb-transport",
),
},
},
server: {
host: "0.0.0.0",
port: 5174,
@@ -11,6 +28,9 @@ export default defineConfig({
hmr: {
path: "/chunker/@vite/client",
},
fs: {
allow: [".."],
},
proxy: {
"/api": {
target: "http://fastapi:8702",
@@ -20,6 +40,11 @@ export default defineConfig({
target: "http://fastapi:8702",
changeOrigin: true,
},
"/grpc-web": {
target: "http://envoy:8090",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/grpc-web/, ""),
},
},
},
});