diff --git a/frontend/src/App.css b/frontend/src/App.css index 147cbdc..fcb0c8c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3849,6 +3849,128 @@ a.app-footer__repo:hover { animation: statusChange 2s ease-out; } +/* ── Stage Tabs (expanded run detail) ─────────────────────────────────────── */ + +.stage-tabs { + padding: 0.5rem 0; +} + +.stage-tabs__bar { + display: flex; + gap: 2px; + border-bottom: 2px solid var(--color-border); + margin-bottom: 0; +} + +.stage-tabs__tab { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem 0.625rem; + border: none; + background: transparent; + color: var(--color-text-muted); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + position: relative; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 150ms ease, border-color 150ms ease; +} + +.stage-tabs__tab:hover:not(:disabled) { + color: var(--color-text-primary); +} + +.stage-tabs__tab:disabled { + opacity: 0.35; + cursor: default; +} + +.stage-tabs__tab--active { + color: var(--color-text-primary); + border-bottom-color: var(--color-accent); +} + +.stage-tabs__indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.stage-tabs__tab--idle .stage-tabs__indicator { + background: var(--color-border); +} + +.stage-tabs__tab--running .stage-tabs__indicator { + background: var(--color-accent); + animation: stagePulse 1.5s ease-in-out infinite; +} + +.stage-tabs__tab--done .stage-tabs__indicator { + background: var(--color-badge-approved-text); +} + +.stage-tabs__tab--error .stage-tabs__indicator { + background: var(--color-badge-rejected-text); +} + +@keyframes stagePulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.stage-tabs__label { + white-space: nowrap; +} + +.stage-tabs__panel { + padding: 0.75rem 0; +} + +.stage-tabs__panel--empty { + color: var(--color-text-muted); + font-size: 0.8rem; + padding: 1.5rem 0; + text-align: center; +} + +.stage-tabs__summary { + display: flex; + gap: 1rem; + padding: 0.5rem 0 0.75rem; + border-bottom: 1px solid rgba(42, 42, 56, 0.5); + margin-bottom: 0.75rem; +} + +.stage-tabs__stat { + color: var(--color-text-muted); + font-size: 0.75rem; + font-weight: 500; +} + +.stage-tabs__events { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.stage-tabs__error-detail { + margin: 0.375rem 0 0 1.5rem; + padding: 0.5rem 0.75rem; + background: var(--color-badge-rejected-bg); + color: var(--color-badge-rejected-text); + border-radius: 4px; + font-size: 0.75rem; + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + /* ── Stage Timeline ───────────────────────────────────────────────────────── */ .stage-timeline { diff --git a/frontend/src/pages/AdminPipeline.tsx b/frontend/src/pages/AdminPipeline.tsx index 6ecf240..da9805f 100644 --- a/frontend/src/pages/AdminPipeline.tsx +++ b/frontend/src/pages/AdminPipeline.tsx @@ -512,6 +512,180 @@ const StatusFilter = React.memo(function StatusFilter({ ); }); +// ── Stage Tab View ─────────────────────────────────────────────────────────── + +const STAGE_TAB_ORDER = [ + { key: "stage2_segmentation", label: "Segment", short: "S2" }, + { key: "stage3_extraction", label: "Extract", short: "S3" }, + { key: "stage4_classification", label: "Classify", short: "S4" }, + { key: "stage5_synthesis", label: "Synthesize", short: "S5" }, + { key: "stage6_embed_and_index", label: "Embed", short: "S6" }, +]; + +function stageTabStatus(events: PipelineEvent[]): "idle" | "running" | "done" | "error" { + if (events.length === 0) return "idle"; + const hasError = events.some((e) => e.event_type === "error"); + if (hasError) return "error"; + const hasComplete = events.some((e) => e.event_type === "complete"); + if (hasComplete) return "done"; + const hasStart = events.some((e) => e.event_type === "start"); + if (hasStart) return "running"; + return "running"; // llm_call without start — treat as running +} + +function StageTabView({ videoId, runId, status }: { videoId: string; runId: string; status: string }) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState(null); + + const load = useCallback(async (silent = false) => { + if (!silent) setLoading(true); + try { + const res = await fetchPipelineEvents(videoId, { + offset: 0, + limit: 500, + order: "asc", + run_id: runId, + }); + setEvents(res.items); + // Auto-select the most interesting tab: latest active or error stage + if (!silent && activeTab === null && res.items.length > 0) { + const stages = new Set(res.items.map((e) => e.stage)); + const errorStage = res.items.find((e) => e.event_type === "error")?.stage; + if (errorStage) { + setActiveTab(errorStage); + } else { + // Pick the latest stage that has events + const lastStage = [...STAGE_TAB_ORDER].reverse().find((s) => stages.has(s.key)); + if (lastStage) setActiveTab(lastStage.key); + } + } + } catch { + // silent + } finally { + if (!silent) setLoading(false); + } + }, [videoId, runId, activeTab]); + + useEffect(() => { void load(); }, [load]); + + // Auto-refresh while processing + useEffect(() => { + if (status !== "running") return; + const id = setInterval(() => void load(true), 8_000); + return () => clearInterval(id); + }, [status, load]); + + if (loading) return
Loading stages…
; + + // Group events by stage + const byStage = new Map(); + for (const evt of events) { + const list = byStage.get(evt.stage) ?? []; + list.push(evt); + byStage.set(evt.stage, list); + } + + const tabEvents = activeTab ? (byStage.get(activeTab) ?? []) : []; + + // Compute summary stats for active tab + const tabTokens = tabEvents.reduce((sum, e) => sum + (e.total_tokens ?? 0), 0); + const tabLlmCalls = tabEvents.filter((e) => e.event_type === "llm_call").length; + const tabDuration = (() => { + const starts = tabEvents.filter((e) => e.event_type === "start"); + const ends = tabEvents.filter((e) => e.event_type === "complete" || e.event_type === "error"); + if (starts.length > 0 && ends.length > 0) { + const endEvt = ends[ends.length - 1]; + const startEvt = starts[0]; + if (endEvt && startEvt && endEvt.created_at && startEvt.created_at) { + const ms = new Date(endEvt.created_at).getTime() - new Date(startEvt.created_at).getTime(); + if (ms > 0) return formatElapsed(startEvt.created_at); + } + } + return null; + })(); + + return ( +
+
+ {STAGE_TAB_ORDER.map((stage) => { + const stageEvents = byStage.get(stage.key) ?? []; + const tabStatus = stageTabStatus(stageEvents); + const isActive = activeTab === stage.key; + return ( + + ); + })} +
+ + {activeTab && tabEvents.length > 0 && ( +
+
+ {tabLlmCalls > 0 && ( + {tabLlmCalls} LLM call{tabLlmCalls !== 1 ? "s" : ""} + )} + {tabTokens > 0 && ( + {formatTokens(tabTokens)} tokens + )} + {tabDuration && ( + {tabDuration} + )} +
+
+ {tabEvents.map((evt) => ( +
+
+ {eventTypeIcon(evt.event_type)} + + {evt.event_type} + + {typeof evt.payload?.context === "string" && ( + + {evt.payload.context} + + )} + {evt.model && {evt.model}} + {evt.total_tokens != null && evt.total_tokens > 0 && ( + + {formatTokens(evt.total_tokens)} tok + + )} + {evt.duration_ms != null && ( + {evt.duration_ms}ms + )} + {formatDate(evt.created_at)} +
+ {evt.event_type === "error" && evt.payload?.error != null && ( +
+ {`${evt.payload.error}`.slice(0, 500)} +
+ )} + + +
+ ))} +
+
+ )} + + {activeTab && tabEvents.length === 0 && ( +
+ Stage not started yet. +
+ )} +
+ ); +} + // ── Run List ───────────────────────────────────────────────────────────────── const TRIGGER_LABELS: Record = { @@ -601,7 +775,7 @@ function RunList({ videoId, videoStatus }: { videoId: string; videoStatus: strin {isExpanded && (
- +
)} @@ -1205,9 +1379,9 @@ export default function AdminPipeline() { )}