feat: Stage tab view for pipeline runs, rename stale→orphaned pages

- Expanded runs now show horizontal stage tabs (Segment→Extract→Classify→Synthesize→Embed)
- Each tab has status indicator dot (idle/running/done/error) with pulse animation
- Clicking a tab shows that stage's events with summary stats (LLM calls, tokens, duration)
- Error events auto-expanded with monospace error detail block
- Auto-selects the error stage or latest active stage on expand
- Renamed 'stale pages' to 'orphaned pages' in admin header
This commit is contained in:
jlightner 2026-04-03 03:24:43 +00:00
parent fd48435a84
commit c7a4d8aa27
2 changed files with 299 additions and 3 deletions

View file

@ -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 {

View file

@ -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<PipelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<string | null>(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 <div className="loading">Loading stages</div>;
// Group events by stage
const byStage = new Map<string, PipelineEvent[]>();
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 (
<div className="stage-tabs">
<div className="stage-tabs__bar">
{STAGE_TAB_ORDER.map((stage) => {
const stageEvents = byStage.get(stage.key) ?? [];
const tabStatus = stageTabStatus(stageEvents);
const isActive = activeTab === stage.key;
return (
<button
key={stage.key}
className={`stage-tabs__tab stage-tabs__tab--${tabStatus}${isActive ? " stage-tabs__tab--active" : ""}`}
onClick={() => setActiveTab(stage.key)}
disabled={tabStatus === "idle"}
title={stageEvents.length > 0 ? `${stageEvents.length} events` : "Not started"}
>
<span className="stage-tabs__indicator" />
<span className="stage-tabs__label">{stage.label}</span>
</button>
);
})}
</div>
{activeTab && tabEvents.length > 0 && (
<div className="stage-tabs__panel">
<div className="stage-tabs__summary">
{tabLlmCalls > 0 && (
<span className="stage-tabs__stat">{tabLlmCalls} LLM call{tabLlmCalls !== 1 ? "s" : ""}</span>
)}
{tabTokens > 0 && (
<span className="stage-tabs__stat">{formatTokens(tabTokens)} tokens</span>
)}
{tabDuration && (
<span className="stage-tabs__stat">{tabDuration}</span>
)}
</div>
<div className="stage-tabs__events">
{tabEvents.map((evt) => (
<div key={evt.id} className={`pipeline-event pipeline-event--${evt.event_type}`}>
<div className="pipeline-event__row">
<span className="pipeline-event__icon">{eventTypeIcon(evt.event_type)}</span>
<span className={`pipeline-badge pipeline-badge--event-${evt.event_type}`}>
{evt.event_type}
</span>
{typeof evt.payload?.context === "string" && (
<span className="pipeline-event__context" title="Processing context">
{evt.payload.context}
</span>
)}
{evt.model && <span className="pipeline-event__model">{evt.model}</span>}
{evt.total_tokens != null && evt.total_tokens > 0 && (
<span className="pipeline-event__tokens" title={`prompt: ${evt.prompt_tokens ?? 0} / completion: ${evt.completion_tokens ?? 0}`}>
{formatTokens(evt.total_tokens)} tok
</span>
)}
{evt.duration_ms != null && (
<span className="pipeline-event__duration">{evt.duration_ms}ms</span>
)}
<span className="pipeline-event__time">{formatDate(evt.created_at)}</span>
</div>
{evt.event_type === "error" && evt.payload?.error != null && (
<div className="stage-tabs__error-detail">
{`${evt.payload.error}`.slice(0, 500)}
</div>
)}
<JsonViewer data={evt.payload} />
<DebugPayloadViewer event={evt} />
</div>
))}
</div>
</div>
)}
{activeTab && tabEvents.length === 0 && (
<div className="stage-tabs__panel stage-tabs__panel--empty">
Stage not started yet.
</div>
)}
</div>
);
}
// ── Run List ─────────────────────────────────────────────────────────────────
const TRIGGER_LABELS: Record<string, string> = {
@ -601,7 +775,7 @@ function RunList({ videoId, videoStatus }: { videoId: string; videoStatus: strin
</button>
{isExpanded && (
<div className="run-card__body">
<EventLog videoId={videoId} status={run.status} runId={run.id} />
<StageTabView videoId={videoId} runId={run.id} status={run.status} />
</div>
)}
</div>
@ -1205,9 +1379,9 @@ export default function AdminPipeline() {
<button
className="btn btn--small btn--warning"
onClick={() => void handleBulkResynth()}
title={`${stalePagesCount} pages synthesized with an older prompt`}
title={`${stalePagesCount} pages synthesized with an older prompt version`}
>
{stalePagesCount} stale pages
{stalePagesCount} orphaned pages
</button>
)}
<button