mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 21:44:13 +03:00
refactor to select onvif urls via combobox in step 3
This commit is contained in:
parent
ccf2d2018b
commit
945d395525
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user