LeLab / src /components /ui /PortDetectionModal.tsx
GitHub CI
Sync from leLab @ 8420275bdc324fa9e71046ce66c3ea3dd59e60e2
018306c
import React, { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Loader2, CheckCircle, AlertCircle } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { useApi } from "@/contexts/ApiContext";
interface PortDetectionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
robotType: "leader" | "follower";
onPortDetected: (port: string) => void;
}
const SUCCESS_HOLD_MS = 2000;
const PortDetectionModal: React.FC<PortDetectionModalProps> = ({
open,
onOpenChange,
robotType,
onPortDetected,
}) => {
const [step, setStep] = useState<"detecting" | "success" | "error">(
"detecting"
);
const [detectedPort, setDetectedPort] = useState<string>("");
const [error, setError] = useState<string>("");
const cancelledRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const successTimerRef = useRef<number | null>(null);
const { toast } = useToast();
const { baseUrl, fetchWithHeaders } = useApi();
const runDetection = async () => {
try {
abortRef.current = new AbortController();
const startResponse = await fetchWithHeaders(
`${baseUrl}/start-port-detection`,
{
method: "POST",
body: JSON.stringify({ robot_type: robotType }),
signal: abortRef.current.signal,
}
);
const startData = await startResponse.json();
if (cancelledRef.current) return;
if (startData.status !== "success") {
throw new Error(startData.message || "Failed to start port detection");
}
const portsBefore: string[] = startData.data.ports_before;
// Poll the backend in a loop. Each call waits up to 15s for an unplug;
// we silently retry on timeout so the user has unlimited time to read
// and act. The loop ends on success, abort, or a non-timeout failure.
while (!cancelledRef.current) {
abortRef.current = new AbortController();
const response = await fetchWithHeaders(
`${baseUrl}/detect-port-after-disconnect`,
{
method: "POST",
body: JSON.stringify({ ports_before: portsBefore }),
signal: abortRef.current.signal,
}
);
const data = await response.json();
if (cancelledRef.current) return;
if (data.status === "success") {
setDetectedPort(data.port);
await savePort(data.port);
if (cancelledRef.current) return;
setStep("success");
toast({
title: "Port Detected Successfully",
description: `${robotType} port detected: ${data.port}`,
});
successTimerRef.current = window.setTimeout(() => {
if (cancelledRef.current) return;
onPortDetected(data.port);
onOpenChange(false);
}, SUCCESS_HOLD_MS);
return;
}
const message =
typeof data.message === "string" ? data.message : "";
if (message.includes("Timed out")) continue;
throw new Error(message || "Failed to detect port");
}
} catch (e) {
if (cancelledRef.current) return;
if (e instanceof DOMException && e.name === "AbortError") return;
console.error("Port detection failed:", e);
setError(e instanceof Error ? e.message : "Unknown error");
setStep("error");
}
};
const savePort = async (port: string) => {
try {
await fetchWithHeaders(`${baseUrl}/save-robot-port`, {
method: "POST",
body: JSON.stringify({ robot_type: robotType, port }),
});
} catch (e) {
console.error("Error saving port:", e);
}
};
useEffect(() => {
if (!open) return;
cancelledRef.current = false;
setStep("detecting");
setError("");
setDetectedPort("");
runDetection();
return () => {
cancelledRef.current = true;
abortRef.current?.abort();
if (successTimerRef.current !== null) {
window.clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleCancel = () => {
onOpenChange(false);
};
const handleRetry = () => {
cancelledRef.current = false;
abortRef.current?.abort();
setStep("detecting");
setError("");
setDetectedPort("");
runDetection();
};
const renderStepContent = () => {
switch (step) {
case "detecting":
return (
<div className="space-y-6 text-center">
<Loader2 className="w-16 h-16 text-blue-500 mx-auto animate-spin" />
<div className="space-y-2">
<h3 className="text-lg font-semibold text-white">
Unplug the {robotType} arm
</h3>
<p className="text-gray-400">
Disconnect the {robotType} robot arm from USB. The port will be
detected automatically.
</p>
</div>
<div className="flex justify-center">
<Button
onClick={handleCancel}
variant="outline"
className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white px-8 py-2"
>
Cancel
</Button>
</div>
</div>
);
case "success":
return (
<div className="space-y-6 text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
<div className="space-y-2">
<h3 className="text-lg font-semibold text-white">
Port Detected
</h3>
<p className="text-xl font-mono text-green-400 bg-gray-800 px-4 py-2 rounded inline-block">
{detectedPort}
</p>
</div>
</div>
);
case "error":
return (
<div className="space-y-6 text-center">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto" />
<div className="space-y-2">
<h3 className="text-lg font-semibold text-white">
Detection Failed
</h3>
<div className="bg-red-900/20 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm">{error}</p>
</div>
</div>
<div className="flex gap-4 justify-center">
<Button
onClick={handleRetry}
className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-2"
>
Try Again
</Button>
<Button
onClick={handleCancel}
variant="outline"
className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white px-8 py-2"
>
Cancel
</Button>
</div>
</div>
);
default:
return null;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[500px] p-8">
<DialogHeader>
<DialogTitle className="text-white text-center text-xl font-bold">
Port Detection
</DialogTitle>
<DialogDescription className="text-gray-400 text-center">
Detect the USB port for your {robotType} arm
</DialogDescription>
</DialogHeader>
<div className="py-4">{renderStepContent()}</div>
</DialogContent>
</Dialog>
);
};
export default PortDetectionModal;