consolidate probe dialog

This commit is contained in:
Josh Hawkins 2025-11-10 12:33:07 -06:00
parent 5c41107fcd
commit c54ada65dc
4 changed files with 386 additions and 401 deletions

View File

@ -217,6 +217,7 @@
"presets": "Presets", "presets": "Presets",
"deviceInfo": "Device Information", "deviceInfo": "Device Information",
"rtspCandidates": "RTSP Candidates", "rtspCandidates": "RTSP Candidates",
"noRtspCandidates": "No RTSP URLs were found from the camera. Your credentials may be incorrect, or the camera may not support ONVIF or the method used to retrieve RTSP URLs. Go back and enter the RTSP URL manually.",
"candidateStreamTitle": "Candidate {{number}}", "candidateStreamTitle": "Candidate {{number}}",
"useCandidate": "Use", "useCandidate": "Use",
"uriCopy": "Copy", "uriCopy": "Copy",
@ -265,6 +266,7 @@
"autotrackingSupport": "Autotracking Support", "autotrackingSupport": "Autotracking Support",
"presets": "Presets", "presets": "Presets",
"rtspCandidates": "RTSP Candidates", "rtspCandidates": "RTSP Candidates",
"rtspCandidatesDescription": "The following RTSP URLs were found from the camera probe. Select one or more to use for this camera in Frigate.",
"candidateStreamTitle": "Candidate {{number}}", "candidateStreamTitle": "Candidate {{number}}",
"useCandidate": "Use", "useCandidate": "Use",
"uriCopy": "Copy", "uriCopy": "Copy",

View File

@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
// Input removed: URL shown as text
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FaCopy, FaCheck } from "react-icons/fa"; import { FaCopy, FaCheck } from "react-icons/fa";
import { LuX } from "react-icons/lu"; import { LuX } from "react-icons/lu";
@ -96,21 +95,32 @@ export default function OnvifProbeResults({
); );
} }
const rtspCandidates = probeResult.rtsp_candidates || []; const rtspCandidates = (probeResult.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
);
if (probeResult?.success && rtspCandidates.length === 0) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Probe success header (green check + text) */} <Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("cameraWizard.step2.noRtspCandidates")}</AlertTitle>
</Alert>
</div>
);
}
return (
<>
<div className="space-y-2">
{probeResult?.success && ( {probeResult?.success && (
<div className="mb-3 flex flex-row items-center gap-2 text-sm text-success"> <div className="mb-3 flex flex-row items-center gap-2 text-sm text-success">
<FaCircleCheck className="size-4" /> <FaCircleCheck className="size-4" />
<span>{t("cameraWizard.step2.probeSuccessful")}</span> <span>{t("cameraWizard.step2.probeSuccessful")}</span>
</div> </div>
)} )}
<div className="text-sm">{t("cameraWizard.step2.deviceInfo")}</div>
<Card> <Card>
<CardTitle className="border-b p-4 text-sm">
{t("cameraWizard.step2.deviceInfo")}
</CardTitle>
<CardContent className="space-y-2 p-4 text-sm"> <CardContent className="space-y-2 p-4 text-sm">
{probeResult.manufacturer && ( {probeResult.manufacturer && (
<div> <div>
@ -127,7 +137,9 @@ export default function OnvifProbeResults({
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("cameraWizard.step2.model")}: {t("cameraWizard.step2.model")}:
</span>{" "} </span>{" "}
<span className="text-primary-variant">{probeResult.model}</span> <span className="text-primary-variant">
{probeResult.model}
</span>
</div> </div>
)} )}
{probeResult.firmware_version && ( {probeResult.firmware_version && (
@ -185,10 +197,16 @@ export default function OnvifProbeResults({
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
<div className="space-y-2">
{rtspCandidates.length > 0 && ( {rtspCandidates.length > 0 && (
<div className="space-y-4"> <div className="mt-5 space-y-2">
<h3 className="text-sm">{t("cameraWizard.step2.rtspCandidates")}</h3> <div className="text-sm">
{t("cameraWizard.step2.rtspCandidates")}
</div>
<div className="text-sm text-muted-foreground">
{t("cameraWizard.step2.rtspCandidatesDescription")}
</div>
<div className="space-y-2"> <div className="space-y-2">
{rtspCandidates.map((candidate, idx) => { {rtspCandidates.map((candidate, idx) => {
@ -215,18 +233,17 @@ export default function OnvifProbeResults({
</div> </div>
)} )}
</div> </div>
</>
); );
} }
type CandidateItemProps = { type CandidateItemProps = {
candidate: OnvifRtspCandidate; candidate: OnvifRtspCandidate;
index?: number; index?: number;
// isTested?: boolean; (unused)
copiedUri: string | null; copiedUri: string | null;
onCopy: () => void; onCopy: () => void;
onUse: () => void; onUse: () => void;
isSelected?: boolean; isSelected?: boolean;
// onTest?: () => void; (unused)
testCandidate?: (uri: string) => void; testCandidate?: (uri: string) => void;
candidateTest?: TestResult | { success: false; error: string }; candidateTest?: TestResult | { success: false; error: string };
isTesting?: boolean; isTesting?: boolean;
@ -253,14 +270,14 @@ function CandidateItem({
}; };
return ( return (
<div <Card
className={cn( className={cn(
"rounded-lg bg-card",
isSelected && isSelected &&
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200", "outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
)} )}
> >
<div className="flex flex-col space-y-4 p-4"> <CardContent className="p-4">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium"> <h4 className="font-medium">
@ -334,11 +351,15 @@ function CandidateItem({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={isTesting}
onClick={() => testCandidate?.(candidate.uri)} onClick={() => testCandidate?.(candidate.uri)}
className="h-8 px-3 text-sm" className="h-8 px-3 text-sm"
> >
{isTesting ? ( {isTesting ? (
<ActivityIndicator className="size-3" /> <>
<ActivityIndicator className="mr-2 size-4" />{" "}
{t("cameraWizard.step2.testConnection")}
</>
) : ( ) : (
t("cameraWizard.step2.testConnection") t("cameraWizard.step2.testConnection")
)} )}
@ -357,6 +378,7 @@ function CandidateItem({
</Button> </Button>
</div> </div>
</div> </div>
</div> </CardContent>
</Card>
); );
} }

View File

@ -1,126 +0,0 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import OnvifProbeResults from "./OnvifProbeResults";
import {} from "@/components/ui/card";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import type {
OnvifProbeResponse,
CandidateTestMap,
} from "@/types/cameraWizard";
import StepIndicator from "@/components/indicators/StepIndicator";
type ProbeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isLoading: boolean;
isError: boolean;
error?: string;
probeResult?: OnvifProbeResponse | null;
onSelectCandidate: (uri: string) => void;
onRetry: () => void;
selectedCandidateUris?: string[];
testAllSelectedCandidates?: () => void;
isTesting?: boolean;
testStatus?: string;
testCandidate?: (uri: string) => void;
candidateTests?: CandidateTestMap;
testingCandidates?: Record<string, boolean>;
};
export default function ProbeDialog({
open,
onOpenChange,
isLoading,
isError,
error,
probeResult,
onSelectCandidate,
onRetry,
selectedCandidateUris,
testAllSelectedCandidates,
isTesting,
testStatus,
testCandidate,
candidateTests,
testingCandidates,
}: ProbeDialogProps) {
const { t } = useTranslation(["views/settings"]);
const STEPS = [
"cameraWizard.steps.nameAndConnection",
"cameraWizard.steps.streamConfiguration",
"cameraWizard.steps.validationAndTesting",
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90%] max-w-3xl overflow-y-auto xl:max-h-[80%]">
<StepIndicator
steps={STEPS}
currentStep={0}
variant="dots"
className="mb-4 justify-start"
/>
<DialogHeader>
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
<DialogDescription>{t("cameraWizard.description")}</DialogDescription>
</DialogHeader>
<div className="p-0">
<OnvifProbeResults
isLoading={isLoading}
isError={isError}
error={error}
probeResult={probeResult || undefined}
onSelectCandidate={onSelectCandidate}
onRetry={onRetry}
selectedUris={selectedCandidateUris}
testCandidate={testCandidate}
candidateTests={candidateTests}
testingCandidates={testingCandidates}
/>
</div>
<div className="mt-4">
{isTesting && testStatus && (
<div className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
<span>{testStatus}</span>
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button
className="flex items-center justify-center gap-2 sm:flex-1"
onClick={() => onOpenChange(false)}
>
{t("button.back", { ns: "common" })}
</Button>
<Button
className="flex items-center justify-center gap-2 sm:flex-1"
onClick={testAllSelectedCandidates}
disabled={
!(selectedCandidateUris && selectedCandidateUris.length > 0) ||
isLoading ||
isTesting
}
variant="select"
>
<span className="flex items-center gap-2">
{isTesting && <ActivityIndicator className="size-4" />}
<span>{t("cameraWizard.step1.testConnection")}</span>
</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -17,7 +17,7 @@ import type {
} from "@/types/cameraWizard"; } from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../../ui/card"; import { Card, CardContent, CardTitle } from "../../ui/card";
import ProbeDialog from "./ProbeDialog"; import OnvifProbeResults from "./OnvifProbeResults";
import { CAMERA_BRANDS } from "@/types/cameraWizard"; import { CAMERA_BRANDS } from "@/types/cameraWizard";
import { detectReolinkCamera } from "@/utils/cameraUtil"; import { detectReolinkCamera } from "@/utils/cameraUtil";
@ -45,16 +45,15 @@ export default function Step2ProbeOrSnapshot({
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>( const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
null, null,
); );
const [probeDialogOpen, setProbeDialogOpen] = useState(false); const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>( const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>(
[], [],
); );
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>( const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
{} as CandidateTestMap, {} as CandidateTestMap,
); );
const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const handleSelectCandidate = useCallback((uri: string) => { const handleSelectCandidate = useCallback((uri: string) => {
setSelectedCandidateUris((s) => { setSelectedCandidateUris((s) => {
@ -205,7 +204,6 @@ export default function Step2ProbeOrSnapshot({
if (response.data && response.data.success) { if (response.data && response.data.success) {
setProbeResult(response.data); setProbeResult(response.data);
setProbeDialogOpen(true);
} else { } else {
setProbeError(response.data?.message || "Probe failed"); setProbeError(response.data?.message || "Probe failed");
} }
@ -266,7 +264,6 @@ export default function Step2ProbeOrSnapshot({
if (streamConfigs.length > 0) { if (streamConfigs.length > 0) {
onNext({ streams: streamConfigs }); onNext({ streams: streamConfigs });
toast.success(t("cameraWizard.step2.testSuccess")); toast.success(t("cameraWizard.step2.testSuccess"));
setProbeDialogOpen(false);
} else { } else {
toast.error( toast.error(
t("cameraWizard.commonErrors.testFailed", { t("cameraWizard.commonErrors.testFailed", {
@ -459,29 +456,18 @@ export default function Step2ProbeOrSnapshot({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{probeMode ? ( {probeMode ? (
// Probe mode: show probe dialog // Probe mode: show probe results directly
<> <>
{probeResult && ( {probeResult && (
<div className="p-4"> <div className="space-y-4">
<ProbeDialog <OnvifProbeResults
open={probeDialogOpen}
onOpenChange={(open) => {
setProbeDialogOpen(open);
// If dialog is being closed (open=false), go back
if (!open) {
onBack();
}
}}
isLoading={isProbing} isLoading={isProbing}
isError={!!probeError} isError={!!probeError}
error={probeError || undefined} error={probeError || undefined}
probeResult={probeResult} probeResult={probeResult}
onSelectCandidate={handleSelectCandidate} onSelectCandidate={handleSelectCandidate}
onRetry={probeCamera} onRetry={probeCamera}
selectedCandidateUris={selectedCandidateUris} selectedUris={selectedCandidateUris}
testAllSelectedCandidates={testAllSelectedCandidates}
isTesting={isTesting}
testStatus={testStatus}
testCandidate={testCandidate} testCandidate={testCandidate}
candidateTests={candidateTests} candidateTests={candidateTests}
testingCandidates={testingCandidates} testingCandidates={testingCandidates}
@ -489,31 +475,15 @@ export default function Step2ProbeOrSnapshot({
</div> </div>
)} )}
{isProbing && !probeResult && ( <ProbeFooterButtons
<div className="flex items-center gap-2 text-sm text-muted-foreground"> isProbing={isProbing}
<ActivityIndicator className="size-4" /> probeError={probeError}
{t("cameraWizard.step2.probing")} onBack={onBack}
</div> onTestAll={testAllSelectedCandidates}
)} onRetry={probeCamera}
isTesting={isTesting}
{probeError && !probeResult && ( selectedCount={selectedCandidateUris.length}
<div className="space-y-4"> />
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={probeCamera}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step2.retry")}
</Button>
</div>
</div>
)}
</> </>
) : ( ) : (
// Manual mode: show snapshot and stream details // Manual mode: show snapshot and stream details
@ -566,32 +536,19 @@ export default function Step2ProbeOrSnapshot({
</div> </div>
)} )}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <ProbeFooterButtons
<Button type="button" onClick={onBack} className="sm:flex-1"> mode="manual"
{t("button.back", { ns: "common" })} isProbing={false}
</Button> probeError={null}
{testResult?.success ? ( onBack={onBack}
<Button onTestAll={testAllSelectedCandidates}
type="button" onRetry={probeCamera}
onClick={handleContinue} isTesting={isTesting}
variant="select" selectedCount={selectedCandidateUris.length}
className="flex items-center justify-center gap-2 sm:flex-1" manualTestSuccess={!!testResult?.success}
> onContinue={handleContinue}
{t("button.continue", { ns: "common" })} onManualTest={testConnection}
</Button> />
) : (
<Button
type="button"
onClick={testConnection}
disabled={isTesting}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.retry")}
</Button>
)}
</div>
</> </>
)} )}
</div> </div>
@ -638,3 +595,133 @@ function StreamDetails({ testResult }: { testResult: TestResult }) {
</> </>
); );
} }
type ProbeFooterProps = {
isProbing: boolean;
probeError: string | null;
onBack: () => void;
onTestAll: () => void;
onRetry: () => void;
isTesting: boolean;
selectedCount: number;
mode?: "probe" | "manual";
manualTestSuccess?: boolean;
onContinue?: () => void;
onManualTest?: () => void;
};
function ProbeFooterButtons({
isProbing,
probeError,
onBack,
onTestAll,
onRetry,
isTesting,
selectedCount,
mode = "probe",
manualTestSuccess,
onContinue,
onManualTest,
}: ProbeFooterProps) {
const { t } = useTranslation(["views/settings"]);
// Loading footer
if (isProbing) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
disabled
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</Button>
</div>
</div>
);
}
// Error footer
if (probeError) {
return (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onRetry}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step2.retry")}
</Button>
</div>
</div>
);
}
// Default footer: show back + test (test disabled if none selected or testing)
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
if (mode === "manual") {
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
{manualTestSuccess ? (
<Button
type="button"
onClick={onContinue}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={onManualTest}
disabled={isTesting}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.retry")}
</Button>
)}
</div>
);
}
// Default probe footer
return (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onTestAll}
disabled={isTesting || selectedCount === 0}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.testConnection")}
</Button>
</div>
);
}