mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-07 22:05:44 +03:00
add optional probe dialog to wizard step 1
This commit is contained in:
parent
ce3ff199e9
commit
d74688eb37
323
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
323
web/src/components/settings/wizard/OnvifProbeResults.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Card, CardContent, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { FaExclamationCircle, FaCopy, FaCheck } from "react-icons/fa";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type {
|
||||||
|
OnvifProbeResponse,
|
||||||
|
OnvifRtspCandidate,
|
||||||
|
TestResult,
|
||||||
|
CandidateTestMap,
|
||||||
|
} from "@/types/cameraWizard";
|
||||||
|
|
||||||
|
type OnvifProbeResultsProps = {
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error?: string;
|
||||||
|
probeResult?: OnvifProbeResponse;
|
||||||
|
onSelectCandidate: (uri: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
selectedUris?: string[];
|
||||||
|
testCandidate?: (uri: string) => void;
|
||||||
|
candidateTests?: CandidateTestMap;
|
||||||
|
testingCandidates?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OnvifProbeResults({
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
probeResult,
|
||||||
|
onSelectCandidate,
|
||||||
|
onRetry,
|
||||||
|
selectedUris,
|
||||||
|
testCandidate,
|
||||||
|
candidateTests,
|
||||||
|
testingCandidates,
|
||||||
|
}: OnvifProbeResultsProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const [copiedUri, setCopiedUri] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopyUri = (uri: string) => {
|
||||||
|
navigator.clipboard.writeText(uri);
|
||||||
|
setCopiedUri(uri);
|
||||||
|
toast.success(t("cameraWizard.step1.uriCopied"));
|
||||||
|
setTimeout(() => setCopiedUri(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||||
|
<ActivityIndicator className="size-6" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("cameraWizard.step1.probingDevice")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||||
|
<FaExclamationCircle className="mt-1 size-5 flex-shrink-0 text-destructive" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-destructive">
|
||||||
|
{t("cameraWizard.step1.probeError")}
|
||||||
|
</h3>
|
||||||
|
{error && <p className="text-sm text-muted-foreground">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||||
|
{t("button.retry", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!probeResult?.success) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 p-4">
|
||||||
|
<FaExclamationCircle className="mt-1 size-5 flex-shrink-0 text-amber-600" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-amber-900">
|
||||||
|
{t("cameraWizard.step1.probeNoSuccess")}
|
||||||
|
</h3>
|
||||||
|
{probeResult?.message && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{probeResult.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onRetry} variant="outline" className="w-full">
|
||||||
|
{t("button.retry", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rtspCandidates = probeResult.rtsp_candidates || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardTitle className="border-b p-4 text-sm">
|
||||||
|
{t("cameraWizard.step1.deviceInfo")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardContent className="space-y-2 p-4 text-sm">
|
||||||
|
{probeResult.manufacturer && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Manufacturer:</span>{" "}
|
||||||
|
<span className="font-medium">{probeResult.manufacturer}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{probeResult.model && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Model:</span>{" "}
|
||||||
|
<span className="font-medium">{probeResult.model}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{probeResult.firmware_version && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Firmware:</span>{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{probeResult.firmware_version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{probeResult.profiles_count !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Profiles:</span>{" "}
|
||||||
|
<span className="font-medium">{probeResult.profiles_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{probeResult.ptz_supported !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">PTZ Support:</span>{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{probeResult.ptz_supported ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{probeResult.ptz_supported && probeResult.autotrack_supported && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Autotrack Support:</span>{" "}
|
||||||
|
<span className="font-medium">Yes</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{probeResult.ptz_supported &&
|
||||||
|
probeResult.presets_count !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Presets:</span>{" "}
|
||||||
|
<span className="font-medium">{probeResult.presets_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{rtspCandidates.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{t("cameraWizard.step1.rtspCandidates")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rtspCandidates.map((candidate, idx) => {
|
||||||
|
const isSelected = !!selectedUris?.includes(candidate.uri);
|
||||||
|
const candidateTest = candidateTests?.[candidate.uri];
|
||||||
|
const isTesting = testingCandidates?.[candidate.uri];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CandidateItem
|
||||||
|
key={idx}
|
||||||
|
candidate={candidate}
|
||||||
|
copiedUri={copiedUri}
|
||||||
|
onCopy={() => handleCopyUri(candidate.uri)}
|
||||||
|
onUse={() => onSelectCandidate(candidate.uri)}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onTest={() => testCandidate && testCandidate(candidate.uri)}
|
||||||
|
candidateTest={candidateTest}
|
||||||
|
isTesting={isTesting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandidateItemProps = {
|
||||||
|
candidate: OnvifRtspCandidate;
|
||||||
|
isTested?: boolean;
|
||||||
|
copiedUri: string | null;
|
||||||
|
onCopy: () => void;
|
||||||
|
onUse: () => void;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onTest?: () => void;
|
||||||
|
candidateTest?: TestResult | { success: false; error: string };
|
||||||
|
isTesting?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CandidateItem({
|
||||||
|
candidate,
|
||||||
|
isTested,
|
||||||
|
copiedUri,
|
||||||
|
onCopy,
|
||||||
|
onUse,
|
||||||
|
isSelected,
|
||||||
|
onTest,
|
||||||
|
candidateTest,
|
||||||
|
isTesting,
|
||||||
|
}: CandidateItemProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
const [showFull, setShowFull] = useState(false);
|
||||||
|
|
||||||
|
// Mask credentials for display
|
||||||
|
const maskUri = (uri: string) => {
|
||||||
|
const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
|
||||||
|
if (match) {
|
||||||
|
return `rtsp://${match[1]}:••••@${match[3]}`;
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-3 ${
|
||||||
|
isSelected ? "border-selected bg-selected/10" : "border-input bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isTested !== undefined && (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
isTested ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isTested ? "✓ OK" : "✗ Failed"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<p
|
||||||
|
className="cursor-pointer break-all text-xs text-muted-foreground hover:underline"
|
||||||
|
onClick={() => setShowFull(!showFull)}
|
||||||
|
title="Click to toggle masked/full view"
|
||||||
|
>
|
||||||
|
{showFull ? candidate.uri : maskUri(candidate.uri)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onCopy}
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Copy URI"
|
||||||
|
>
|
||||||
|
{copiedUri === candidate.uri ? (
|
||||||
|
<FaCheck className="size-3" />
|
||||||
|
) : (
|
||||||
|
<FaCopy className="size-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onTest}
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<ActivityIndicator className="size-3" />
|
||||||
|
) : (
|
||||||
|
t("cameraWizard.step1.test")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onUse}
|
||||||
|
variant="default"
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
>
|
||||||
|
{t("cameraWizard.step1.useCandidate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{candidateTest && candidateTest.success && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{candidateTest.resolution && (
|
||||||
|
<span className="mr-2">
|
||||||
|
{`${t("cameraWizard.testResultLabels.resolution")} ${candidateTest.resolution}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{candidateTest.fps && (
|
||||||
|
<span className="mr-2">
|
||||||
|
{t("cameraWizard.testResultLabels.fps")} {candidateTest.fps}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{candidateTest.videoCodec && (
|
||||||
|
<span className="mr-2">
|
||||||
|
{`${t("cameraWizard.testResultLabels.video")} ${candidateTest.videoCodec}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{candidateTest.audioCodec && (
|
||||||
|
<span className="mr-2">
|
||||||
|
{`${t("cameraWizard.testResultLabels.audio")} ${candidateTest.audioCodec}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
web/src/components/settings/wizard/ProbeDialog.tsx
Normal file
126
web/src/components/settings/wizard/ProbeDialog.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import OnvifProbeResults from "./OnvifProbeResults";
|
||||||
|
import {} from "@/components/ui/card";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import type {
|
||||||
|
OnvifProbeResponse,
|
||||||
|
CandidateTestMap,
|
||||||
|
} from "@/types/cameraWizard";
|
||||||
|
import StepIndicator from "@/components/indicators/StepIndicator";
|
||||||
|
|
||||||
|
type ProbeDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error?: string;
|
||||||
|
probeResult?: OnvifProbeResponse | null;
|
||||||
|
onSelectCandidate: (uri: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
selectedCandidateUris?: string[];
|
||||||
|
testAllSelectedCandidates?: () => void;
|
||||||
|
isTesting?: boolean;
|
||||||
|
testStatus?: string;
|
||||||
|
testCandidate?: (uri: string) => void;
|
||||||
|
candidateTests?: CandidateTestMap;
|
||||||
|
testingCandidates?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProbeDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
probeResult,
|
||||||
|
onSelectCandidate,
|
||||||
|
onRetry,
|
||||||
|
selectedCandidateUris,
|
||||||
|
testAllSelectedCandidates,
|
||||||
|
isTesting,
|
||||||
|
testStatus,
|
||||||
|
testCandidate,
|
||||||
|
candidateTests,
|
||||||
|
testingCandidates,
|
||||||
|
}: ProbeDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"cameraWizard.steps.nameAndConnection",
|
||||||
|
"cameraWizard.steps.streamConfiguration",
|
||||||
|
"cameraWizard.steps.validationAndTesting",
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<StepIndicator
|
||||||
|
steps={STEPS}
|
||||||
|
currentStep={0}
|
||||||
|
variant="dots"
|
||||||
|
className="mb-4 justify-start"
|
||||||
|
/>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("cameraWizard.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("cameraWizard.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="p-0">
|
||||||
|
<OnvifProbeResults
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
probeResult={probeResult || undefined}
|
||||||
|
onSelectCandidate={onSelectCandidate}
|
||||||
|
onRetry={onRetry}
|
||||||
|
selectedUris={selectedCandidateUris}
|
||||||
|
testCandidate={testCandidate}
|
||||||
|
candidateTests={candidateTests}
|
||||||
|
testingCandidates={testingCandidates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{isTesting && testStatus && (
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
<span>{testStatus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button
|
||||||
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
|
onClick={testAllSelectedCandidates}
|
||||||
|
disabled={
|
||||||
|
!(selectedCandidateUris && selectedCandidateUris.length > 0) ||
|
||||||
|
isLoading ||
|
||||||
|
isTesting
|
||||||
|
}
|
||||||
|
variant="select"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{isTesting && <ActivityIndicator className="size-4" />}
|
||||||
|
<span>{t("cameraWizard.step1.testConnection")}</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -33,9 +34,13 @@ import {
|
|||||||
CAMERA_BRAND_VALUES,
|
CAMERA_BRAND_VALUES,
|
||||||
TestResult,
|
TestResult,
|
||||||
FfprobeStream,
|
FfprobeStream,
|
||||||
|
FfprobeData,
|
||||||
|
FfprobeResponse,
|
||||||
StreamRole,
|
StreamRole,
|
||||||
StreamConfig,
|
StreamConfig,
|
||||||
|
OnvifProbeResponse,
|
||||||
} from "@/types/cameraWizard";
|
} from "@/types/cameraWizard";
|
||||||
|
import type { CandidateTestMap } from "@/types/cameraWizard";
|
||||||
import { FaCircleCheck } from "react-icons/fa6";
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
import { Card, CardContent, CardTitle } from "../../ui/card";
|
import { Card, CardContent, CardTitle } from "../../ui/card";
|
||||||
import {
|
import {
|
||||||
@ -45,6 +50,7 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { LuInfo } from "react-icons/lu";
|
import { LuInfo } from "react-icons/lu";
|
||||||
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
import { detectReolinkCamera } from "@/utils/cameraUtil";
|
||||||
|
import ProbeDialog from "./ProbeDialog";
|
||||||
|
|
||||||
type Step1NameCameraProps = {
|
type Step1NameCameraProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
wizardData: Partial<WizardFormData>;
|
||||||
@ -66,6 +72,23 @@ export default function Step1NameCamera({
|
|||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testStatus, setTestStatus] = useState<string>("");
|
const [testStatus, setTestStatus] = useState<string>("");
|
||||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
const [probeMode, setProbeMode] = useState<boolean>(true);
|
||||||
|
const [onvifPort, setOnvifPort] = useState<number>(80);
|
||||||
|
const [isProbing, setIsProbing] = useState(false);
|
||||||
|
const [probeError, setProbeError] = useState<string | null>(null);
|
||||||
|
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [probeDialogOpen, setProbeDialogOpen] = useState(false);
|
||||||
|
const [selectedCandidateUris, setSelectedCandidateUris] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
|
||||||
|
{} as CandidateTestMap,
|
||||||
|
);
|
||||||
|
const [testingCandidates, setTestingCandidates] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({} as Record<string, boolean>);
|
||||||
|
|
||||||
const existingCameraNames = useMemo(() => {
|
const existingCameraNames = useMemo(() => {
|
||||||
if (!config?.cameras) {
|
if (!config?.cameras) {
|
||||||
@ -132,10 +155,17 @@ export default function Step1NameCamera({
|
|||||||
const watchedHost = form.watch("host");
|
const watchedHost = form.watch("host");
|
||||||
const watchedCustomUrl = form.watch("customUrl");
|
const watchedCustomUrl = form.watch("customUrl");
|
||||||
|
|
||||||
|
const hostPresent = !!(watchedHost && watchedHost.trim());
|
||||||
|
const customPresent = !!(watchedCustomUrl && watchedCustomUrl.trim());
|
||||||
|
const cameraNamePresent = !!(form.getValues().cameraName || "").trim();
|
||||||
|
|
||||||
const isTestButtonEnabled =
|
const isTestButtonEnabled =
|
||||||
watchedBrand === "other"
|
cameraNamePresent &&
|
||||||
? !!(watchedCustomUrl && watchedCustomUrl.trim())
|
(probeMode
|
||||||
: !!(watchedHost && watchedHost.trim());
|
? hostPresent
|
||||||
|
: watchedBrand === "other"
|
||||||
|
? customPresent
|
||||||
|
: hostPresent);
|
||||||
|
|
||||||
const generateDynamicStreamUrl = useCallback(
|
const generateDynamicStreamUrl = useCallback(
|
||||||
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
async (data: z.infer<typeof step1FormData>): Promise<string | null> => {
|
||||||
@ -200,28 +230,79 @@ export default function Step1NameCamera({
|
|||||||
[generateDynamicStreamUrl],
|
[generateDynamicStreamUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testConnection = useCallback(async () => {
|
const probeCamera = useCallback(async () => {
|
||||||
const data = form.getValues();
|
const data = form.getValues();
|
||||||
const streamUrl = await generateStreamUrl(data);
|
|
||||||
|
|
||||||
if (!streamUrl) {
|
if (!data.host) {
|
||||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
toast.error(t("cameraWizard.step1.errors.hostRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsTesting(true);
|
setIsProbing(true);
|
||||||
setTestStatus("");
|
setProbeError(null);
|
||||||
setTestResult(null);
|
setProbeResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First get probe data for metadata
|
const response = await axios.get("/onvif/probe", {
|
||||||
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
params: {
|
||||||
|
host: data.host,
|
||||||
|
port: onvifPort,
|
||||||
|
username: data.username || "",
|
||||||
|
password: data.password || "",
|
||||||
|
test: false,
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
setProbeResult(response.data);
|
||||||
|
// open the probe dialog to show results
|
||||||
|
setProbeDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setProbeError(response.data?.message || "Probe failed");
|
||||||
|
}
|
||||||
|
} 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 ||
|
||||||
|
"Failed to probe camera";
|
||||||
|
setProbeError(errorMessage);
|
||||||
|
toast.error(t("cameraWizard.step1.probeFailed", { error: errorMessage }));
|
||||||
|
} finally {
|
||||||
|
setIsProbing(false);
|
||||||
|
}
|
||||||
|
}, [form, onvifPort, t]);
|
||||||
|
|
||||||
|
const handleSelectCandidate = useCallback((uri: string) => {
|
||||||
|
// toggle selection: add or remove from selectedCandidateUris
|
||||||
|
setSelectedCandidateUris((s) => {
|
||||||
|
if (s.includes(uri)) {
|
||||||
|
return s.filter((u) => u !== uri);
|
||||||
|
}
|
||||||
|
return [...s, uri];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Probe a single URI and return a TestResult. If fetchSnapshot is true,
|
||||||
|
// also attempt to fetch a snapshot (may be undefined on failure).
|
||||||
|
const probeUri = useCallback(
|
||||||
|
async (
|
||||||
|
uri: string,
|
||||||
|
fetchSnapshot = false,
|
||||||
|
setStatus?: (s: string) => void,
|
||||||
|
): Promise<TestResult> => {
|
||||||
|
try {
|
||||||
const probeResponse = await axios.get("ffprobe", {
|
const probeResponse = await axios.get("ffprobe", {
|
||||||
params: { paths: streamUrl, detailed: true },
|
params: { paths: uri, detailed: true },
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
let probeData = null;
|
let probeData: FfprobeResponse | null = null;
|
||||||
if (
|
if (
|
||||||
probeResponse.data &&
|
probeResponse.data &&
|
||||||
probeResponse.data.length > 0 &&
|
probeResponse.data.length > 0 &&
|
||||||
@ -230,25 +311,27 @@ export default function Step1NameCamera({
|
|||||||
probeData = probeResponse.data[0];
|
probeData = probeResponse.data[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then get snapshot for preview (only if probe succeeded)
|
if (!probeData) {
|
||||||
let snapshotBlob = null;
|
const error =
|
||||||
if (probeData) {
|
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
||||||
setTestStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
|
probeResponse.data[0].stderr.length > 0
|
||||||
try {
|
? probeResponse.data[0].stderr.join("\n")
|
||||||
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
: "Unable to probe stream";
|
||||||
params: { url: streamUrl },
|
return { success: false, error };
|
||||||
responseType: "blob",
|
}
|
||||||
timeout: 10000,
|
|
||||||
});
|
// stdout may be a string or structured object. Normalize to FfprobeData.
|
||||||
snapshotBlob = snapshotResponse.data;
|
let ffprobeData: FfprobeData;
|
||||||
} catch (snapshotError) {
|
if (typeof probeData.stdout === "string") {
|
||||||
// Snapshot is optional, don't fail if it doesn't work
|
try {
|
||||||
toast.warning(t("cameraWizard.step1.warnings.noSnapshot"));
|
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
|
||||||
}
|
} catch {
|
||||||
|
ffprobeData = { streams: [] };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ffprobeData = probeData.stdout as FfprobeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (probeData) {
|
|
||||||
const ffprobeData = probeData.stdout;
|
|
||||||
const streams = ffprobeData.streams || [];
|
const streams = ffprobeData.streams || [];
|
||||||
|
|
||||||
const videoStream = streams.find(
|
const videoStream = streams.find(
|
||||||
@ -271,23 +354,34 @@ export default function Step1NameCamera({
|
|||||||
? `${videoStream.width}x${videoStream.height}`
|
? `${videoStream.width}x${videoStream.height}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Extract FPS from rational (e.g., "15/1" -> 15)
|
|
||||||
const fps = videoStream?.avg_frame_rate
|
const fps = videoStream?.avg_frame_rate
|
||||||
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
|
||||||
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
parseFloat(videoStream.avg_frame_rate.split("/")[1])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Convert snapshot blob to base64 if available
|
let snapshotBase64: string | undefined = undefined;
|
||||||
let snapshotBase64 = undefined;
|
if (fetchSnapshot) {
|
||||||
if (snapshotBlob) {
|
if (setStatus) {
|
||||||
|
setStatus(t("cameraWizard.step1.testing.fetchingSnapshot"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const snapshotResponse = await axios.get("ffprobe/snapshot", {
|
||||||
|
params: { url: uri },
|
||||||
|
responseType: "blob",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
const snapshotBlob = snapshotResponse.data;
|
||||||
snapshotBase64 = await new Promise<string>((resolve) => {
|
snapshotBase64 = await new Promise<string>((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => resolve(reader.result as string);
|
reader.onload = () => resolve(reader.result as string);
|
||||||
reader.readAsDataURL(snapshotBlob);
|
reader.readAsDataURL(snapshotBlob);
|
||||||
});
|
});
|
||||||
|
} catch (snapshotError) {
|
||||||
|
snapshotBase64 = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const testResult: TestResult = {
|
const streamTestResult: TestResult = {
|
||||||
success: true,
|
success: true,
|
||||||
snapshot: snapshotBase64,
|
snapshot: snapshotBase64,
|
||||||
resolution,
|
resolution,
|
||||||
@ -296,22 +390,155 @@ export default function Step1NameCamera({
|
|||||||
fps: fps && !isNaN(fps) ? fps : undefined,
|
fps: fps && !isNaN(fps) ? fps : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setTestResult(testResult);
|
return streamTestResult;
|
||||||
onUpdate({ streams: [{ id: "", url: "", roles: [], testResult }] });
|
} catch (err) {
|
||||||
toast.success(t("cameraWizard.step1.testSuccess"));
|
const axiosError = err as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.detail ||
|
||||||
|
axiosError.message ||
|
||||||
|
"Connection failed";
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const testAllSelectedCandidates = useCallback(async () => {
|
||||||
|
const uris = selectedCandidateUris;
|
||||||
|
if (!uris || uris.length === 0) {
|
||||||
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
||||||
|
|
||||||
|
const streamConfigs: StreamConfig[] = [];
|
||||||
|
let firstSuccessfulTestResult: TestResult | null = null;
|
||||||
|
let firstSuccessfulUri: string | undefined = undefined;
|
||||||
|
|
||||||
|
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,
|
||||||
|
// Only the first stream should have the detect role
|
||||||
|
roles:
|
||||||
|
streamConfigs.length === 0
|
||||||
|
? (["detect"] as StreamRole[])
|
||||||
|
: ([] as StreamRole[]),
|
||||||
|
testResult: streamTestResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
// keep first successful for main pane snapshot
|
||||||
|
if (!firstSuccessfulTestResult) {
|
||||||
|
firstSuccessfulTestResult = streamTestResult;
|
||||||
|
firstSuccessfulUri = uri;
|
||||||
|
}
|
||||||
|
// also store candidate test summary
|
||||||
|
setCandidateTests((s) => ({ ...s, [uri]: streamTestResult }));
|
||||||
} else {
|
} else {
|
||||||
const error =
|
setCandidateTests((s) => ({
|
||||||
Array.isArray(probeResponse.data?.[0]?.stderr) &&
|
...s,
|
||||||
probeResponse.data[0].stderr.length > 0
|
[uri]: streamTestResult,
|
||||||
? probeResponse.data[0].stderr.join("\n")
|
}));
|
||||||
: "Unable to probe stream";
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamConfigs.length > 0) {
|
||||||
|
// Add all successful streams and navigate to Step 2
|
||||||
|
onNext({ streams: streamConfigs, customUrl: firstSuccessfulUri });
|
||||||
|
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||||
|
setProbeDialogOpen(false);
|
||||||
|
setProbeResult(null);
|
||||||
|
} 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";
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
error: error,
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
toast.error(t("cameraWizard.commonErrors.testFailed", { error }), {
|
toast.error(
|
||||||
|
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
setTestStatus("");
|
||||||
|
}
|
||||||
|
}, [selectedCandidateUris, t, onNext, probeUri]);
|
||||||
|
|
||||||
|
const testCandidate = useCallback(
|
||||||
|
async (uri: string) => {
|
||||||
|
if (!uri) return;
|
||||||
|
setTestingCandidates((s) => ({ ...s, [uri]: true }));
|
||||||
|
try {
|
||||||
|
const result = await probeUri(uri, false);
|
||||||
|
setCandidateTests((s) => ({ ...s, [uri]: result }));
|
||||||
|
} finally {
|
||||||
|
setTestingCandidates((s) => ({ ...s, [uri]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[probeUri],
|
||||||
|
);
|
||||||
|
|
||||||
|
const testConnection = useCallback(async () => {
|
||||||
|
const _data = form.getValues();
|
||||||
|
const streamUrl = await generateStreamUrl(_data);
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
|
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestStatus("");
|
||||||
|
setTestResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setTestStatus(t("cameraWizard.step1.testing.probingMetadata"));
|
||||||
|
const result = await probeUri(streamUrl, true, setTestStatus);
|
||||||
|
|
||||||
|
if (result && result.success) {
|
||||||
|
setTestResult(result);
|
||||||
|
onUpdate({
|
||||||
|
streams: [{ id: "", url: streamUrl, roles: [], testResult: result }],
|
||||||
|
});
|
||||||
|
toast.success(t("cameraWizard.step1.testSuccess"));
|
||||||
|
} else {
|
||||||
|
const errMsg = result?.error || "Unable to probe stream";
|
||||||
|
setTestResult({
|
||||||
|
success: false,
|
||||||
|
error: errMsg,
|
||||||
|
});
|
||||||
|
toast.error(
|
||||||
|
t("cameraWizard.commonErrors.testFailed", { error: errMsg }),
|
||||||
|
{
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as {
|
const axiosError = error as {
|
||||||
@ -337,7 +564,7 @@ export default function Step1NameCamera({
|
|||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
setTestStatus("");
|
setTestStatus("");
|
||||||
}
|
}
|
||||||
}, [form, generateStreamUrl, t, onUpdate]);
|
}, [form, generateStreamUrl, t, onUpdate, probeUri]);
|
||||||
|
|
||||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||||
onUpdate(data);
|
onUpdate(data);
|
||||||
@ -397,74 +624,7 @@ export default function Step1NameCamera({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<div className="space-y-4">
|
||||||
control={form.control}
|
|
||||||
name="brandTemplate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="flex items-center gap-1 pb-1">
|
|
||||||
<FormLabel className="text-primary-variant">
|
|
||||||
{t("cameraWizard.step1.cameraBrand")}
|
|
||||||
</FormLabel>
|
|
||||||
{field.value &&
|
|
||||||
(() => {
|
|
||||||
const selectedBrand = CAMERA_BRANDS.find(
|
|
||||||
(brand) => brand.value === field.value,
|
|
||||||
);
|
|
||||||
return selectedBrand &&
|
|
||||||
selectedBrand.value != "other" ? (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-4 w-4 p-0"
|
|
||||||
>
|
|
||||||
<LuInfo className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="pointer-events-auto w-80 text-primary-variant">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-medium">
|
|
||||||
{selectedBrand.label}
|
|
||||||
</h4>
|
|
||||||
<p className="break-all text-sm text-muted-foreground">
|
|
||||||
{t("cameraWizard.step1.brandUrlFormat", {
|
|
||||||
exampleUrl: selectedBrand.exampleUrl,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger className="h-8">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("cameraWizard.step1.selectBrand")}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{CAMERA_BRANDS.map((brand) => (
|
|
||||||
<SelectItem key={brand.value} value={brand.value}>
|
|
||||||
{brand.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{watchedBrand !== "other" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="host"
|
name="host"
|
||||||
@ -544,9 +704,136 @@ export default function Step1NameCamera({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-4">
|
||||||
|
<FormLabel className="text-primary-variant">
|
||||||
|
{t("cameraWizard.step1.detectionMethod")}
|
||||||
|
</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
value={probeMode ? "probe" : "manual"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setProbeMode(value === "probe");
|
||||||
|
setProbeResult(null);
|
||||||
|
setProbeError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="probe" id="probe-mode" />
|
||||||
|
<label
|
||||||
|
htmlFor="probe-mode"
|
||||||
|
className="cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
{t("cameraWizard.step1.probeMode")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="manual" id="manual-mode" />
|
||||||
|
<label
|
||||||
|
htmlFor="manual-mode"
|
||||||
|
className="cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
{t("cameraWizard.step1.manualMode")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{probeMode && (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-primary-variant">
|
||||||
|
{t("cameraWizard.step1.onvifPort")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-md h-8"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={onvifPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOnvifPort(parseInt(e.target.value, 10) || 80)
|
||||||
|
}
|
||||||
|
placeholder="80"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!probeMode && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="brandTemplate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center gap-1 pb-1">
|
||||||
|
<FormLabel className="text-primary-variant">
|
||||||
|
{t("cameraWizard.step1.cameraBrand")}
|
||||||
|
</FormLabel>
|
||||||
|
{field.value &&
|
||||||
|
(() => {
|
||||||
|
const selectedBrand = CAMERA_BRANDS.find(
|
||||||
|
(brand) => brand.value === field.value,
|
||||||
|
);
|
||||||
|
return selectedBrand &&
|
||||||
|
selectedBrand.value != "other" ? (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0"
|
||||||
|
>
|
||||||
|
<LuInfo className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="pointer-events-auto w-80 text-primary-variant">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium">
|
||||||
|
{selectedBrand.label}
|
||||||
|
</h4>
|
||||||
|
<p className="break-all text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"cameraWizard.step1.brandUrlFormat",
|
||||||
|
{
|
||||||
|
exampleUrl:
|
||||||
|
selectedBrand.exampleUrl,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"cameraWizard.step1.selectBrand",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{CAMERA_BRANDS.map((brand) => (
|
||||||
|
<SelectItem key={brand.value} value={brand.value}>
|
||||||
|
{brand.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{watchedBrand == "other" && (
|
{watchedBrand == "other" && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -568,11 +855,35 @@ export default function Step1NameCamera({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{probeMode && probeResult && (
|
||||||
|
<div className="p-4">
|
||||||
|
<ProbeDialog
|
||||||
|
open={probeDialogOpen}
|
||||||
|
onOpenChange={setProbeDialogOpen}
|
||||||
|
isLoading={isProbing}
|
||||||
|
isError={!!probeError}
|
||||||
|
error={probeError || undefined}
|
||||||
|
probeResult={probeResult}
|
||||||
|
onSelectCandidate={handleSelectCandidate}
|
||||||
|
onRetry={probeCamera}
|
||||||
|
selectedCandidateUris={selectedCandidateUris}
|
||||||
|
testAllSelectedCandidates={testAllSelectedCandidates}
|
||||||
|
isTesting={isTesting}
|
||||||
|
testStatus={testStatus}
|
||||||
|
testCandidate={testCandidate}
|
||||||
|
candidateTests={candidateTests}
|
||||||
|
testingCandidates={testingCandidates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{testResult?.success && (
|
{testResult?.success && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
|
||||||
@ -636,12 +947,16 @@ export default function Step1NameCamera({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={testConnection}
|
onClick={probeMode ? probeCamera : testConnection}
|
||||||
disabled={isTesting || !isTestButtonEnabled}
|
disabled={
|
||||||
|
(probeMode ? isProbing : isTesting) || !isTestButtonEnabled
|
||||||
|
}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{t("cameraWizard.step1.testConnection")}
|
{probeMode
|
||||||
|
? t("cameraWizard.step1.probeMode")
|
||||||
|
: t("cameraWizard.step1.testConnection")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user