Files
frigate/web/src/components/settings/wizard/Step2ProbeOrSnapshot.tsx
T
Josh HawkinsandGitHub caa6edecac Migrate web to ESLint 10 flat config (#24326)
* migrate web to eslint 10 flat config

ESLint 10 dropped `.eslintrc` support, so `.eslintrc.cjs` is replaced with `eslint.config.js` and the lint scripts no longer pass `--ext` or `--ignore-path`. typescript-eslint moves to 8, react-hooks to 7, and react-refresh to 0.5, and the unused jest and vitest-globals plugins are removed. Lint behaves as it did before: catch variables aren't checked, unused disable directives aren't reported, and rules newly added to the recommended sets are off until the code passes them. typescript-eslint 8 flags constants used only in `typeof`, so those are now exported, or replaced with a union type where the export would trip react-refresh.

* fix lint findings from the eslint 10 recommended rules

Remove the rule overrides from the flat config migration and fix what they were hiding. Unused catch bindings are dropped, 20 disable directives that suppressed nothing are removed (react-hooks 5.2 and 7.1.1 report identical exhaustive-deps findings with inline config ignored), dead initial values are dropped, short-circuit calls become if statements or optional calls, rethrown errors pass `cause`, and the disabled "No recordings" tooltip in `ReviewTimeline` is removed along with its memo and the `getRecordingAvailability` prop. The 3 react-refresh warnings for files that export contexts or classes are left for a later refactor.
2026-09-14 07:53:45 -06:00

747 lines
23 KiB
TypeScript

import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useState, useCallback, useEffect } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios";
import { toast } from "sonner";
import type {
WizardFormData,
TestResult,
StreamConfig,
StreamRole,
OnvifProbeResponse,
CandidateTestMap,
FfprobeStream,
FfprobeData,
FfprobeResponse,
} from "@/types/cameraWizard";
import { FaCircleCheck } from "react-icons/fa6";
import { Card, CardContent, CardTitle } from "../../ui/card";
import OnvifProbeResults from "./OnvifProbeResults";
import { CAMERA_BRANDS } from "@/types/cameraWizard";
import { detectReolinkCamera } from "@/utils/cameraUtil";
type Step2ProbeOrSnapshotProps = {
wizardData: Partial<WizardFormData>;
onUpdate: (data: Partial<WizardFormData>) => void;
onNext: (data?: Partial<WizardFormData>) => void;
onBack: () => void;
probeMode: boolean;
};
export default function Step2ProbeOrSnapshot({
wizardData,
onUpdate,
onNext,
onBack,
probeMode,
}: Step2ProbeOrSnapshotProps) {
const { t } = useTranslation(["views/settings"]);
const [isTesting, setIsTesting] = useState(false);
const [testStatus, setTestStatus] = useState<string>("");
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [isProbing, setIsProbing] = useState(false);
const [probeError, setProbeError] = useState<string | null>(null);
const [probeResult, setProbeResult] = useState<OnvifProbeResponse | null>(
null,
);
const [testingCandidates, setTestingCandidates] = useState<
Record<string, boolean>
>({} as Record<string, boolean>);
const [candidateTests, setCandidateTests] = useState<CandidateTestMap>(
{} as CandidateTestMap,
);
const probeUri = useCallback(
async (
uri: string,
fetchSnapshot = false,
setStatus?: (s: string) => void,
): Promise<TestResult> => {
try {
const probeResponse = await axios.get("ffprobe", {
params: { paths: uri, detailed: true },
timeout: 10000,
});
let probeData: FfprobeResponse | null = null;
if (
probeResponse.data &&
probeResponse.data.length > 0 &&
probeResponse.data[0].return_code === 0
) {
probeData = probeResponse.data[0];
}
if (!probeData) {
const error =
Array.isArray(probeResponse.data?.[0]?.stderr) &&
probeResponse.data[0].stderr.length > 0
? probeResponse.data[0].stderr.join("\n")
: "Unable to probe stream";
return { success: false, error };
}
let ffprobeData: FfprobeData;
if (typeof probeData.stdout === "string") {
try {
ffprobeData = JSON.parse(probeData.stdout as string) as FfprobeData;
} catch {
ffprobeData = { streams: [] };
}
} else {
ffprobeData = probeData.stdout as FfprobeData;
}
const streams = ffprobeData.streams || [];
const videoStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("hevc"),
);
const audioStream = streams.find(
(s: FfprobeStream) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3") ||
s.codec_name?.includes("pcm_mulaw") ||
s.codec_name?.includes("pcm_alaw"),
);
let resolution: string | undefined = undefined;
if (videoStream) {
const width = Number(videoStream.width || 0);
const height = Number(videoStream.height || 0);
if (width > 0 && height > 0) {
resolution = `${width}x${height}`;
}
}
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
let snapshotBase64: string | undefined = undefined;
if (fetchSnapshot) {
if (setStatus) {
setStatus(t("cameraWizard.step2.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) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(snapshotBlob);
});
} catch {
snapshotBase64 = undefined;
}
}
const streamTestResult: TestResult = {
success: true,
snapshot: snapshotBase64,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
return streamTestResult;
} catch (err) {
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 probeCamera = useCallback(async () => {
if (!wizardData.host) {
toast.error(t("cameraWizard.step2.errors.hostRequired"));
return;
}
setIsProbing(true);
setProbeError(null);
setProbeResult(null);
try {
const response = await axios.get("/onvif/probe", {
params: {
host: wizardData.host,
port: wizardData.onvifPort ?? 80,
username: wizardData.username || "",
password: wizardData.password || "",
test: false,
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
},
timeout: 30000,
});
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);
const update: Partial<WizardFormData> = {
probeMode: true,
probeResult: response.data,
probeCandidates: candidateUris,
candidateTests: {},
};
const manufacturer: string = response.data.manufacturer || "";
if (
manufacturer.toLowerCase().includes("reolink") &&
wizardData.host &&
wizardData.username &&
wizardData.password
) {
const protocol = await detectReolinkCamera(
wizardData.host,
wizardData.username,
wizardData.password,
);
if (protocol === "http-flv") {
update.brandTemplate = "reolink";
}
}
onUpdate(update);
} 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.step2.probeFailed", { error: errorMessage }));
} finally {
setIsProbing(false);
}
}, [wizardData, onUpdate, t]);
const testAllSelectedCandidates = useCallback(async () => {
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;
}
// 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],
});
}
// 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) => {
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 generateDynamicStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string | null> => {
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return null;
let protocol = undefined;
if (data.brandTemplate === "reolink" && data.username && data.password) {
try {
protocol = await detectReolinkCamera(
data.host,
data.username,
data.password,
);
} catch {
return null;
}
}
const protocolKey = protocol || "rtsp";
const templates: Record<string, string> = brand.dynamicTemplates || {};
if (Object.keys(templates).includes(protocolKey)) {
const template =
templates[protocolKey as keyof typeof brand.dynamicTemplates];
return template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
}
return null;
},
[],
);
const generateStreamUrl = useCallback(
async (data: Partial<WizardFormData>): Promise<string> => {
if (data.brandTemplate === "other") {
return data.customUrl || "";
}
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
if (!brand || !data.host) return "";
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
const dynamicUrl = await generateDynamicStreamUrl(data);
if (dynamicUrl) {
return dynamicUrl;
}
return "";
}
return brand.template
.replace("{username}", data.username || "")
.replace("{password}", data.password || "")
.replace("{host}", data.host);
},
[generateDynamicStreamUrl],
);
const testConnection = useCallback(
async (showToast = true) => {
const streamUrl = await generateStreamUrl(wizardData);
if (!streamUrl) {
toast.error(t("cameraWizard.commonErrors.noUrl"));
return;
}
setIsTesting(true);
setTestStatus("");
setTestResult(null);
try {
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
const result = await probeUri(streamUrl, true, setTestStatus);
if (result && result.success) {
setTestResult(result);
const streamId = `stream_${Date.now()}`;
onUpdate({
streams: [
{
id: streamId,
url: streamUrl,
roles: ["detect"] as StreamRole[],
testResult: result,
},
],
});
if (showToast) {
toast.success(t("cameraWizard.step2.testSuccess"));
}
} else {
const errMsg = result?.error || "Unable to probe stream";
setTestResult({
success: false,
error: errMsg,
});
if (showToast) {
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errMsg }),
{
duration: 6000,
},
);
}
}
} 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({
success: false,
error: errorMessage,
});
if (showToast) {
toast.error(
t("cameraWizard.commonErrors.testFailed", { error: errorMessage }),
{
duration: 10000,
},
);
}
} finally {
setIsTesting(false);
setTestStatus("");
}
},
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
);
const handleContinue = useCallback(() => {
onNext();
}, [onNext]);
// Auto-start probe or test when step loads
const [hasStarted, setHasStarted] = useState(false);
useEffect(() => {
if (!hasStarted) {
setHasStarted(true);
if (probeMode) {
probeCamera();
} else {
// Auto-run the connection test but suppress toasts to avoid duplicates
testConnection(false);
}
}
}, [hasStarted, probeMode, probeCamera, testConnection]);
return (
<div className="space-y-6">
{probeMode ? (
// Probe mode: show probe results directly
<>
{probeResult && (
<div className="space-y-4">
<OnvifProbeResults
isLoading={isProbing}
isError={!!probeError}
error={probeError || undefined}
probeResult={probeResult}
onRetry={probeCamera}
testCandidate={testCandidate}
candidateTests={candidateTests}
testingCandidates={testingCandidates}
/>
</div>
)}
<ProbeFooterButtons
isProbing={isProbing}
probeError={probeError}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
// 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
}
/>
</>
) : (
// Manual mode: show snapshot and stream details
<>
{testResult?.success && (
<div className="p-4">
<div className="mb-3 flex flex-row items-center gap-2 text-sm font-medium text-success">
<FaCircleCheck className="size-4" />
{t("cameraWizard.step2.testSuccess")}
</div>
<div className="space-y-3">
{testResult.snapshot ? (
<div className="relative flex justify-center">
<img
src={testResult.snapshot}
alt="Camera snapshot"
className="max-h-[50dvh] max-w-full rounded-lg object-contain"
/>
<div className="absolute bottom-2 right-2 rounded-md bg-black/70 p-3 text-sm backdrop-blur-sm">
<div className="space-y-1">
<StreamDetails testResult={testResult} />
</div>
</div>
</div>
) : (
<Card className="p-4">
<CardTitle className="mb-2 text-sm">
{t("cameraWizard.step2.streamDetails")}
</CardTitle>
<CardContent className="p-0 text-sm">
<StreamDetails testResult={testResult} />
</CardContent>
</Card>
)}
</div>
</div>
)}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
{testResult && !testResult.success && (
<div className="space-y-4">
<div className="text-sm text-destructive">{testResult.error}</div>
</div>
)}
<ProbeFooterButtons
mode="manual"
isProbing={false}
probeError={null}
onBack={onBack}
onTestAll={testAllSelectedCandidates}
onRetry={probeCamera}
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}
/>
</>
)}
</div>
);
}
function StreamDetails({ testResult }: { testResult: TestResult }) {
const { t } = useTranslation(["views/settings"]);
return (
<>
{testResult.resolution && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.resolution")}:
</span>{" "}
<span className="text-white">{testResult.resolution}</span>
</div>
)}
{testResult.fps && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.fps")}:
</span>{" "}
<span className="text-white">{testResult.fps}</span>
</div>
)}
{testResult.videoCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.video")}:
</span>{" "}
<span className="text-white">{testResult.videoCodec}</span>
</div>
)}
{testResult.audioCodec && (
<div>
<span className="text-white/70">
{t("cameraWizard.testResultLabels.audio")}:
</span>{" "}
<span className="text-white">{testResult.audioCodec}</span>
</div>
)}
</>
);
}
type ProbeFooterProps = {
isProbing: boolean;
probeError: string | null;
onBack: () => void;
onTestAll: () => void;
onRetry: () => void;
isTesting: boolean;
candidateCount?: number;
mode?: "probe" | "manual";
manualTestSuccess?: boolean;
onContinue?: () => void;
onManualTest?: () => void;
};
function ProbeFooterButtons({
isProbing,
probeError,
onBack,
onTestAll,
onRetry,
isTesting,
candidateCount = 0,
mode = "probe",
manualTestSuccess,
onContinue,
onManualTest,
}: ProbeFooterProps) {
const { t } = useTranslation(["views/settings"]);
// Loading footer
if (isProbing) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</div>
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} disabled className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
disabled
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
<ActivityIndicator className="size-4" />
{t("cameraWizard.step2.probing")}
</Button>
</div>
</div>
);
}
// Error footer
if (probeError) {
return (
<div className="space-y-4">
<div className="text-sm text-destructive">{probeError}</div>
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onRetry}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("cameraWizard.step2.retry")}
</Button>
</div>
</div>
);
}
// Default footer: show back + test (test disabled if none selected or testing)
// If manual mode, show Continue when test succeeded, otherwise show Test (calls onManualTest)
if (mode === "manual") {
return (
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
{manualTestSuccess ? (
<Button
type="button"
onClick={onContinue}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.continue", { ns: "common" })}
</Button>
) : (
<Button
type="button"
onClick={onManualTest}
disabled={isTesting}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{isTesting ? (
<>
<ActivityIndicator className="size-4" />{" "}
{t("button.continue", { ns: "common" })}
</>
) : (
t("cameraWizard.step2.retry")
)}
</Button>
)}
</div>
);
}
// Default probe footer
return (
<div className="flex flex-col-reverse gap-2 pt-3 sm:flex-row sm:justify-end">
<Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Button
type="button"
onClick={onTestAll}
disabled={isTesting || (candidateCount ?? 0) === 0}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
>
{t("button.next", { ns: "common" })}
</Button>
</div>
);
}