chunker and ui
This commit is contained in:
12
ui/chunker/index.html
Normal file
12
ui/chunker/index.html
Normal 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
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
22
ui/chunker/package.json
Normal 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
735
ui/chunker/src/App.css
Normal 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
245
ui/chunker/src/App.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import "./App.css";
|
||||
import { createChunkJob, getAssets, scanMediaFolder } from "./api";
|
||||
import { ChunkGrid } from "./components/ChunkGrid";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { ErrorLog } from "./components/ErrorLog";
|
||||
import { PipelineDiagram } from "./components/PipelineDiagram";
|
||||
import { QueueGauge } from "./components/QueueGauge";
|
||||
import { StatsPanel } from "./components/StatsPanel";
|
||||
import { WorkerPanel } from "./components/WorkerPanel";
|
||||
import { useEventStream } from "./hooks/useEventStream";
|
||||
import type {
|
||||
ChunkInfo,
|
||||
ErrorEntry,
|
||||
MediaAsset,
|
||||
PipelineConfig,
|
||||
PipelineStats,
|
||||
WorkerInfo,
|
||||
} from "./types";
|
||||
|
||||
export default function App() {
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Asset state
|
||||
const [assets, setAssets] = useState<MediaAsset[]>([]);
|
||||
const [selectedAsset, setSelectedAsset] = useState<MediaAsset | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const { events, connected, done } = useEventStream(jobId);
|
||||
|
||||
// Load assets on mount
|
||||
useEffect(() => {
|
||||
getAssets()
|
||||
.then((data) => setAssets(data.sort((a, b) => a.filename.localeCompare(b.filename))))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load assets"));
|
||||
}, []);
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
try {
|
||||
await scanMediaFolder();
|
||||
const data = await getAssets();
|
||||
setAssets(data.sort((a, b) => a.filename.localeCompare(b.filename)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Derive state from events
|
||||
const { chunks, workers, stats, errors, activeStage, queueSize } =
|
||||
useMemo(() => {
|
||||
const chunkMap = new Map<number, ChunkInfo>();
|
||||
const workerMap = new Map<string, WorkerInfo>();
|
||||
const errorList: ErrorEntry[] = [];
|
||||
let totalChunks = 0;
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
let retries = 0;
|
||||
let elapsed = 0;
|
||||
let throughput = 0;
|
||||
let queueSize = 0;
|
||||
let stage = "pending";
|
||||
|
||||
for (const evt of events) {
|
||||
if (evt.total_chunks) totalChunks = evt.total_chunks;
|
||||
if (evt.processed_chunks) processed = evt.processed_chunks;
|
||||
if (evt.failed_chunks) failed = evt.failed_chunks;
|
||||
if (evt.elapsed) elapsed = evt.elapsed;
|
||||
if (evt.throughput_mbps) throughput = evt.throughput_mbps;
|
||||
if (evt.queue_size !== undefined) queueSize = evt.queue_size;
|
||||
if (evt.status && evt.status !== "waiting") stage = evt.status;
|
||||
|
||||
// Track chunks
|
||||
if (evt.sequence !== undefined) {
|
||||
const existing = chunkMap.get(evt.sequence) || {
|
||||
sequence: evt.sequence,
|
||||
state: "pending" as const,
|
||||
};
|
||||
|
||||
if (evt.status === "chunking" || evt.status === "pending") {
|
||||
existing.state = "queued";
|
||||
} else if (evt.status === "processing") {
|
||||
existing.state = "processing";
|
||||
if (evt.worker_id) existing.worker_id = evt.worker_id;
|
||||
} else if (evt.status === "completed") {
|
||||
existing.state = "done";
|
||||
if (evt.processing_time)
|
||||
existing.processing_time = evt.processing_time;
|
||||
if (evt.retries) existing.retries = evt.retries;
|
||||
} else if (evt.status === "failed") {
|
||||
existing.state = "error";
|
||||
if (evt.error) existing.error = evt.error;
|
||||
}
|
||||
|
||||
if (evt.size) existing.size = evt.size;
|
||||
chunkMap.set(evt.sequence, existing);
|
||||
}
|
||||
|
||||
// Track workers
|
||||
if (evt.worker_id) {
|
||||
const w = workerMap.get(evt.worker_id) || {
|
||||
worker_id: evt.worker_id,
|
||||
state: "idle" as const,
|
||||
processed: 0,
|
||||
errors: 0,
|
||||
retries: 0,
|
||||
};
|
||||
|
||||
if (evt.state === "processing") {
|
||||
w.state = "processing";
|
||||
w.current_chunk = evt.sequence;
|
||||
} else if (evt.state === "idle") {
|
||||
w.state = "idle";
|
||||
w.current_chunk = undefined;
|
||||
} else if (evt.state === "stopped") {
|
||||
w.state = "stopped";
|
||||
}
|
||||
|
||||
if (evt.success !== undefined) {
|
||||
if (evt.success) w.processed++;
|
||||
else w.errors++;
|
||||
}
|
||||
if (evt.retries) {
|
||||
retries += evt.retries;
|
||||
w.retries += evt.retries;
|
||||
}
|
||||
|
||||
workerMap.set(evt.worker_id, w);
|
||||
}
|
||||
|
||||
// Track errors
|
||||
if (evt.error) {
|
||||
errorList.push({
|
||||
timestamp: Date.now(),
|
||||
sequence: evt.sequence,
|
||||
worker_id: evt.worker_id,
|
||||
error: evt.error,
|
||||
retries: evt.retries,
|
||||
event_type: evt.status || "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const statsObj: PipelineStats = {
|
||||
total_chunks: totalChunks,
|
||||
processed,
|
||||
failed,
|
||||
retries,
|
||||
elapsed,
|
||||
throughput_mbps: throughput,
|
||||
queue_size: queueSize,
|
||||
};
|
||||
|
||||
return {
|
||||
chunks: Array.from(chunkMap.values()).sort(
|
||||
(a, b) => a.sequence - b.sequence
|
||||
),
|
||||
workers: Array.from(workerMap.values()),
|
||||
stats: statsObj,
|
||||
errors: errorList,
|
||||
activeStage: stage,
|
||||
queueSize,
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
const handleStart = useCallback(async (config: PipelineConfig) => {
|
||||
setError(null);
|
||||
setRunning(true);
|
||||
try {
|
||||
const result = await createChunkJob(config);
|
||||
setJobId(result.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to start");
|
||||
setRunning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset running state when done
|
||||
if (done && running) {
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<h1>MPR Chunker Pipeline</h1>
|
||||
<div className="connection-status">
|
||||
{jobId && (
|
||||
<span className={`dot ${connected ? "connected" : ""}`} />
|
||||
)}
|
||||
<span className="status-text">
|
||||
{!jobId
|
||||
? "Configure and launch"
|
||||
: connected
|
||||
? "Streaming"
|
||||
: done
|
||||
? "Complete"
|
||||
: "Connecting..."}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<ConfigPanel
|
||||
onStart={handleStart}
|
||||
running={running}
|
||||
assets={assets}
|
||||
selectedAsset={selectedAsset}
|
||||
onSelectAsset={setSelectedAsset}
|
||||
onScan={handleScan}
|
||||
scanning={scanning}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<PipelineDiagram activeStage={activeStage} />
|
||||
|
||||
<div className="main-grid">
|
||||
<div className="main-left">
|
||||
<ChunkGrid chunks={chunks} totalChunks={stats.total_chunks} />
|
||||
<QueueGauge
|
||||
current={queueSize}
|
||||
max={10}
|
||||
buffered={0}
|
||||
/>
|
||||
</div>
|
||||
<div className="main-right">
|
||||
<WorkerPanel workers={workers} />
|
||||
<StatsPanel stats={stats} />
|
||||
<ErrorLog errors={errors} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
ui/chunker/src/api.ts
Normal file
72
ui/chunker/src/api.ts
Normal 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;
|
||||
}
|
||||
59
ui/chunker/src/components/ChunkGrid.tsx
Normal file
59
ui/chunker/src/components/ChunkGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
ui/chunker/src/components/ConfigPanel.tsx
Normal file
172
ui/chunker/src/components/ConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
ui/chunker/src/components/ErrorLog.tsx
Normal file
63
ui/chunker/src/components/ErrorLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
ui/chunker/src/components/PipelineDiagram.tsx
Normal file
50
ui/chunker/src/components/PipelineDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
ui/chunker/src/components/QueueGauge.tsx
Normal file
46
ui/chunker/src/components/QueueGauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
ui/chunker/src/components/StatsPanel.tsx
Normal file
59
ui/chunker/src/components/StatsPanel.tsx
Normal 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 · pytest · parametrized
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
ui/chunker/src/components/TopicBadge.tsx
Normal file
86
ui/chunker/src/components/TopicBadge.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
55
ui/chunker/src/components/WorkerPanel.tsx
Normal file
55
ui/chunker/src/components/WorkerPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
ui/chunker/src/hooks/useEventStream.ts
Normal file
81
ui/chunker/src/hooks/useEventStream.ts
Normal 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
9
ui/chunker/src/main.tsx
Normal 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
114
ui/chunker/src/types.ts
Normal 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
1
ui/chunker/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
ui/chunker/tsconfig.json
Normal file
21
ui/chunker/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
ui/chunker/tsconfig.node.json
Normal file
10
ui/chunker/tsconfig.node.json
Normal 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
21
ui/chunker/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user