From d74688eb37a1ee8333e7bf8baef95f2a893a001f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 9 Nov 2025 12:17:46 -0600 Subject: [PATCH] add optional probe dialog to wizard step 1 --- .../settings/wizard/OnvifProbeResults.tsx | 323 ++++++++ .../settings/wizard/ProbeDialog.tsx | 126 +++ .../settings/wizard/Step1NameCamera.tsx | 751 +++++++++++++----- 3 files changed, 982 insertions(+), 218 deletions(-) create mode 100644 web/src/components/settings/wizard/OnvifProbeResults.tsx create mode 100644 web/src/components/settings/wizard/ProbeDialog.tsx diff --git a/web/src/components/settings/wizard/OnvifProbeResults.tsx b/web/src/components/settings/wizard/OnvifProbeResults.tsx new file mode 100644 index 000000000..9ad3809fb --- /dev/null +++ b/web/src/components/settings/wizard/OnvifProbeResults.tsx @@ -0,0 +1,323 @@ +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { FaExclamationCircle, FaCopy, FaCheck } from "react-icons/fa"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { + OnvifProbeResponse, + OnvifRtspCandidate, + TestResult, + CandidateTestMap, +} from "@/types/cameraWizard"; + +type OnvifProbeResultsProps = { + isLoading: boolean; + isError: boolean; + error?: string; + probeResult?: OnvifProbeResponse; + onSelectCandidate: (uri: string) => void; + onRetry: () => void; + selectedUris?: string[]; + testCandidate?: (uri: string) => void; + candidateTests?: CandidateTestMap; + testingCandidates?: Record; +}; + +export default function OnvifProbeResults({ + isLoading, + isError, + error, + probeResult, + onSelectCandidate, + onRetry, + selectedUris, + testCandidate, + candidateTests, + testingCandidates, +}: OnvifProbeResultsProps) { + const { t } = useTranslation(["views/settings"]); + const [copiedUri, setCopiedUri] = useState(null); + + const handleCopyUri = (uri: string) => { + navigator.clipboard.writeText(uri); + setCopiedUri(uri); + toast.success(t("cameraWizard.step1.uriCopied")); + setTimeout(() => setCopiedUri(null), 2000); + }; + + if (isLoading) { + return ( +
+ +

+ {t("cameraWizard.step1.probingDevice")} +

+
+ ); + } + + if (isError) { + return ( +
+
+ +
+

+ {t("cameraWizard.step1.probeError")} +

+ {error &&

{error}

} +
+
+ +
+ ); + } + + if (!probeResult?.success) { + return ( +
+
+ +
+

+ {t("cameraWizard.step1.probeNoSuccess")} +

+ {probeResult?.message && ( +

+ {probeResult.message} +

+ )} +
+
+ +
+ ); + } + + const rtspCandidates = probeResult.rtsp_candidates || []; + + return ( +
+ + + {t("cameraWizard.step1.deviceInfo")} + + + {probeResult.manufacturer && ( +
+ Manufacturer:{" "} + {probeResult.manufacturer} +
+ )} + {probeResult.model && ( +
+ Model:{" "} + {probeResult.model} +
+ )} + {probeResult.firmware_version && ( +
+ Firmware:{" "} + + {probeResult.firmware_version} + +
+ )} + {probeResult.profiles_count !== undefined && ( +
+ Profiles:{" "} + {probeResult.profiles_count} +
+ )} + {probeResult.ptz_supported !== undefined && ( +
+ PTZ Support:{" "} + + {probeResult.ptz_supported ? "Yes" : "No"} + +
+ )} + {probeResult.ptz_supported && probeResult.autotrack_supported && ( +
+ Autotrack Support:{" "} + Yes +
+ )} + {probeResult.ptz_supported && + probeResult.presets_count !== undefined && ( +
+ Presets:{" "} + {probeResult.presets_count} +
+ )} +
+
+ + {rtspCandidates.length > 0 && ( +
+

+ {t("cameraWizard.step1.rtspCandidates")} +

+ +
+ {rtspCandidates.map((candidate, idx) => { + const isSelected = !!selectedUris?.includes(candidate.uri); + const candidateTest = candidateTests?.[candidate.uri]; + const isTesting = testingCandidates?.[candidate.uri]; + + return ( + handleCopyUri(candidate.uri)} + onUse={() => onSelectCandidate(candidate.uri)} + isSelected={isSelected} + onTest={() => testCandidate && testCandidate(candidate.uri)} + candidateTest={candidateTest} + isTesting={isTesting} + /> + ); + })} +
+
+ )} +
+ ); +} + +type CandidateItemProps = { + candidate: OnvifRtspCandidate; + isTested?: boolean; + copiedUri: string | null; + onCopy: () => void; + onUse: () => void; + isSelected?: boolean; + onTest?: () => void; + candidateTest?: TestResult | { success: false; error: string }; + isTesting?: boolean; +}; + +function CandidateItem({ + candidate, + isTested, + copiedUri, + onCopy, + onUse, + isSelected, + onTest, + candidateTest, + isTesting, +}: CandidateItemProps) { + const { t } = useTranslation(["views/settings"]); + const [showFull, setShowFull] = useState(false); + + // Mask credentials for display + const maskUri = (uri: string) => { + const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/); + if (match) { + return `rtsp://${match[1]}:••••@${match[3]}`; + } + return uri; + }; + + return ( +
+
+
+
+
+ {isTested !== undefined && ( + + {isTested ? "✓ OK" : "✗ Failed"} + + )} +
+
+

setShowFull(!showFull)} + title="Click to toggle masked/full view" + > + {showFull ? candidate.uri : maskUri(candidate.uri)} +

+ +
+
+
+ + +
+
+ {candidateTest && candidateTest.success && ( +
+ {candidateTest.resolution && ( + + {`${t("cameraWizard.testResultLabels.resolution")} ${candidateTest.resolution}`} + + )} + {candidateTest.fps && ( + + {t("cameraWizard.testResultLabels.fps")} {candidateTest.fps} + + )} + {candidateTest.videoCodec && ( + + {`${t("cameraWizard.testResultLabels.video")} ${candidateTest.videoCodec}`} + + )} + {candidateTest.audioCodec && ( + + {`${t("cameraWizard.testResultLabels.audio")} ${candidateTest.audioCodec}`} + + )} +
+ )} +
+
+ ); +} diff --git a/web/src/components/settings/wizard/ProbeDialog.tsx b/web/src/components/settings/wizard/ProbeDialog.tsx new file mode 100644 index 000000000..582ba6c40 --- /dev/null +++ b/web/src/components/settings/wizard/ProbeDialog.tsx @@ -0,0 +1,126 @@ +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; +}; + +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 ( + + + + + {t("cameraWizard.title")} + {t("cameraWizard.description")} + + +
+ +
+ +
+ {isTesting && testStatus && ( +
+ + {testStatus} +
+ )} +
+ + + +
+
+
+
+ ); +} diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx index 8895f82f6..cc2bd3887 100644 --- a/web/src/components/settings/wizard/Step1NameCamera.tsx +++ b/web/src/components/settings/wizard/Step1NameCamera.tsx @@ -15,6 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -33,9 +34,13 @@ import { CAMERA_BRAND_VALUES, TestResult, FfprobeStream, + FfprobeData, + FfprobeResponse, StreamRole, StreamConfig, + OnvifProbeResponse, } from "@/types/cameraWizard"; +import type { CandidateTestMap } from "@/types/cameraWizard"; import { FaCircleCheck } from "react-icons/fa6"; import { Card, CardContent, CardTitle } from "../../ui/card"; import { @@ -45,6 +50,7 @@ import { } from "@/components/ui/popover"; import { LuInfo } from "react-icons/lu"; import { detectReolinkCamera } from "@/utils/cameraUtil"; +import ProbeDialog from "./ProbeDialog"; type Step1NameCameraProps = { wizardData: Partial; @@ -66,6 +72,23 @@ export default function Step1NameCamera({ const [isTesting, setIsTesting] = useState(false); const [testStatus, setTestStatus] = useState(""); const [testResult, setTestResult] = useState(null); + const [probeMode, setProbeMode] = useState(true); + const [onvifPort, setOnvifPort] = useState(80); + const [isProbing, setIsProbing] = useState(false); + const [probeError, setProbeError] = useState(null); + const [probeResult, setProbeResult] = useState( + null, + ); + const [probeDialogOpen, setProbeDialogOpen] = useState(false); + const [selectedCandidateUris, setSelectedCandidateUris] = useState( + [], + ); + const [candidateTests, setCandidateTests] = useState( + {} as CandidateTestMap, + ); + const [testingCandidates, setTestingCandidates] = useState< + Record + >({} as Record); const existingCameraNames = useMemo(() => { if (!config?.cameras) { @@ -132,10 +155,17 @@ export default function Step1NameCamera({ const watchedHost = form.watch("host"); const watchedCustomUrl = form.watch("customUrl"); + const hostPresent = !!(watchedHost && watchedHost.trim()); + const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim()); + const cameraNamePresent = !!(form.getValues().cameraName || "").trim(); + const isTestButtonEnabled = - watchedBrand === "other" - ? !!(watchedCustomUrl && watchedCustomUrl.trim()) - : !!(watchedHost && watchedHost.trim()); + cameraNamePresent && + (probeMode + ? hostPresent + : watchedBrand === "other" + ? customPresent + : hostPresent); const generateDynamicStreamUrl = useCallback( async (data: z.infer): Promise => { @@ -200,55 +230,108 @@ export default function Step1NameCamera({ [generateDynamicStreamUrl], ); - const testConnection = useCallback(async () => { + const probeCamera = useCallback(async () => { const data = form.getValues(); - const streamUrl = await generateStreamUrl(data); - if (!streamUrl) { - toast.error(t("cameraWizard.commonErrors.noUrl")); + if (!data.host) { + toast.error(t("cameraWizard.step1.errors.hostRequired")); return; } - setIsTesting(true); - setTestStatus(""); - setTestResult(null); + setIsProbing(true); + setProbeError(null); + setProbeResult(null); try { - // First get probe data for metadata - setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); - const probeResponse = await axios.get("ffprobe", { - params: { paths: streamUrl, detailed: true }, - timeout: 10000, + const response = await axios.get("/onvif/probe", { + params: { + host: data.host, + port: onvifPort, + username: data.username || "", + password: data.password || "", + test: false, + }, + timeout: 30000, }); - let probeData = null; - if ( - probeResponse.data && - probeResponse.data.length > 0 && - probeResponse.data[0].return_code === 0 - ) { - probeData = probeResponse.data[0]; + if (response.data && response.data.success) { + setProbeResult(response.data); + // open the probe dialog to show results + setProbeDialogOpen(true); + } else { + setProbeError(response.data?.message || "Probe failed"); } + } catch (error) { + const axiosError = error as { + response?: { data?: { message?: string; detail?: string } }; + message?: string; + }; + const errorMessage = + axiosError.response?.data?.message || + axiosError.response?.data?.detail || + axiosError.message || + "Failed to probe camera"; + setProbeError(errorMessage); + toast.error(t("cameraWizard.step1.probeFailed", { error: errorMessage })); + } finally { + setIsProbing(false); + } + }, [form, onvifPort, t]); - // Then get snapshot for preview (only if probe succeeded) - let snapshotBlob = null; - if (probeData) { - setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot")); - try { - const snapshotResponse = await axios.get("ffprobe/snapshot", { - params: { url: streamUrl }, - responseType: "blob", - timeout: 10000, - }); - snapshotBlob = snapshotResponse.data; - } catch (snapshotError) { - // Snapshot is optional, don't fail if it doesn't work - toast.warning(t("cameraWizard.step1.warnings.noSnapshot")); + const handleSelectCandidate = useCallback((uri: string) => { + // toggle selection: add or remove from selectedCandidateUris + setSelectedCandidateUris((s) => { + if (s.includes(uri)) { + return s.filter((u) => u !== uri); + } + return [...s, uri]; + }); + }, []); + + // Probe a single URI and return a TestResult. If fetchSnapshot is true, + // also attempt to fetch a snapshot (may be undefined on failure). + const probeUri = useCallback( + async ( + uri: string, + fetchSnapshot = false, + setStatus?: (s: string) => void, + ): Promise => { + try { + const probeResponse = await axios.get("ffprobe", { + params: { paths: uri, detailed: true }, + timeout: 10000, + }); + + let probeData: FfprobeResponse | null = null; + if ( + probeResponse.data && + probeResponse.data.length > 0 && + probeResponse.data[0].return_code === 0 + ) { + probeData = probeResponse.data[0]; + } + + if (!probeData) { + const error = + Array.isArray(probeResponse.data?.[0]?.stderr) && + probeResponse.data[0].stderr.length > 0 + ? probeResponse.data[0].stderr.join("\n") + : "Unable to probe stream"; + return { success: false, error }; + } + + // stdout may be a string or structured object. Normalize to FfprobeData. + let ffprobeData: FfprobeData; + if (typeof probeData.stdout === "string") { + try { + ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData; + } catch { + ffprobeData = { streams: [] }; + } + } else { + ffprobeData = probeData.stdout as FfprobeData; } - } - if (probeData) { - const ffprobeData = probeData.stdout; const streams = ffprobeData.streams || []; const videoStream = streams.find( @@ -271,23 +354,34 @@ export default function Step1NameCamera({ ? `${videoStream.width}x${videoStream.height}` : undefined; - // Extract FPS from rational (e.g., "15/1" -> 15) const fps = videoStream?.avg_frame_rate ? parseFloat(videoStream.avg_frame_rate.split("/")[0]) / parseFloat(videoStream.avg_frame_rate.split("/")[1]) : undefined; - // Convert snapshot blob to base64 if available - let snapshotBase64 = undefined; - if (snapshotBlob) { - snapshotBase64 = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.readAsDataURL(snapshotBlob); - }); + let snapshotBase64: string | undefined = undefined; + if (fetchSnapshot) { + if (setStatus) { + setStatus(t("cameraWizard.step1.testing.fetchingSnapshot")); + } + try { + const snapshotResponse = await axios.get("ffprobe/snapshot", { + params: { url: uri }, + responseType: "blob", + timeout: 10000, + }); + const snapshotBlob = snapshotResponse.data; + snapshotBase64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(snapshotBlob); + }); + } catch (snapshotError) { + snapshotBase64 = undefined; + } } - const testResult: TestResult = { + const streamTestResult: TestResult = { success: true, snapshot: snapshotBase64, resolution, @@ -296,22 +390,155 @@ export default function Step1NameCamera({ fps: fps && !isNaN(fps) ? fps : undefined, }; - setTestResult(testResult); - onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] }); + return streamTestResult; + } catch (err) { + const axiosError = err as { + response?: { data?: { message?: string; detail?: string } }; + message?: string; + }; + const errorMessage = + axiosError.response?.data?.message || + axiosError.response?.data?.detail || + axiosError.message || + "Connection failed"; + return { success: false, error: errorMessage }; + } + }, + [t], + ); + + const testAllSelectedCandidates = useCallback(async () => { + const uris = selectedCandidateUris; + if (!uris || uris.length === 0) { + toast.error(t("cameraWizard.commonErrors.noUrl")); + return; + } + + setIsTesting(true); + setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); + + const streamConfigs: StreamConfig[] = []; + let firstSuccessfulTestResult: TestResult | null = null; + let firstSuccessfulUri: string | undefined = undefined; + + try { + for (let i = 0; i < uris.length; i++) { + const uri = uris[i]; + const streamTestResult = await probeUri(uri, false); + + if (streamTestResult && streamTestResult.success) { + const streamId = `stream_${Date.now()}_${i}`; + streamConfigs.push({ + id: streamId, + url: uri, + // Only the first stream should have the detect role + roles: + streamConfigs.length === 0 + ? (["detect"] as StreamRole[]) + : ([] as StreamRole[]), + testResult: streamTestResult, + }); + + // keep first successful for main pane snapshot + if (!firstSuccessfulTestResult) { + firstSuccessfulTestResult = streamTestResult; + firstSuccessfulUri = uri; + } + // also store candidate test summary + setCandidateTests((s) => ({ ...s, [uri]: streamTestResult })); + } else { + setCandidateTests((s) => ({ + ...s, + [uri]: streamTestResult, + })); + } + } + + if (streamConfigs.length > 0) { + // Add all successful streams and navigate to Step 2 + onNext({ streams: streamConfigs, customUrl: firstSuccessfulUri }); + toast.success(t("cameraWizard.step1.testSuccess")); + setProbeDialogOpen(false); + setProbeResult(null); + } else { + toast.error( + t("cameraWizard.commonErrors.testFailed", { + error: "No streams succeeded", + }), + ); + } + } catch (error) { + const axiosError = error as { + response?: { data?: { message?: string; detail?: string } }; + message?: string; + }; + const errorMessage = + axiosError.response?.data?.message || + axiosError.response?.data?.detail || + axiosError.message || + "Connection failed"; + setTestResult({ + success: false, + error: errorMessage, + }); + toast.error( + t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), + ); + } finally { + setIsTesting(false); + setTestStatus(""); + } + }, [selectedCandidateUris, t, onNext, probeUri]); + + const testCandidate = useCallback( + async (uri: string) => { + if (!uri) return; + setTestingCandidates((s) => ({ ...s, [uri]: true })); + try { + const result = await probeUri(uri, false); + setCandidateTests((s) => ({ ...s, [uri]: result })); + } finally { + setTestingCandidates((s) => ({ ...s, [uri]: false })); + } + }, + [probeUri], + ); + + const testConnection = useCallback(async () => { + const _data = form.getValues(); + const streamUrl = await generateStreamUrl(_data); + + if (!streamUrl) { + toast.error(t("cameraWizard.commonErrors.noUrl")); + return; + } + + setIsTesting(true); + setTestStatus(""); + setTestResult(null); + + try { + setTestStatus(t("cameraWizard.step1.testing.probingMetadata")); + const result = await probeUri(streamUrl, true, setTestStatus); + + if (result && result.success) { + setTestResult(result); + onUpdate({ + streams: [{ id: "", url: streamUrl, roles: [], testResult: result }], + }); toast.success(t("cameraWizard.step1.testSuccess")); } else { - const error = - Array.isArray(probeResponse.data?.[0]?.stderr) && - probeResponse.data[0].stderr.length > 0 - ? probeResponse.data[0].stderr.join("\n") - : "Unable to probe stream"; + const errMsg = result?.error || "Unable to probe stream"; setTestResult({ success: false, - error: error, - }); - toast.error(t("cameraWizard.commonErrors.testFailed", { error }), { - duration: 6000, + error: errMsg, }); + toast.error( + t("cameraWizard.commonErrors.testFailed", { error: errMsg }), + { + duration: 6000, + }, + ); } } catch (error) { const axiosError = error as { @@ -337,7 +564,7 @@ export default function Step1NameCamera({ setIsTesting(false); setTestStatus(""); } - }, [form, generateStreamUrl, t, onUpdate]); + }, [form, generateStreamUrl, t, onUpdate, probeUri]); const onSubmit = (data: z.infer) => { onUpdate(data); @@ -397,169 +624,19 @@ export default function Step1NameCamera({ )} /> - ( - -
- - {t("cameraWizard.step1.cameraBrand")} - - {field.value && - (() => { - const selectedBrand = CAMERA_BRANDS.find( - (brand) => brand.value === field.value, - ); - return selectedBrand && - selectedBrand.value != "other" ? ( - - - - - -
-

- {selectedBrand.label} -

-

- {t("cameraWizard.step1.brandUrlFormat", { - exampleUrl: selectedBrand.exampleUrl, - })} -

-
-
-
- ) : null; - })()} -
- - -
- )} - /> - - {watchedBrand !== "other" && ( - <> - ( - - - {t("cameraWizard.step1.host")} - - - - - - - )} - /> - - ( - - - {t("cameraWizard.step1.username")} - - - - - - - )} - /> - - ( - - - {t("cameraWizard.step1.password")} - - -
- - -
-
- -
- )} - /> - - )} - - {watchedBrand == "other" && ( +
( - {t("cameraWizard.step1.customUrl")} + {t("cameraWizard.step1.host")} @@ -567,12 +644,246 @@ export default function Step1NameCamera({ )} /> + + ( + + + {t("cameraWizard.step1.username")} + + + + + + + )} + /> + + ( + + + {t("cameraWizard.step1.password")} + + +
+ + +
+
+ +
+ )} + /> +
+ +
+ + {t("cameraWizard.step1.detectionMethod")} + + { + setProbeMode(value === "probe"); + setProbeResult(null); + setProbeError(null); + }} + > +
+ + +
+
+ + +
+
+
+ + {probeMode && ( + + + {t("cameraWizard.step1.onvifPort")} + + + + setOnvifPort(parseInt(e.target.value, 10) || 80) + } + placeholder="80" + /> + + + )} + + {!probeMode && ( +
+ ( + +
+ + {t("cameraWizard.step1.cameraBrand")} + + {field.value && + (() => { + const selectedBrand = CAMERA_BRANDS.find( + (brand) => brand.value === field.value, + ); + return selectedBrand && + selectedBrand.value != "other" ? ( + + + + + +
+

+ {selectedBrand.label} +

+

+ {t( + "cameraWizard.step1.brandUrlFormat", + { + exampleUrl: + selectedBrand.exampleUrl, + }, + )} +

+
+
+
+ ) : null; + })()} +
+ + +
+ )} + /> + + {watchedBrand == "other" && ( + ( + + + {t("cameraWizard.step1.customUrl")} + + + + + + + )} + /> + )} +
)} )} + {probeMode && probeResult && ( +
+ +
+ )} + {testResult?.success && (
@@ -636,12 +947,16 @@ export default function Step1NameCamera({ ) : ( )}