refactor to select onvif urls via combobox in step 3

This commit is contained in:
Josh Hawkins 2025-11-10 14:23:14 -06:00
parent ccf2d2018b
commit 945d395525
6 changed files with 254 additions and 121 deletions

View File

@ -287,6 +287,9 @@
"streamTitle": "Stream {{number}}", "streamTitle": "Stream {{number}}",
"streamUrl": "Stream URL", "streamUrl": "Stream URL",
"streamUrlPlaceholder": "rtsp://username:password@host:port/path", "streamUrlPlaceholder": "rtsp://username:password@host:port/path",
"selectStream": "Select a stream",
"searchCandidates": "Search candidates...",
"noStreamFound": "No stream found",
"url": "URL", "url": "URL",
"resolution": "Resolution", "resolution": "Resolution",
"selectResolution": "Select resolution", "selectResolution": "Select resolution",

View File

@ -22,7 +22,6 @@ type OnvifProbeResultsProps = {
isError: boolean; isError: boolean;
error?: string; error?: string;
probeResult?: OnvifProbeResponse; probeResult?: OnvifProbeResponse;
onSelectCandidate: (uri: string) => void;
onRetry: () => void; onRetry: () => void;
selectedUris?: string[]; selectedUris?: string[];
testCandidate?: (uri: string) => void; testCandidate?: (uri: string) => void;
@ -35,7 +34,6 @@ export default function OnvifProbeResults({
isError, isError,
error, error,
probeResult, probeResult,
onSelectCandidate,
onRetry, onRetry,
selectedUris, selectedUris,
testCandidate, testCandidate,
@ -221,7 +219,6 @@ export default function OnvifProbeResults({
candidate={candidate} candidate={candidate}
copiedUri={copiedUri} copiedUri={copiedUri}
onCopy={() => handleCopyUri(candidate.uri)} onCopy={() => handleCopyUri(candidate.uri)}
onUse={() => onSelectCandidate(candidate.uri)}
isSelected={isSelected} isSelected={isSelected}
testCandidate={testCandidate} testCandidate={testCandidate}
candidateTest={candidateTest} candidateTest={candidateTest}
@ -242,7 +239,6 @@ type CandidateItemProps = {
index?: number; index?: number;
copiedUri: string | null; copiedUri: string | null;
onCopy: () => void; onCopy: () => void;
onUse: () => void;
isSelected?: boolean; isSelected?: boolean;
testCandidate?: (uri: string) => void; testCandidate?: (uri: string) => void;
candidateTest?: TestResult | { success: false; error: string }; candidateTest?: TestResult | { success: false; error: string };
@ -254,7 +250,6 @@ function CandidateItem({
candidate, candidate,
copiedUri, copiedUri,
onCopy, onCopy,
onUse,
isSelected, isSelected,
testCandidate, testCandidate,
candidateTest, candidateTest,
@ -366,17 +361,6 @@ function CandidateItem({
</Button> </Button>
</div> </div>
</div> </div>
<div className="mt-3 flex flex-row justify-end">
<Button
size="sm"
onClick={onUse}
variant="select"
className="h-8 px-3 text-sm"
>
{t("cameraWizard.step2.useCandidate")}
</Button>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -142,11 +142,13 @@ export default function Step1NameCamera({
onUpdate({ ...data, probeMode }); onUpdate({ ...data, probeMode });
}; };
const handleContinue = useCallback(() => { const handleContinue = useCallback(async () => {
const data = form.getValues(); const isValid = await form.trigger();
onUpdate({ ...data, probeMode }); if (isValid) {
onNext(); const data = form.getValues();
}, [form, probeMode, onUpdate, onNext]); onNext({ ...data, probeMode });
}
}, [form, probeMode, onNext]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@ -48,21 +48,11 @@ export default function Step2ProbeOrSnapshot({
const [testingCandidates, setTestingCandidates] = useState< const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean> Record<string, boolean>
>({} as Record<string, boolean>); >({} as Record<string, boolean>);
const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>(
[],
);
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>( const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
{} as CandidateTestMap, {} as CandidateTestMap,
); );
const handleSelectCandidate = useCallback((uri: string) => { // selection is handled in Step 3 now; no local selection needed in Step 2
setSelectedCandidateUris((s) => {
if (s.includes(uri)) {
return s.filter((u) => u !== uri);
}
return [...s, uri];
});
}, []);
const probeUri = useCallback( const probeUri = useCallback(
async ( async (
@ -204,6 +194,15 @@ export default function Step2ProbeOrSnapshot({
if (response.data && response.data.success) { if (response.data && response.data.success) {
setProbeResult(response.data); 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 { } else {
setProbeError(response.data?.message || "Probe failed"); setProbeError(response.data?.message || "Probe failed");
} }
@ -222,73 +221,39 @@ export default function Step2ProbeOrSnapshot({
} finally { } finally {
setIsProbing(false); setIsProbing(false);
} }
}, [wizardData, t]); }, [wizardData, onUpdate, t]);
const testAllSelectedCandidates = useCallback(async () => { 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) { if (!uris || uris.length === 0) {
toast.error(t("cameraWizard.commonErrors.noUrl")); toast.error(t("cameraWizard.commonErrors.noUrl"));
return; return;
} }
setIsTesting(true); // Prepare an initial stream so the wizard can proceed to step 3.
setTestStatus(t("cameraWizard.step2.testing.probingMetadata")); // Use the first candidate as the initial stream (no extra probing here).
const streamsToCreate: StreamConfig[] = [];
const streamConfigs: StreamConfig[] = []; if (uris.length > 0) {
const first = uris[0];
try { streamsToCreate.push({
for (let i = 0; i < uris.length; i++) { id: `stream_${Date.now()}`,
const uri = uris[i]; url: first,
const streamTestResult = await probeUri(uri, false); roles: ["detect" as const],
testResult: candidateTests[first],
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("");
} }
}, [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( const testCandidate = useCallback(
async (uri: string) => { async (uri: string) => {
@ -465,9 +430,7 @@ export default function Step2ProbeOrSnapshot({
isError={!!probeError} isError={!!probeError}
error={probeError || undefined} error={probeError || undefined}
probeResult={probeResult} probeResult={probeResult}
onSelectCandidate={handleSelectCandidate}
onRetry={probeCamera} onRetry={probeCamera}
selectedUris={selectedCandidateUris}
testCandidate={testCandidate} testCandidate={testCandidate}
candidateTests={candidateTests} candidateTests={candidateTests}
testingCandidates={testingCandidates} testingCandidates={testingCandidates}
@ -481,8 +444,15 @@ export default function Step2ProbeOrSnapshot({
onBack={onBack} onBack={onBack}
onTestAll={testAllSelectedCandidates} onTestAll={testAllSelectedCandidates}
onRetry={probeCamera} onRetry={probeCamera}
isTesting={isTesting} // disable next if either the overall testConnection is running or any candidate test is running
selectedCount={selectedCandidateUris.length} 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} onBack={onBack}
onTestAll={testAllSelectedCandidates} onTestAll={testAllSelectedCandidates}
onRetry={probeCamera} onRetry={probeCamera}
isTesting={isTesting} isTesting={
selectedCount={selectedCandidateUris.length} isTesting || Object.values(testingCandidates).some((v) => v)
}
candidateCount={
(probeResult?.rtsp_candidates || []).filter(
(c) => c.source === "GetStreamUri",
).length
}
manualTestSuccess={!!testResult?.success} manualTestSuccess={!!testResult?.success}
onContinue={handleContinue} onContinue={handleContinue}
onManualTest={testConnection} onManualTest={testConnection}
@ -603,7 +579,7 @@ type ProbeFooterProps = {
onTestAll: () => void; onTestAll: () => void;
onRetry: () => void; onRetry: () => void;
isTesting: boolean; isTesting: boolean;
selectedCount: number; candidateCount?: number;
mode?: "probe" | "manual"; mode?: "probe" | "manual";
manualTestSuccess?: boolean; manualTestSuccess?: boolean;
onContinue?: () => void; onContinue?: () => void;
@ -617,7 +593,7 @@ function ProbeFooterButtons({
onTestAll, onTestAll,
onRetry, onRetry,
isTesting, isTesting,
selectedCount, candidateCount = 0,
mode = "probe", mode = "probe",
manualTestSuccess, manualTestSuccess,
onContinue, onContinue,
@ -698,8 +674,14 @@ function ProbeFooterButtons({
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
> >
{isTesting && <ActivityIndicator className="size-4" />} {isTesting ? (
{t("cameraWizard.step2.retry")} <>
<ActivityIndicator className="size-4" />{" "}
{t("button.continue", { ns: "common" })}
</>
) : (
t("cameraWizard.step2.retry")
)}
</Button> </Button>
)} )}
</div> </div>
@ -715,12 +697,12 @@ function ProbeFooterButtons({
<Button <Button
type="button" type="button"
onClick={onTestAll} onClick={onTestAll}
disabled={isTesting || selectedCount === 0} disabled={isTesting || (candidateCount ?? 0) === 0}
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
> >
{isTesting && <ActivityIndicator className="size-4" />} {isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.testConnection")} {t("button.next", { ns: "common" })}
</Button> </Button>
</div> </div>
); );

View File

@ -14,6 +14,7 @@ import {
StreamRole, StreamRole,
TestResult, TestResult,
FfprobeStream, FfprobeStream,
CandidateTestMap,
} from "@/types/cameraWizard"; } from "@/types/cameraWizard";
import { Label } from "../../ui/label"; import { Label } from "../../ui/label";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
@ -25,6 +26,16 @@ import {
import { LuInfo, LuExternalLink } from "react-icons/lu"; import { LuInfo, LuExternalLink } from "react-icons/lu";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain"; 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 = { type Step3StreamConfigProps = {
wizardData: Partial<WizardFormData>; wizardData: Partial<WizardFormData>;
@ -44,19 +55,48 @@ export default function Step3StreamConfig({
const { t } = useTranslation(["views/settings", "components/dialog"]); const { t } = useTranslation(["views/settings", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set()); const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const [openCombobox, setOpenCombobox] = useState<string | null>(null);
const streams = useMemo(() => wizardData.streams || [], [wizardData.streams]); 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 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 = { const newStream: StreamConfig = {
id: `stream_${Date.now()}`, id: newStreamId,
url: "", url: initialUrl,
roles: [], roles: [],
testResult: initialUrl ? candidateTests[initialUrl] : undefined,
userTested: initialUrl ? !!candidateTests[initialUrl] : false,
}; };
onUpdate({ onUpdate({
streams: [...streams, newStream], streams: [...streams, newStream],
}); });
}, [streams, onUpdate]); }, [streams, onUpdate, isProbeMode, probeCandidates, candidateTests]);
const removeStream = useCallback( const removeStream = useCallback(
(streamId: string) => { (streamId: string) => {
@ -91,6 +131,19 @@ export default function Step3StreamConfig({
[streams], [streams],
); );
const getUsedUrlsExcludingStream = useCallback(
(excludeStreamId: string) => {
const used = new Set<string>();
streams.forEach((s) => {
if (s.id !== excludeStreamId && s.url) {
used.add(s.url);
}
});
return used;
},
[streams],
);
const toggleRole = useCallback( const toggleRole = useCallback(
(streamId: string, role: StreamRole) => { (streamId: string, role: StreamRole) => {
const stream = streams.find((s) => s.id === streamId); const stream = streams.find((s) => s.id === streamId);
@ -164,14 +217,28 @@ export default function Step3StreamConfig({
fps: fps && !isNaN(fps) ? fps : undefined, fps: fps && !isNaN(fps) ? fps : undefined,
}; };
// Update the stream and also persist the candidate test result
updateStream(stream.id, { testResult, userTested: true }); updateStream(stream.id, { testResult, userTested: true });
onUpdate({
candidateTests: {
...(wizardData.candidateTests || {}),
[stream.url]: testResult,
} as CandidateTestMap,
});
toast.success(t("cameraWizard.step3.testSuccess")); toast.success(t("cameraWizard.step3.testSuccess"));
} else { } else {
const error = response.data?.[0]?.stderr || "Unknown error"; const error = response.data?.[0]?.stderr || "Unknown error";
const failResult: TestResult = { success: false, error };
updateStream(stream.id, { updateStream(stream.id, {
testResult: { success: false, error }, testResult: failResult,
userTested: true, userTested: true,
}); });
onUpdate({
candidateTests: {
...(wizardData.candidateTests || {}),
[stream.url]: failResult,
} as CandidateTestMap,
});
toast.error(t("cameraWizard.commonErrors.testFailed", { error })); toast.error(t("cameraWizard.commonErrors.testFailed", { error }));
} }
}) })
@ -180,10 +247,20 @@ export default function Step3StreamConfig({
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Connection failed"; "Connection failed";
const catchResult: TestResult = {
success: false,
error: errorMessage,
};
updateStream(stream.id, { updateStream(stream.id, {
testResult: { success: false, error: errorMessage }, testResult: catchResult,
userTested: true, userTested: true,
}); });
onUpdate({
candidateTests: {
...(wizardData.candidateTests || {}),
[stream.url]: catchResult,
} as CandidateTestMap,
});
toast.error( toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }), t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
); );
@ -196,7 +273,7 @@ export default function Step3StreamConfig({
}); });
}); });
}, },
[updateStream, t], [updateStream, t, onUpdate, wizardData.candidateTests],
); );
const setRestream = useCallback( const setRestream = useCallback(
@ -277,17 +354,100 @@ export default function Step3StreamConfig({
{t("cameraWizard.step3.url")} {t("cameraWizard.step3.url")}
</label> </label>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Input {isProbeMode && probeCandidates.length > 0 ? (
value={stream.url} <Popover
onChange={(e) => open={openCombobox === stream.id}
updateStream(stream.id, { onOpenChange={(isOpen) => {
url: e.target.value, setOpenCombobox(isOpen ? stream.id : null);
testResult: undefined, }}
}) >
} <PopoverTrigger asChild>
className="h-8 flex-1" <div className="min-w-0 flex-1">
placeholder={t("cameraWizard.step3.streamUrlPlaceholder")} <Button
/> variant="outline"
role="combobox"
aria-expanded={openCombobox === stream.id}
className="h-8 w-full justify-between overflow-hidden text-left"
>
<span className="truncate">
{stream.url
? stream.url
: t("cameraWizard.step3.selectStream")}
</span>
<ChevronsUpDown className="ml-2 size-6 opacity-50" />
</Button>
</div>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-2"
disablePortal
>
<Command>
<CommandInput
placeholder={t(
"cameraWizard.step3.searchCandidates",
)}
className="h-9"
/>
<CommandList>
<CommandEmpty>
{t("cameraWizard.step3.noStreamFound")}
</CommandEmpty>
<CommandGroup>
{probeCandidates
.filter((c) => {
const used = getUsedUrlsExcludingStream(
stream.id,
);
return !used.has(c);
})
.map((candidate) => (
<CommandItem
key={candidate}
value={candidate}
onSelect={() => {
updateStream(stream.id, {
url: candidate,
testResult:
candidateTests[candidate] ||
undefined,
userTested:
!!candidateTests[candidate],
});
setOpenCombobox(null);
}}
>
<Check
className={cn(
"mr-3",
stream.url === candidate
? "opacity-100"
: "opacity-0",
)}
/>
{candidate}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={stream.url}
onChange={(e) =>
updateStream(stream.id, {
url: e.target.value,
testResult: undefined,
})
}
className="h-8 flex-1"
placeholder={t(
"cameraWizard.step3.streamUrlPlaceholder",
)}
/>
)}
<Button <Button
type="button" type="button"
onClick={() => testStream(stream)} onClick={() => testStream(stream)}
@ -468,7 +628,7 @@ export default function Step3StreamConfig({
<Button <Button
type="button" type="button"
onClick={() => onNext?.()} onClick={() => onNext?.()}
disabled={!canProceed} disabled={!canProceed || testingStreams.size > 0}
variant="select" variant="select"
className="sm:flex-1" className="sm:flex-1"
> >

View File

@ -115,6 +115,8 @@ export type WizardFormData = {
probeMode?: boolean; // true for probe, false for manual probeMode?: boolean; // true for probe, false for manual
onvifPort?: number; onvifPort?: number;
probeResult?: OnvifProbeResponse; probeResult?: OnvifProbeResponse;
probeCandidates?: string[]; // candidate URLs from probe
candidateTests?: CandidateTestMap; // test results for candidates
}; };
// API Response Types // API Response Types