feat: Enrich in-progress stage display and memoize pipeline page

In-progress stages now show:
- Live elapsed time (ticks every second) next to the active stage dot
- Run-level token count so far

Performance: wrapped StageTimeline, StatusFilter, WorkerStatus, and
RecentActivityFeed with React.memo. Memoized filteredVideos with useMemo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jlightner 2026-03-31 22:10:07 -05:00
parent e80094dc05
commit 9b2db11095
2 changed files with 59 additions and 17 deletions

View file

@ -3689,6 +3689,22 @@ a.app-footer__repo:hover {
.stage-timeline__step--done + .stage-timeline__step::before {
background: #00c853;
}
.stage-timeline__elapsed {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-accent);
margin-left: 6px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.stage-timeline__tokens {
font-size: 0.65rem;
color: var(--color-text-muted);
margin-left: 4px;
white-space: nowrap;
}
/* ── Recent Activity Feed ─────────────────────────────────────────────────── */

View file

@ -3,7 +3,7 @@
* expandable event log with token usage and collapsible JSON viewer.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import {
@ -45,6 +45,19 @@ function formatTokens(n: number): string {
return String(n);
}
function formatElapsed(isoStart: string | null): string {
if (!isoStart) return "";
const ms = Date.now() - new Date(isoStart).getTime();
if (ms < 0) return "";
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remainSecs = secs % 60;
if (mins < 60) return `${mins}m ${remainSecs}s`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ${mins % 60}m`;
}
const STATUS_LABELS: Record<string, string> = {
not_started: "Not Started",
queued: "Queued",
@ -343,7 +356,7 @@ function EventLog({ videoId, status, runId }: { videoId: string; status: string;
// ── Worker Status ────────────────────────────────────────────────────────────
function WorkerStatus() {
const WorkerStatus = React.memo(function WorkerStatus() {
const [status, setStatus] = useState<WorkerStatusResponse | null>(null);
const [error, setError] = useState<string | null>(null);
@ -397,7 +410,7 @@ function WorkerStatus() {
))}
</div>
);
}
});
// ── Debug Mode Toggle ────────────────────────────────────────────────────────
@ -443,7 +456,7 @@ function DebugModeToggle({
// ── Status Filter ────────────────────────────────────────────────────────────
function StatusFilter({
const StatusFilter = React.memo(function StatusFilter({
videos,
activeFilter,
onFilterChange,
@ -483,7 +496,7 @@ function StatusFilter({
})}
</div>
);
}
});
// ── Run List ─────────────────────────────────────────────────────────────────
@ -614,15 +627,23 @@ const PIPELINE_STAGES = [
{ key: "stage6_embed", label: "Embed" },
];
function StageTimeline({ video }: { video: PipelineVideoItem }) {
if (video.processing_status !== "processing" && video.processing_status !== "complete" && video.processing_status !== "error") {
const StageTimeline = React.memo(function StageTimeline({ video }: { video: PipelineVideoItem }) {
const [, setTick] = useState(0);
const isProcessing = video.processing_status === "processing";
// Tick every second while processing to update elapsed time
useEffect(() => {
if (!isProcessing) return;
const id = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(id);
}, [isProcessing]);
if (!isProcessing && video.processing_status !== "complete" && video.processing_status !== "error") {
return null;
}
const activeStage = video.active_stage;
const activeStatus = video.active_stage_status; // "start" = running, "complete" = done, "error" = failed
// Determine each stage's state
const activeStatus = video.active_stage_status;
const activeIdx = PIPELINE_STAGES.findIndex((s) => s.key === activeStage);
return (
@ -638,7 +659,6 @@ function StageTimeline({ video }: { video: PipelineVideoItem }) {
else if (activeStatus === "error") stateClass = "stage-timeline__step--error";
}
}
// If video is complete, all stages are done
if (video.processing_status === "complete") {
stateClass = "stage-timeline__step--done";
}
@ -650,13 +670,19 @@ function StageTimeline({ video }: { video: PipelineVideoItem }) {
</div>
);
})}
{isProcessing && activeStatus === "start" && (
<span className="stage-timeline__elapsed">{formatElapsed(video.stage_started_at)}</span>
)}
{isProcessing && video.latest_run && video.latest_run.total_tokens > 0 && (
<span className="stage-timeline__tokens">{formatTokens(video.latest_run.total_tokens)} tok</span>
)}
</div>
);
}
})
// ── Recent Activity Feed ─────────────────────────────────────────────────────
function RecentActivityFeed() {
const RecentActivityFeed = React.memo(function RecentActivityFeed() {
const [items, setItems] = useState<RecentActivityItem[]>([]);
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
@ -718,7 +744,7 @@ function RecentActivityFeed() {
)}
</div>
);
}
});
// ── Main Page ────────────────────────────────────────────────────────────────
@ -760,8 +786,8 @@ export default function AdminPipeline() {
const videoRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const deepLinked = useRef(false);
// Compute filtered list (status + creator)
const filteredVideos = videos.filter((v) => {
// Compute filtered list (status + creator) — memoized to avoid recomputing on unrelated state changes
const filteredVideos = useMemo(() => videos.filter((v) => {
if (activeFilter !== null && v.processing_status !== activeFilter) return false;
if (creatorFilter !== null && v.creator_name !== creatorFilter) return false;
if (searchQuery) {
@ -769,7 +795,7 @@ export default function AdminPipeline() {
if (!v.filename.toLowerCase().includes(q) && !v.creator_name.toLowerCase().includes(q)) return false;
}
return true;
});
}), [videos, activeFilter, creatorFilter, searchQuery]);
const load = useCallback(async (isAutoRefresh = false) => {
if (!isAutoRefresh) setLoading(true);