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:
parent
fd48435a84
commit
c7a4d8aa27
2 changed files with 299 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue