Add Camera Wizard (#20461)

* fetch more from ffprobe

* add detailed param to ffprobe endpoint

* add dots variant to step indicator

* add classname

* tweak colors for dark mode to match figma

* add step 1 form

* add helper function for ffmpeg snapshot

* add go2rtc stream add and ffprobe snapshot endpoints

* add camera image and stream details on successful test

* step 1 tweaks

* step 2 and i18n

* types

* step 1 and 2 tweaks

* add wizard to camera settings view

* add data unit i18n keys

* restream tweak

* fix type

* implement rough idea for step 3

* add api endpoint to delete stream from go2rtc

* add main wizard dialog component

* extract logic for friendly_name and use in wizard

* add i18n and popover for brand url

* add camera name to top

* consolidate validation logic

* prevent dialog from closing when clicking outside

* center camera name on mobile

* add help/docs link popovers

* keep spaces in friendly name

* add stream details to overlay like stats in liveplayer

* add validation results pane to step 3

* ensure test is invalidated if stream is changed

* only display validation results and enable save button if all streams have been tested

* tweaks

* normalize camera name to lower case and improve hash generation

* move wizard to subfolder

* tweaks

* match look of camera edit form to wizard

* move wizard and edit form to its own component

* move enabled/disabled switch to management section

* clean up

* fixes

* fix mobile
This commit is contained in:
Josh Hawkins
2025-10-13 11:52:08 -05:00
committed by GitHub
parent 423693d14d
commit 9d85136f8f
19 changed files with 3571 additions and 429 deletions

View File

@@ -0,0 +1,199 @@
import Heading from "@/components/ui/heading";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Toaster } from "sonner";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { LuPlus } from "react-icons/lu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { Switch } from "@/components/ui/switch";
import { Trans } from "react-i18next";
import { Separator } from "@/components/ui/separator";
import { useEnabledState } from "@/api/ws";
type CameraManagementViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function CameraManagementView({
setUnsavedChanges,
}: CameraManagementViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
"settings",
); // Control view state
const [editCameraName, setEditCameraName] = useState<string | undefined>(
undefined,
); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
// List of cameras for dropdown
const cameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras).sort();
}
return [];
}, [config]);
useEffect(() => {
document.title = t("documentTitle.cameraManagement");
}, [t]);
// Handle back navigation from add/edit form
const handleBack = useCallback(() => {
setViewMode("settings");
setEditCameraName(undefined);
setUnsavedChanges(false);
updateConfig();
}, [updateConfig, setUnsavedChanges]);
return (
<>
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<div className="my-4 flex flex-col gap-4">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{cameras.length > 0 && (
<>
<div className="my-4 flex flex-col gap-2">
<Label>{t("cameraManagement.editCamera")}</Label>
<Select
onValueChange={(value) => {
setEditCameraName(value);
setViewMode("edit");
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue
placeholder={t("cameraManagement.selectCamera")}
/>
</SelectTrigger>
<SelectContent>
{cameras.map((camera) => {
return (
<SelectItem key={camera} value={camera}>
<CameraNameLabel camera={camera} />
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="max-w-7xl space-y-4">
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraManagement.streams.title
</Trans>
</Heading>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraManagement.streams.desc
</Trans>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
{cameras.map((camera) => (
<div
key={camera}
className="flex items-center justify-between smart-capitalize"
>
<CameraNameLabel camera={camera} />
<CameraEnableSwitch cameraName={camera} />
</div>
))}
</div>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
</>
)}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center gap-2">
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleBack}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
<div className="md:max-w-5xl">
<CameraEditForm
cameraName={viewMode === "edit" ? editCameraName : undefined}
onSave={handleBack}
onCancel={handleBack}
/>
</div>
</>
)}
</div>
</div>
<CameraWizardDialog
open={showWizard}
onClose={() => setShowWizard(false)}
/>
</>
);
}
type CameraEnableSwitchProps = {
cameraName: string;
};
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
return (
<div className="flex flex-row items-center">
<Switch
id={`camera-enabled-${cameraName}`}
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
/>
</div>
);
}

View File

@@ -34,23 +34,14 @@ import { getTranslatedLabel } from "@/utils/i18n";
import {
useAlertsState,
useDetectionsState,
useEnabledState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import CameraEditForm from "@/components/settings/CameraEditForm";
import { LuPlus } from "react-icons/lu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type CameraSettingsViewProps = {
selectedCamera: string;
@@ -87,17 +78,10 @@ export default function CameraSettingsView({
const [editCameraName, setEditCameraName] = useState<string | undefined>(
undefined,
); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// List of cameras for dropdown
const cameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras).sort();
}
return [];
}, [config]);
const selectCameraName = useCameraFriendlyName(selectedCamera);
// zones and labels
@@ -148,8 +132,6 @@ export default function CameraSettingsView({
const watchedAlertsZones = form.watch("alerts_zones");
const watchedDetectionsZones = form.watch("detections_zones");
const { payload: enabledState, send: sendEnabled } =
useEnabledState(selectedCamera);
const { payload: alertsState, send: sendAlerts } =
useAlertsState(selectedCamera);
const { payload: detectionsState, send: sendDetections } =
@@ -202,9 +184,12 @@ export default function CameraSettingsView({
})
.then((res) => {
if (res.status === 200) {
toast.success(t("camera.reviewClassification.toast.success"), {
position: "top-center",
});
toast.success(
t("cameraReview.reviewClassification.toast.success"),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(
@@ -272,7 +257,7 @@ export default function CameraSettingsView({
if (changedValue) {
addMessage(
"camera_settings",
t("camera.reviewClassification.unsavedChanges", {
t("cameraReview.reviewClassification.unsavedChanges", {
camera: selectedCamera,
}),
undefined,
@@ -295,7 +280,7 @@ export default function CameraSettingsView({
}
useEffect(() => {
document.title = t("documentTitle.camera");
document.title = t("documentTitle.cameraReview");
}, [t]);
// Handle back navigation from add/edit form
@@ -317,70 +302,11 @@ export default function CameraSettingsView({
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
{t("camera.title")}
</Heading>
<div className="mb-4 flex flex-col gap-4">
<Button
variant="select"
onClick={() => setViewMode("add")}
className="flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("camera.addCamera")}
</Button>
{cameras.length > 0 && (
<div className="flex items-center gap-2">
<Label>{t("camera.editCamera")}</Label>
<Select
onValueChange={(value) => {
setEditCameraName(value);
setViewMode("edit");
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("camera.selectCamera")} />
</SelectTrigger>
<SelectContent>
{cameras.map((camera) => {
return (
<SelectItem key={camera} value={camera}>
<CameraNameLabel camera={camera} />
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">camera.streams.title</Trans>
{t("cameraReview.title")}
</Heading>
<div className="flex flex-row items-center">
<Switch
id="camera-enabled"
className="mr-3"
checked={enabledState === "ON"}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="camera-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">camera.streams.desc</Trans>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">camera.review.title</Trans>
<Trans ns="views/settings">cameraReview.review.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
@@ -395,7 +321,9 @@ export default function CameraSettingsView({
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">camera.review.alerts</Trans>
<Trans ns="views/settings">
cameraReview.review.alerts
</Trans>
</Label>
</div>
</div>
@@ -418,7 +346,7 @@ export default function CameraSettingsView({
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">camera.review.desc</Trans>
<Trans ns="views/settings">cameraReview.review.desc</Trans>
</div>
</div>
</div>
@@ -428,7 +356,7 @@ export default function CameraSettingsView({
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
camera.object_descriptions.title
cameraReview.object_descriptions.title
</Trans>
</Heading>
@@ -450,7 +378,7 @@ export default function CameraSettingsView({
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
camera.object_descriptions.desc
cameraReview.object_descriptions.desc
</Trans>
</div>
</div>
@@ -463,7 +391,7 @@ export default function CameraSettingsView({
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
camera.review_descriptions.title
cameraReview.review_descriptions.title
</Trans>
</Heading>
@@ -485,7 +413,7 @@ export default function CameraSettingsView({
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
camera.review_descriptions.desc
cameraReview.review_descriptions.desc
</Trans>
</div>
</div>
@@ -496,7 +424,7 @@ export default function CameraSettingsView({
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
camera.reviewClassification.title
cameraReview.reviewClassification.title
</Trans>
</Heading>
@@ -504,7 +432,7 @@ export default function CameraSettingsView({
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">
camera.reviewClassification.desc
cameraReview.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
@@ -550,7 +478,7 @@ export default function CameraSettingsView({
</FormLabel>
<FormDescription>
<Trans ns="views/settings">
camera.reviewClassification.selectAlertsZones
cameraReview.reviewClassification.selectAlertsZones
</Trans>
</FormDescription>
</div>
@@ -599,7 +527,7 @@ export default function CameraSettingsView({
) : (
<div className="font-normal text-destructive">
<Trans ns="views/settings">
camera.reviewClassification.noDefinedZones
cameraReview.reviewClassification.noDefinedZones
</Trans>
</div>
)}
@@ -607,7 +535,7 @@ export default function CameraSettingsView({
<div className="text-sm">
{watchedAlertsZones && watchedAlertsZones.length > 0
? t(
"camera.reviewClassification.zoneObjectAlertsTips",
"cameraReview.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: watchedAlertsZones
@@ -622,7 +550,7 @@ export default function CameraSettingsView({
},
)
: t(
"camera.reviewClassification.objectAlertsTips",
"cameraReview.reviewClassification.objectAlertsTips",
{
alertsLabels,
cameraName: selectCameraName,
@@ -650,7 +578,7 @@ export default function CameraSettingsView({
{selectDetections && (
<FormDescription>
<Trans ns="views/settings">
camera.reviewClassification.selectDetectionsZones
cameraReview.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
@@ -713,7 +641,7 @@ export default function CameraSettingsView({
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans ns="views/settings">
camera.reviewClassification.limitDetections
cameraReview.reviewClassification.limitDetections
</Trans>
</label>
</div>
@@ -726,7 +654,7 @@ export default function CameraSettingsView({
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.text"
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: watchedDetectionsZones
@@ -743,7 +671,7 @@ export default function CameraSettingsView({
/>
) : (
<Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: watchedDetectionsZones
@@ -761,7 +689,7 @@ export default function CameraSettingsView({
)
) : (
<Trans
i18nKey="camera.reviewClassification.objectDetectionsTips"
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: selectCameraName,
@@ -835,6 +763,11 @@ export default function CameraSettingsView({
)}
</div>
</div>
<CameraWizardDialog
open={showWizard}
onClose={() => setShowWizard(false)}
/>
</>
);
}