172 lines
5.7 KiB
TypeScript
172 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useJobProgress } from "@/hooks/use-job-progress";
|
|
import type { StageStatus, RoutingDetail as RoutingDetailType } from "@/hooks/use-job-progress";
|
|
|
|
function StageIcon({ status }: { status: StageStatus["status"] }) {
|
|
if (status === "completed")
|
|
return (
|
|
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-green-100 text-green-600 text-xs shrink-0">
|
|
✓
|
|
</span>
|
|
);
|
|
if (status === "failed")
|
|
return (
|
|
<span className="w-5 h-5 flex items-center justify-center rounded-full bg-red-100 text-red-600 text-xs shrink-0">
|
|
✗
|
|
</span>
|
|
);
|
|
if (status === "active")
|
|
return (
|
|
<span className="w-5 h-5 flex items-center justify-center shrink-0">
|
|
<svg className="animate-spin w-4 h-4 text-brand-600" viewBox="0 0 24 24" fill="none">
|
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
</span>
|
|
);
|
|
return <span className="w-5 h-5 rounded-full border-2 border-gray-300 shrink-0" />;
|
|
}
|
|
|
|
function StageRow({ stage, error }: { stage: StageStatus; error?: string }) {
|
|
return (
|
|
<div className="flex items-start gap-3">
|
|
<StageIcon status={stage.status} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className={`text-sm font-medium ${stage.status === "pending" ? "text-gray-400" : "text-gray-900"}`}>
|
|
{stage.label}
|
|
</p>
|
|
{stage.status === "active" && (
|
|
<>
|
|
<div className="w-full bg-gray-200 rounded-full h-1 mt-1.5">
|
|
<div
|
|
className="bg-brand-600 h-1 rounded-full transition-all duration-500"
|
|
style={{ width: `${stage.pct}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-0.5 truncate">{stage.message}</p>
|
|
</>
|
|
)}
|
|
{stage.status === "failed" && error && (
|
|
<p className="text-xs text-red-600 mt-0.5">{error}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RoutingGrid({ routingDetail }: { routingDetail: RoutingDetailType }) {
|
|
const MODE_LABELS: Record<string, string> = {
|
|
walking: "Walking",
|
|
cycling: "Cycling",
|
|
driving: "Driving",
|
|
transit: "Transit",
|
|
};
|
|
const entries = Object.entries(routingDetail);
|
|
if (entries.length === 0) return null;
|
|
return (
|
|
<div className="mt-2 space-y-1.5 pl-8">
|
|
{entries.map(([mode, { done, total }]) => (
|
|
<div key={mode} className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-500 w-14 shrink-0">{MODE_LABELS[mode] ?? mode}</span>
|
|
<div className="flex-1 bg-gray-200 rounded-full h-1.5">
|
|
<div
|
|
className="bg-brand-500 h-1.5 rounded-full transition-all duration-500"
|
|
style={{ width: total > 0 ? `${(done / total) * 100}%` : done > 0 ? "100%" : "0%" }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-gray-400 w-10 text-right shrink-0">
|
|
{total > 1 ? `${done}/${total}` : done >= 1 ? "done" : "…"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CityIngestProgress({
|
|
jobId,
|
|
className = "card max-w-lg",
|
|
}: {
|
|
jobId: string | null;
|
|
className?: string;
|
|
}) {
|
|
const { stages, overall, error, routingDetail } = useJobProgress(jobId);
|
|
|
|
if (!jobId) return null;
|
|
|
|
type StageGroup =
|
|
| { kind: "single"; stage: StageStatus }
|
|
| { kind: "parallel"; stages: StageStatus[] };
|
|
|
|
const groups: StageGroup[] = [];
|
|
for (const stage of stages) {
|
|
if (stage.parallelGroup) {
|
|
const last = groups[groups.length - 1];
|
|
if (last?.kind === "parallel" && last.stages[0].parallelGroup === stage.parallelGroup) {
|
|
last.stages.push(stage);
|
|
} else {
|
|
groups.push({ kind: "parallel", stages: [stage] });
|
|
}
|
|
} else {
|
|
groups.push({ kind: "single", stage });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={className}>
|
|
<h2 className="text-lg font-semibold mb-6">Processing City Data</h2>
|
|
|
|
<div className="space-y-4">
|
|
{groups.map((group, gi) =>
|
|
group.kind === "single" ? (
|
|
<div key={group.stage.key}>
|
|
<StageRow stage={group.stage} error={error} />
|
|
{group.stage.key === "Computing scores" &&
|
|
group.stage.status === "active" &&
|
|
routingDetail && (
|
|
<RoutingGrid routingDetail={routingDetail} />
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div
|
|
key={`group-${gi}`}
|
|
className="rounded-lg border border-gray-200 bg-gray-50 p-3 space-y-3"
|
|
>
|
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
|
Running in parallel
|
|
</p>
|
|
{group.stages.map((s) => (
|
|
<StageRow key={s.key} stage={s} error={error} />
|
|
))}
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
|
|
{overall === "completed" && (
|
|
<div className="mt-6 p-4 bg-green-50 rounded-lg text-green-800 text-sm">
|
|
✓ City ingestion complete!{" "}
|
|
<a href="/admin" className="underline font-medium">
|
|
Return to dashboard
|
|
</a>{" "}
|
|
or{" "}
|
|
<a href="/" className="underline font-medium">
|
|
view on map
|
|
</a>
|
|
.
|
|
</div>
|
|
)}
|
|
|
|
{overall === "failed" && (
|
|
<div className="mt-6 p-4 bg-red-50 rounded-lg text-red-800 text-sm">
|
|
✗ Ingestion failed: {error}{" "}
|
|
<a href="/admin" className="underline">
|
|
Return to dashboard
|
|
</a>
|
|
.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|