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}}",
|
||||
"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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user