diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 37d2e95b1..fcbabc949 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -287,6 +287,9 @@
"streamTitle": "Stream {{number}}",
"streamUrl": "Stream URL",
"streamUrlPlaceholder": "rtsp://username:password@host:port/path",
+ "selectStream": "Select a stream",
+ "searchCandidates": "Search candidates...",
+ "noStreamFound": "No stream found",
"url": "URL",
"resolution": "Resolution",
"selectResolution": "Select resolution",
diff --git a/web/src/components/settings/wizard/OnvifProbeResults.tsx b/web/src/components/settings/wizard/OnvifProbeResults.tsx
index 014dfde29..5f3f9af8e 100644
--- a/web/src/components/settings/wizard/OnvifProbeResults.tsx
+++ b/web/src/components/settings/wizard/OnvifProbeResults.tsx
@@ -22,7 +22,6 @@ type OnvifProbeResultsProps = {
isError: boolean;
error?: string;
probeResult?: OnvifProbeResponse;
- onSelectCandidate: (uri: string) => void;
onRetry: () => void;
selectedUris?: string[];
testCandidate?: (uri: string) => void;
@@ -35,7 +34,6 @@ export default function OnvifProbeResults({
isError,
error,
probeResult,
- onSelectCandidate,
onRetry,
selectedUris,
testCandidate,
@@ -221,7 +219,6 @@ export default function OnvifProbeResults({
candidate={candidate}
copiedUri={copiedUri}
onCopy={() => handleCopyUri(candidate.uri)}
- onUse={() => onSelectCandidate(candidate.uri)}
isSelected={isSelected}
testCandidate={testCandidate}
candidateTest={candidateTest}
@@ -242,7 +239,6 @@ type CandidateItemProps = {
index?: number;
copiedUri: string | null;
onCopy: () => void;
- onUse: () => void;
isSelected?: boolean;
testCandidate?: (uri: string) => void;
candidateTest?: TestResult | { success: false; error: string };
@@ -254,7 +250,6 @@ function CandidateItem({
candidate,
copiedUri,
onCopy,
- onUse,
isSelected,
testCandidate,
candidateTest,
@@ -366,17 +361,6 @@ function CandidateItem({
-
-
-
-
diff --git a/web/src/components/settings/wizard/Step1NameCamera.tsx b/web/src/components/settings/wizard/Step1NameCamera.tsx
index ae6a23bad..0467b54b8 100644
--- a/web/src/components/settings/wizard/Step1NameCamera.tsx
+++ b/web/src/components/settings/wizard/Step1NameCamera.tsx
@@ -142,11 +142,13 @@ export default function Step1NameCamera({
onUpdate({ ...data, probeMode });
};
- const handleContinue = useCallback(() => {
- const data = form.getValues();
- onUpdate({ ...data, probeMode });
- onNext();
- }, [form, probeMode, onUpdate, onNext]);
+ const handleContinue = useCallback(async () => {
+ const isValid = await form.trigger();
+ if (isValid) {
+ const data = form.getValues();
+ onNext({ ...data, probeMode });
+ }
+ }, [form, probeMode, onNext]);
return (
diff --git a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
index b16ddca28..963e413a3 100644
--- a/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
+++ b/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
@@ -48,21 +48,11 @@ export default function Step2ProbeOrSnapshot({
const [testingCandidates, setTestingCandidates] = useState<
Record
>({} as Record);
- const [selectedCandidateUris, setSelectedCandidateUris] = useState(
- [],
- );
const [candidateTests, setCandidateTests] = useState(
{} as CandidateTestMap,
);
- const handleSelectCandidate = useCallback((uri: string) => {
- setSelectedCandidateUris((s) => {
- if (s.includes(uri)) {
- return s.filter((u) => u !== uri);
- }
- return [...s, uri];
- });
- }, []);
+ // selection is handled in Step 3 now; no local selection needed in Step 2
const probeUri = useCallback(
async (
@@ -204,6 +194,15 @@ export default function Step2ProbeOrSnapshot({
if (response.data && response.data.success) {
setProbeResult(response.data);
+ // Extract candidate URLs and pass to wizardData
+ const candidateUris = (response.data.rtsp_candidates || [])
+ .filter((c: { source: string }) => c.source === "GetStreamUri")
+ .map((c: { uri: string }) => c.uri);
+ onUpdate({
+ probeMode: true,
+ probeCandidates: candidateUris,
+ candidateTests: {},
+ });
} else {
setProbeError(response.data?.message || "Probe failed");
}
@@ -222,73 +221,39 @@ export default function Step2ProbeOrSnapshot({
} finally {
setIsProbing(false);
}
- }, [wizardData, t]);
+ }, [wizardData, onUpdate, t]);
const testAllSelectedCandidates = useCallback(async () => {
- const uris = selectedCandidateUris;
+ const uris = (probeResult?.rtsp_candidates || [])
+ .filter((c) => c.source === "GetStreamUri")
+ .map((c) => c.uri);
+
if (!uris || uris.length === 0) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
- setIsTesting(true);
- setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
-
- const streamConfigs: StreamConfig[] = [];
-
- 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,
- roles:
- streamConfigs.length === 0
- ? (["detect"] as StreamRole[])
- : ([] as StreamRole[]),
- testResult: streamTestResult,
- });
- setCandidateTests((s) => ({ ...s, [uri]: streamTestResult }));
- } else {
- setCandidateTests((s) => ({
- ...s,
- [uri]: streamTestResult,
- }));
- }
- }
-
- if (streamConfigs.length > 0) {
- onNext({ streams: streamConfigs });
- toast.success(t("cameraWizard.step2.testSuccess"));
- } 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";
- toast.error(
- t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
- );
- } finally {
- setIsTesting(false);
- setTestStatus("");
+ // Prepare an initial stream so the wizard can proceed to step 3.
+ // Use the first candidate as the initial stream (no extra probing here).
+ const streamsToCreate: StreamConfig[] = [];
+ if (uris.length > 0) {
+ const first = uris[0];
+ streamsToCreate.push({
+ id: `stream_${Date.now()}`,
+ url: first,
+ roles: ["detect" as const],
+ testResult: candidateTests[first],
+ });
}
- }, [selectedCandidateUris, t, onNext, probeUri]);
+
+ // Use existing candidateTests state (may contain entries from individual tests)
+ onNext({
+ probeMode: true,
+ probeCandidates: uris,
+ candidateTests: candidateTests,
+ streams: streamsToCreate,
+ });
+ }, [probeResult, candidateTests, onNext, t]);
const testCandidate = useCallback(
async (uri: string) => {
@@ -465,9 +430,7 @@ export default function Step2ProbeOrSnapshot({
isError={!!probeError}
error={probeError || undefined}
probeResult={probeResult}
- onSelectCandidate={handleSelectCandidate}
onRetry={probeCamera}
- selectedUris={selectedCandidateUris}
testCandidate={testCandidate}
candidateTests={candidateTests}
testingCandidates={testingCandidates}
@@ -481,8 +444,15 @@ export default function Step2ProbeOrSnapshot({
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
- isTesting={isTesting}
- selectedCount={selectedCandidateUris.length}
+ // disable next if either the overall testConnection is running or any candidate test is running
+ isTesting={
+ isTesting || Object.values(testingCandidates).some((v) => v)
+ }
+ candidateCount={
+ (probeResult?.rtsp_candidates || []).filter(
+ (c) => c.source === "GetStreamUri",
+ ).length
+ }
/>
>
) : (
@@ -543,8 +513,14 @@ export default function Step2ProbeOrSnapshot({
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
- isTesting={isTesting}
- selectedCount={selectedCandidateUris.length}
+ isTesting={
+ isTesting || Object.values(testingCandidates).some((v) => v)
+ }
+ candidateCount={
+ (probeResult?.rtsp_candidates || []).filter(
+ (c) => c.source === "GetStreamUri",
+ ).length
+ }
manualTestSuccess={!!testResult?.success}
onContinue={handleContinue}
onManualTest={testConnection}
@@ -603,7 +579,7 @@ type ProbeFooterProps = {
onTestAll: () => void;
onRetry: () => void;
isTesting: boolean;
- selectedCount: number;
+ candidateCount?: number;
mode?: "probe" | "manual";
manualTestSuccess?: boolean;
onContinue?: () => void;
@@ -617,7 +593,7 @@ function ProbeFooterButtons({
onTestAll,
onRetry,
isTesting,
- selectedCount,
+ candidateCount = 0,
mode = "probe",
manualTestSuccess,
onContinue,
@@ -698,8 +674,14 @@ function ProbeFooterButtons({
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
- {isTesting && }
- {t("cameraWizard.step2.retry")}
+ {isTesting ? (
+ <>
+ {" "}
+ {t("button.continue", { ns: "common" })}
+ >
+ ) : (
+ t("cameraWizard.step2.retry")
+ )}
)}
@@ -715,12 +697,12 @@ function ProbeFooterButtons({
);
diff --git a/web/src/components/settings/wizard/Step3StreamConfig.tsx b/web/src/components/settings/wizard/Step3StreamConfig.tsx
index 9168d693b..d8b6d9743 100644
--- a/web/src/components/settings/wizard/Step3StreamConfig.tsx
+++ b/web/src/components/settings/wizard/Step3StreamConfig.tsx
@@ -14,6 +14,7 @@ import {
StreamRole,
TestResult,
FfprobeStream,
+ CandidateTestMap,
} from "@/types/cameraWizard";
import { Label } from "../../ui/label";
import { FaCircleCheck } from "react-icons/fa6";
@@ -25,6 +26,16 @@ import {
import { LuInfo, LuExternalLink } from "react-icons/lu";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
+import { cn } from "@/lib/utils";
+import { Check, ChevronsUpDown } from "lucide-react";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
type Step3StreamConfigProps = {
wizardData: Partial;
@@ -44,19 +55,48 @@ export default function Step3StreamConfig({
const { t } = useTranslation(["views/settings", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState>(new Set());
+ const [openCombobox, setOpenCombobox] = useState(null);
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]);
+ // Probe mode candidate tracking
+ const probeCandidates = useMemo(
+ () => (wizardData.probeCandidates || []) as string[],
+ [wizardData.probeCandidates],
+ );
+
+ const candidateTests = useMemo(
+ () => (wizardData.candidateTests || {}) as CandidateTestMap,
+ [wizardData.candidateTests],
+ );
+
+ const isProbeMode = !!wizardData.probeMode;
+
const addStream = useCallback(() => {
+ const newStreamId = `stream_${Date.now()}`;
+
+ let initialUrl = "";
+ if (isProbeMode && probeCandidates.length > 0) {
+ // pick first candidate not already used
+ const used = new Set(streams.map((s) => s.url).filter(Boolean));
+ const firstAvailable = probeCandidates.find((c) => !used.has(c));
+ if (firstAvailable) {
+ initialUrl = firstAvailable;
+ }
+ }
+
const newStream: StreamConfig = {
- id: `stream_${Date.now()}`,
- url: "",
+ id: newStreamId,
+ url: initialUrl,
roles: [],
+ testResult: initialUrl ? candidateTests[initialUrl] : undefined,
+ userTested: initialUrl ? !!candidateTests[initialUrl] : false,
};
+
onUpdate({
streams: [...streams, newStream],
});
- }, [streams, onUpdate]);
+ }, [streams, onUpdate, isProbeMode, probeCandidates, candidateTests]);
const removeStream = useCallback(
(streamId: string) => {
@@ -91,6 +131,19 @@ export default function Step3StreamConfig({
[streams],
);
+ const getUsedUrlsExcludingStream = useCallback(
+ (excludeStreamId: string) => {
+ const used = new Set();
+ streams.forEach((s) => {
+ if (s.id !== excludeStreamId && s.url) {
+ used.add(s.url);
+ }
+ });
+ return used;
+ },
+ [streams],
+ );
+
const toggleRole = useCallback(
(streamId: string, role: StreamRole) => {
const stream = streams.find((s) => s.id === streamId);
@@ -164,14 +217,28 @@ export default function Step3StreamConfig({
fps: fps && !isNaN(fps) ? fps : undefined,
};
+ // Update the stream and also persist the candidate test result
updateStream(stream.id, { testResult, userTested: true });
+ onUpdate({
+ candidateTests: {
+ ...(wizardData.candidateTests || {}),
+ [stream.url]: testResult,
+ } as CandidateTestMap,
+ });
toast.success(t("cameraWizard.step3.testSuccess"));
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
+ const failResult: TestResult = { success: false, error };
updateStream(stream.id, {
- testResult: { success: false, error },
+ testResult: failResult,
userTested: true,
});
+ onUpdate({
+ candidateTests: {
+ ...(wizardData.candidateTests || {}),
+ [stream.url]: failResult,
+ } as CandidateTestMap,
+ });
toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
}
})
@@ -180,10 +247,20 @@ export default function Step3StreamConfig({
error.response?.data?.message ||
error.response?.data?.detail ||
"Connection failed";
+ const catchResult: TestResult = {
+ success: false,
+ error: errorMessage,
+ };
updateStream(stream.id, {
- testResult: { success: false, error: errorMessage },
+ testResult: catchResult,
userTested: true,
});
+ onUpdate({
+ candidateTests: {
+ ...(wizardData.candidateTests || {}),
+ [stream.url]: catchResult,
+ } as CandidateTestMap,
+ });
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
);
@@ -196,7 +273,7 @@ export default function Step3StreamConfig({
});
});
},
- [updateStream, t],
+ [updateStream, t, onUpdate, wizardData.candidateTests],
);
const setRestream = useCallback(
@@ -277,17 +354,100 @@ export default function Step3StreamConfig({
{t("cameraWizard.step3.url")}
-
- updateStream(stream.id, {
- url: e.target.value,
- testResult: undefined,
- })
- }
- className="h-8 flex-1"
- placeholder={t("cameraWizard.step3.streamUrlPlaceholder")}
- />
+ {isProbeMode && probeCandidates.length > 0 ? (
+
{
+ setOpenCombobox(isOpen ? stream.id : null);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {t("cameraWizard.step3.noStreamFound")}
+
+
+ {probeCandidates
+ .filter((c) => {
+ const used = getUsedUrlsExcludingStream(
+ stream.id,
+ );
+ return !used.has(c);
+ })
+ .map((candidate) => (
+ {
+ updateStream(stream.id, {
+ url: candidate,
+ testResult:
+ candidateTests[candidate] ||
+ undefined,
+ userTested:
+ !!candidateTests[candidate],
+ });
+ setOpenCombobox(null);
+ }}
+ >
+
+ {candidate}
+
+ ))}
+
+
+
+
+
+ ) : (
+
+ updateStream(stream.id, {
+ url: e.target.value,
+ testResult: undefined,
+ })
+ }
+ className="h-8 flex-1"
+ placeholder={t(
+ "cameraWizard.step3.streamUrlPlaceholder",
+ )}
+ />
+ )}