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}}",
"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",

View File

@ -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({
</Button>
</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>
</CardContent>
</Card>

View File

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

View File

@ -48,21 +48,11 @@ export default function Step2ProbeOrSnapshot({
const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>(
[],
);
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
{} 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,
// 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],
});
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(
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 && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.retry")}
{isTesting ? (
<>
<ActivityIndicator className="size-4" />{" "}
{t("button.continue", { ns: "common" })}
</>
) : (
t("cameraWizard.step2.retry")
)}
</Button>
)}
</div>
@ -715,12 +697,12 @@ function ProbeFooterButtons({
<Button
type="button"
onClick={onTestAll}
disabled={isTesting || selectedCount === 0}
disabled={isTesting || (candidateCount ?? 0) === 0}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting && <ActivityIndicator className="size-4" />}
{t("cameraWizard.step2.testConnection")}
{t("button.next", { ns: "common" })}
</Button>
</div>
);

View File

@ -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<WizardFormData>;
@ -44,19 +55,48 @@ export default function Step3StreamConfig({
const { t } = useTranslation(["views/settings", "components/dialog"]);
const { getLocaleDocUrl } = useDocDomain();
const [testingStreams, setTestingStreams] = useState<Set<string>>(new Set());
const [openCombobox, setOpenCombobox] = useState<string | null>(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<string>();
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,6 +354,86 @@ export default function Step3StreamConfig({
{t("cameraWizard.step3.url")}
</label>
<div className="flex flex-row items-center gap-2">
{isProbeMode && probeCandidates.length > 0 ? (
<Popover
open={openCombobox === stream.id}
onOpenChange={(isOpen) => {
setOpenCombobox(isOpen ? stream.id : null);
}}
>
<PopoverTrigger asChild>
<div className="min-w-0 flex-1">
<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) =>
@ -286,8 +443,11 @@ export default function Step3StreamConfig({
})
}
className="h-8 flex-1"
placeholder={t("cameraWizard.step3.streamUrlPlaceholder")}
placeholder={t(
"cameraWizard.step3.streamUrlPlaceholder",
)}
/>
)}
<Button
type="button"
onClick={() => testStream(stream)}
@ -468,7 +628,7 @@ export default function Step3StreamConfig({
<Button
type="button"
onClick={() => onNext?.()}
disabled={!canProceed}
disabled={!canProceed || testingStreams.size > 0}
variant="select"
className="sm:flex-1"
>

View File

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