Report detail: show elapsed time + live activity for running stages
Stage 2 hashtag scrapes can take 1-3 min each, and the run page gave no sign of life between cost events — the active stage just had a pulsing dot. Now shows the elapsed run time in the header, a 'working…' marker on the active stage, the latest cost event under it (e.g. 'apify · hashtag:#hairtok · 30s ago'), and a hint that long Apify silences are mid-flight scrapes, not stalls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
564b6d9274
commit
fa4b356af7
1 changed files with 60 additions and 7 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
useReport, useQaSignoff, useBuildReport, useRetryReport,
|
||||
|
|
@ -50,9 +50,40 @@ function fmtTime(iso: string | null): string {
|
|||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
if (m < 60) return `${m}m ${rem}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ago`;
|
||||
}
|
||||
|
||||
function useTick(intervalMs = 1000) {
|
||||
const [, setT] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setT((n) => n + 1), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
}
|
||||
|
||||
export default function ReportDetail() {
|
||||
const { id } = useParams();
|
||||
const { data, isLoading, error } = useReport(id);
|
||||
// Tick the wall clock so elapsed-time + last-activity-ago refresh while running.
|
||||
// Must run unconditionally before any early returns (Rules of Hooks).
|
||||
useTick(1000);
|
||||
|
||||
if (isLoading) return <div className="text-text-muted text-sm">Loading…</div>;
|
||||
if (error || !data) return <div className="text-red-400 text-sm">Could not load report.</div>;
|
||||
|
|
@ -61,6 +92,9 @@ export default function ReportDetail() {
|
|||
const isTerminal = TERMINAL_STATUSES.includes(report.status);
|
||||
const showSignoffPanel = report.status === 'qa' || (qa && (qa.cm_signoff || qa.strategist_signoff));
|
||||
|
||||
const elapsedMs = (report.finished_at ? new Date(report.finished_at).getTime() : Date.now())
|
||||
- new Date(report.started_at).getTime();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
|
|
@ -70,7 +104,8 @@ export default function ReportDetail() {
|
|||
<div className="text-sm text-text-muted mt-1">{report.brief_business_question}</div>
|
||||
<div className="text-xs text-text-dim mt-2">
|
||||
Run <span className="font-mono">{report.id.slice(0, 8)}</span> ·
|
||||
started {fmtTime(report.started_at)}
|
||||
started {fmtTime(report.started_at)} ·
|
||||
{' '}{isTerminal ? 'ran for' : 'running for'} <span className="text-text-body">{fmtDuration(elapsedMs)}</span>
|
||||
{report.finished_at && <> · finished {fmtTime(report.finished_at)}</>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,7 +113,7 @@ export default function ReportDetail() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<StageProgress report={report} />
|
||||
<StageProgress report={report} latestEvent={cost_events.length > 0 ? (cost_events[cost_events.length - 1] ?? null) : null} />
|
||||
|
||||
{report.error_message && <FailurePanel reportId={report.id} error={report.error_message} />}
|
||||
|
||||
|
|
@ -101,7 +136,8 @@ export default function ReportDetail() {
|
|||
);
|
||||
}
|
||||
|
||||
function StageProgress({ report }: { report: Report }) {
|
||||
function StageProgress({ report, latestEvent }: { report: Report; latestEvent: CostEvent | null }) {
|
||||
const isRunning = !TERMINAL_STATUSES.includes(report.status);
|
||||
return (
|
||||
<section className="bg-bg-panel border border-border-subtle rounded-lg p-4">
|
||||
<h2 className="text-sm font-medium mb-3 text-text-muted uppercase tracking-wider">Pipeline progress</h2>
|
||||
|
|
@ -123,14 +159,31 @@ function StageProgress({ report }: { report: Report }) {
|
|||
state === 'done' ? 'text-text-body' :
|
||||
state === 'current' ? 'text-accent' :
|
||||
'text-text-dim';
|
||||
const showActivity = state === 'current' && isRunning && latestEvent && latestEvent.stage === stageNum;
|
||||
return (
|
||||
<li key={stage} className="flex items-center gap-3 text-sm">
|
||||
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${dot}`} />
|
||||
<span className={text}>{stageNum}. {STAGE_LABELS[stage]}</span>
|
||||
<li key={stage} className="text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${dot}`} />
|
||||
<span className={text}>{stageNum}. {STAGE_LABELS[stage]}</span>
|
||||
{state === 'current' && isRunning && (
|
||||
<span className="text-xs text-text-dim ml-auto">working…</span>
|
||||
)}
|
||||
</div>
|
||||
{showActivity && latestEvent && (
|
||||
<div className="ml-5 mt-0.5 text-xs text-text-dim font-mono truncate">
|
||||
↳ {latestEvent.source} · {latestEvent.label} · {fmtAgo(latestEvent.created_at)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{isRunning && (
|
||||
<div className="mt-3 pt-3 border-t border-border-subtle text-xs text-text-dim">
|
||||
Stages 2 + 4 (Apify scrapes) can take 1-3 minutes per hashtag/profile/video. Each finished scrape
|
||||
appears below as a cost event — silence in the log without an error means a scrape is mid-flight.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue