Jobber/orchestrator/src/client/components/PipelineProgress.tsx
Shaheer Sarfaraz 032626bd7d
Fix #162: real-time bulk action streaming progress (#187)
* initial

* refactor: centralize SSE plumbing for client and server

* docs: add centralized SSE usage standards to agents guide

* use sse to stream actions to the client

* ui: align bulk progress toast with default sonner style

* ui: remove hide action from bulk progress toast

* full width progress bar

* fix(stream): track client disconnect and writability

* fix(stream): stop bulk loop when SSE client disconnects

* fix(stream): avoid writing error/end to closed SSE response

* fix(stream): gate started/progress frames on writable SSE socket

* types(api): narrow SSE stream payload input contract

* refactor(ui): share clamp helper for bulk progress

* fix(stream): add heartbeat to bulk action SSE route

* feat(stream): include completed count in bulk completion event

* fix(client-sse): separate parse vs handler errors and cancel reader
2026-02-18 15:54:39 +00:00

353 lines
11 KiB
TypeScript

/**
* Live pipeline progress display component.
*/
import { Loader2 } from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { subscribeToEventSource } from "@/client/lib/sse";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface PipelineProgress {
step:
| "idle"
| "crawling"
| "importing"
| "scoring"
| "processing"
| "completed"
| "cancelled"
| "failed";
message: string;
detail?: string;
crawlingSource: "gradcracker" | "jobspy" | "ukvisajobs" | "adzuna" | null;
crawlingSourcesCompleted: number;
crawlingSourcesTotal: number;
crawlingTermsProcessed: number;
crawlingTermsTotal: number;
crawlingListPagesProcessed: number;
crawlingListPagesTotal: number;
crawlingJobCardsFound: number;
crawlingJobPagesEnqueued: number;
crawlingJobPagesSkipped: number;
crawlingJobPagesProcessed: number;
crawlingPhase?: "list" | "job";
crawlingCurrentUrl?: string;
jobsDiscovered: number;
jobsScored: number;
jobsProcessed: number;
totalToProcess: number;
currentJob?: {
id: string;
title: string;
employer: string;
};
error?: string;
startedAt?: string;
completedAt?: string;
}
interface PipelineProgressProps {
isRunning: boolean;
}
const stepLabels: Record<PipelineProgress["step"], string> = {
idle: "Ready",
crawling: "Crawling",
importing: "Importing",
scoring: "Scoring",
processing: "Processing",
completed: "Complete",
cancelled: "Cancelled",
failed: "Failed",
};
const stepBadgeClasses: Record<PipelineProgress["step"], string> = {
idle: "bg-muted text-muted-foreground border-border",
crawling: "bg-sky-500/10 text-sky-400 border-sky-500/20",
importing: "bg-sky-500/10 text-sky-400 border-sky-500/20",
scoring: "bg-amber-500/10 text-amber-400 border-amber-500/20",
processing: "bg-primary/10 text-primary border-primary/20",
completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
cancelled: "bg-muted text-muted-foreground border-border",
failed: "bg-destructive/10 text-destructive border-destructive/20",
};
const sourceLabel: Record<
Exclude<PipelineProgress["crawlingSource"], null>,
string
> = {
gradcracker: "Gradcracker",
jobspy: "JobSpy",
ukvisajobs: "UKVisaJobs",
adzuna: "Adzuna",
};
const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value));
export const PipelineProgress: React.FC<PipelineProgressProps> = ({
isRunning,
}) => {
const [progress, setProgress] = useState<PipelineProgress | null>(null);
const [isConnected, setIsConnected] = useState(false);
const percentage = useMemo(() => {
if (!progress) return 0;
switch (progress.step) {
case "crawling": {
if (progress.crawlingTermsTotal > 0) {
return clamp(
5 +
(progress.crawlingTermsProcessed / progress.crawlingTermsTotal) *
10,
5,
15,
);
}
if (progress.crawlingListPagesTotal > 0) {
return clamp(
(progress.crawlingListPagesProcessed /
progress.crawlingListPagesTotal) *
15,
0,
15,
);
}
if (progress.crawlingListPagesProcessed > 0) return 8;
return 5;
}
case "importing":
return 20;
case "scoring": {
if (progress.jobsScored > 0) {
return clamp(
20 +
(progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30,
20,
50,
);
}
return 25;
}
case "processing": {
if (progress.totalToProcess > 0) {
return clamp(
50 + (progress.jobsProcessed / progress.totalToProcess) * 50,
50,
100,
);
}
return 55;
}
case "completed":
case "cancelled":
case "failed":
return 100;
default:
return 0;
}
}, [progress]);
useEffect(() => {
if (!isRunning) {
setProgress(null);
setIsConnected(false);
return;
}
const unsubscribe = subscribeToEventSource<PipelineProgress>(
"/api/pipeline/progress",
{
onOpen: () => {
setIsConnected(true);
},
onMessage: (payload) => {
setProgress(payload);
},
onError: () => {
setIsConnected(false);
},
},
);
return () => {
unsubscribe();
setIsConnected(false);
};
}, [isRunning]);
if (!isRunning && !progress) {
return null;
}
const step = progress?.step ?? "idle";
const isActive =
step !== "idle" &&
step !== "completed" &&
step !== "cancelled" &&
step !== "failed";
const listPagesText = progress
? progress.crawlingListPagesTotal > 0
? `${progress.crawlingListPagesProcessed}/${progress.crawlingListPagesTotal}`
: progress.crawlingListPagesProcessed > 0
? `${progress.crawlingListPagesProcessed}`
: "—"
: "—";
const jobPagesText = progress
? progress.crawlingJobPagesEnqueued > 0
? `${progress.crawlingJobPagesProcessed}/${progress.crawlingJobPagesEnqueued}`
: progress.crawlingJobPagesProcessed > 0
? `${progress.crawlingJobPagesProcessed}`
: "—"
: "—";
const showStats =
!!progress &&
["crawling", "scoring", "processing", "completed", "cancelled"].includes(
step,
);
return (
<Card>
<CardHeader className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<CardTitle className="text-base">Pipeline</CardTitle>
<Badge
variant="outline"
className={cn("uppercase tracking-wide", stepBadgeClasses[step])}
>
{stepLabels[step]}
</Badge>
<span className="truncate text-xs text-muted-foreground">
{isConnected ? "Live" : "Connecting…"}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{isActive && <Loader2 className="h-4 w-4 animate-spin" />}
<span className="tabular-nums">{Math.round(percentage)}%</span>
</div>
</div>
<Progress value={percentage} className="h-2" />
</CardHeader>
{progress && (
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm">{progress.message}</p>
{progress.detail && (
<p className="text-sm text-muted-foreground">{progress.detail}</p>
)}
{step === "crawling" && (
<p className="text-xs text-muted-foreground">
Source:{" "}
{progress.crawlingSource
? sourceLabel[progress.crawlingSource]
: "starting"}
{" "}({progress.crawlingSourcesCompleted}/
{Math.max(progress.crawlingSourcesTotal, 0)})
{progress.crawlingTermsTotal > 0 && (
<>
{" "}
Terms: {progress.crawlingTermsProcessed}/
{progress.crawlingTermsTotal}
</>
)}
</p>
)}
</div>
{showStats && (
<>
<Separator />
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
{step === "crawling" ? (
<>
<div>
<div className="text-xs text-muted-foreground">
List pages
</div>
<div className="tabular-nums">{listPagesText}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Job pages
</div>
<div className="tabular-nums">{jobPagesText}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Enqueued
</div>
<div className="tabular-nums">
{progress.crawlingJobPagesEnqueued}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Skipped
</div>
<div className="tabular-nums">
{progress.crawlingJobPagesSkipped}
</div>
</div>
</>
) : (
<>
<div>
<div className="text-xs text-muted-foreground">
Discovered
</div>
<div className="tabular-nums">
{progress.jobsDiscovered}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Scored
</div>
<div className="tabular-nums">{progress.jobsScored}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
Processed
</div>
<div className="tabular-nums">
{progress.totalToProcess > 0
? `${progress.jobsProcessed}/${progress.totalToProcess}`
: progress.jobsProcessed}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
To process
</div>
<div className="tabular-nums">
{progress.totalToProcess}
</div>
</div>
</>
)}
</div>
</>
)}
{step === "failed" && progress.error && (
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{progress.error}
</div>
)}
</CardContent>
)}
</Card>
);
};