diff --git a/web/package-lock.json b/web/package-lock.json index 3bf882213..75100ba90 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", @@ -1182,6 +1183,35 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz", + "integrity": "sha512-0i/EKJ222Afa1FE0C6pNJxDq1itzcl3HChE9DwskA4th4KRse8ojx8a1nVcOjwJdbpDLcz7uol77yYnQNMHdKw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index b2c442085..704507c82 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 3d6a40c39..9193d9c2d 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -143,20 +143,6 @@ export default function ZoneEditPane({ config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time, isFinished: polygon?.isFinished ?? false, objects: polygon?.objects ?? [], - review_alerts: - (polygon?.camera && - polygon?.name && - config?.cameras[ - polygon.camera - ]?.review.alerts.required_zones.includes(polygon.name)) || - false, - review_detections: - (polygon?.camera && - polygon?.name && - config?.cameras[ - polygon.camera - ]?.review.detections.required_zones.includes(polygon.name)) || - false, }, }); @@ -167,8 +153,6 @@ export default function ZoneEditPane({ inertia, loitering_time, objects: form_objects, - review_alerts, - review_detections, }: ZoneFormValuesType, // values submitted via the form objects: string[], ) => { @@ -176,11 +160,21 @@ export default function ZoneEditPane({ return; } let mutatedConfig = config; + let alertQueries = ""; + let detectionQueries = ""; const renamingZone = zoneName != polygon.name && polygon.name != ""; if (renamingZone) { // rename - delete old zone and replace with new + const zoneInAlerts = + cameraConfig?.review.alerts.required_zones.includes(polygon.name) ?? + false; + const zoneInDetections = + cameraConfig?.review.detections.required_zones.includes( + polygon.name, + ) ?? false; + const { alertQueries: renameAlertQueries, detectionQueries: renameDetectionQueries, @@ -209,6 +203,18 @@ export default function ZoneEditPane({ }); return; } + + // make sure new zone name is readded to review + ({ alertQueries, detectionQueries } = reviewQueries( + zoneName, + zoneInAlerts, + zoneInDetections, + polygon.camera, + mutatedConfig?.cameras[polygon.camera]?.review.alerts + .required_zones || [], + mutatedConfig?.cameras[polygon.camera]?.review.detections + .required_zones || [], + )); } const coordinates = flattenPoints( @@ -233,17 +239,6 @@ export default function ZoneEditPane({ objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`; } - const { alertQueries, detectionQueries } = reviewQueries( - zoneName, - review_alerts, - review_detections, - polygon.camera, - mutatedConfig?.cameras[polygon.camera]?.review.alerts.required_zones || - [], - mutatedConfig?.cameras[polygon.camera]?.review.detections - .required_zones || [], - ); - let inertiaQuery = ""; if (inertia) { inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`; @@ -449,52 +444,6 @@ export default function ZoneEditPane({ /> - - - ( - -
- Alerts - - When an object enters this zone, ensure it is marked as an - alert. - -
- - - -
- )} - /> - ( - -
- Detections - - When an object enters this zone, ensure it is marked as a - detection. - -
- - - -
- )} - /> , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7d501d23e..c355a97c6 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -30,6 +30,7 @@ import { PolygonType } from "@/types/canvas"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import scrollIntoView from "scroll-into-view-if-needed"; import GeneralSettingsView from "@/views/settings/GeneralSettingsView"; +import CameraSettingsView from "@/views/settings/CameraSettingsView"; import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; @@ -38,6 +39,7 @@ import AuthenticationView from "@/views/settings/AuthenticationView"; export default function Settings() { const settingsViews = [ "general", + "camera settings", "masks / zones", "motion tuner", "debug", @@ -136,6 +138,7 @@ export default function Settings() { {(page == "debug" || + page == "camera settings" || page == "masks / zones" || page == "motion tuner") && (
@@ -158,6 +161,12 @@ export default function Settings() { {page == "debug" && ( )} + {page == "camera settings" && ( + + )} {page == "masks / zones" && ( >; +}; + +type CameraReviewSettingsValueType = { + alerts_zones: string[]; + detections_zones: string[]; +}; + +export default function CameraSettingsView({ + selectedCamera, + setUnsavedChanges, +}: CameraSettingsViewProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + const [changedValue, setChangedValue] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectDetections, setSelectDetections] = useState(false); + + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + // zones and labels + + const zones = useMemo(() => { + if (cameraConfig) { + return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ + camera: cameraConfig.name, + name, + objects: zoneData.objects, + color: zoneData.color, + })); + } + }, [cameraConfig]); + + const alertsLabels = useMemo(() => { + return cameraConfig?.review.alerts.labels + ? cameraConfig.review.alerts.labels + .map((label) => label.replaceAll("_", " ")) + .join(", ") + : ""; + }, [cameraConfig]); + + const detectionsLabels = useMemo(() => { + return cameraConfig?.review.detections.labels + ? cameraConfig.review.detections.labels + .map((label) => label.replaceAll("_", " ")) + .join(", ") + : ""; + }, [cameraConfig]); + + // form + + const formSchema = z.object({ + alerts_zones: z.array(z.string()), + detections_zones: z.array(z.string()), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + alerts_zones: cameraConfig?.review.alerts.required_zones || [], + detections_zones: cameraConfig?.review.detections.required_zones || [], + }, + }); + + const watchedAlertsZones = form.watch("alerts_zones"); + const watchedDetectionsZones = form.watch("detections_zones"); + + const handleCheckedChange = useCallback( + (isChecked: boolean) => { + if (!isChecked) { + form.reset({ + alerts_zones: watchedAlertsZones, + detections_zones: + cameraConfig?.review.detections.required_zones || [], + }); + } + setChangedValue(true); + setSelectDetections(isChecked as boolean); + }, + [watchedAlertsZones, cameraConfig, form], + ); + + const saveToConfig = useCallback( + async ( + { alerts_zones, detections_zones }: CameraReviewSettingsValueType, // values submitted via the form + ) => { + const alertQueries = [...alerts_zones] + .map( + (zone) => + `&cameras.${selectedCamera}.review.alerts.required_zones=${zone}`, + ) + .join(""); + + const detectionQueries = [...detections_zones] + .map( + (zone) => + `&cameras.${selectedCamera}.review.detections.required_zones=${zone}`, + ) + .join(""); + + axios + .put(`config/set?${alertQueries}${detectionQueries}`, { + requires_restart: 0, + }) + .then((res) => { + if (res.status === 200) { + toast.success( + `Review classification configuration has been saved. Restart Frigate to apply changes.`, + { + position: "top-center", + }, + ); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [updateConfig, setIsLoading, selectedCamera], + ); + + const onCancel = useCallback(() => { + if (!cameraConfig) { + return; + } + + setChangedValue(false); + setUnsavedChanges(false); + removeMessage( + "camera_settings", + `review_classification_settings_${selectedCamera}`, + ); + form.reset({ + alerts_zones: cameraConfig?.review.alerts.required_zones ?? [], + detections_zones: cameraConfig?.review.detections.required_zones || [], + }); + setSelectDetections( + !!cameraConfig?.review.detections.required_zones?.length, + ); + }, [removeMessage, selectedCamera, setUnsavedChanges, form, cameraConfig]); + + useEffect(() => { + onCancel(); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCamera]); + + useEffect(() => { + if (changedValue) { + addMessage( + "camera_settings", + `Unsaved review classification settings for ${capitalizeFirstLetter(selectedCamera)}`, + undefined, + `review_classification_settings_${selectedCamera}`, + ); + } else { + removeMessage( + "camera_settings", + `review_classification_settings_${selectedCamera}`, + ); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedValue, selectedCamera]); + + function onSubmit(values: z.infer) { + setIsLoading(true); + + saveToConfig(values as CameraReviewSettingsValueType); + } + + useEffect(() => { + document.title = "Camera Settings - Frigate"; + }, []); + + if (!cameraConfig && !selectedCamera) { + return ; + } + + return ( + <> +
+ +
+ + Camera Settings + + + + + + Review Classification + + +
+
+

+ Frigate categorizes review items as Alerts and Detections. By + default, all person and car objects are + considered Alerts. You can refine categorization of your review + items by configuring required zones for them. +

+
+ + Read the Documentation{" "} + + +
+
+
+ +
+ +
0 && + "grid items-start gap-5 md:grid-cols-2", + )} + > + ( + + {zones && zones?.length > 0 ? ( + <> +
+ + Alerts{" "} + + + + Select zones for Alerts + +
+
+ {zones?.map((zone) => ( + { + return ( + + + { + setChangedValue(true); + return checked + ? field.onChange([ + ...field.value, + zone.name, + ]) + : field.onChange( + field.value?.filter( + (value) => + value !== zone.name, + ), + ); + }} + /> + + + {zone.name.replaceAll("_", " ")} + + + ); + }} + /> + ))} +
+ + ) : ( +
+ No zones are defined for this camera. +
+ )} + +
+ All {alertsLabels} objects + {watchedAlertsZones && watchedAlertsZones.length > 0 + ? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}` + : ""}{" "} + on{" "} + {capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " ")}{" "} + will be shown as Alerts. +
+
+ )} + /> + + ( + + {zones && zones?.length > 0 && ( + <> +
+ + Detections{" "} + + + {selectDetections && ( + + Select zones for Detections + + )} +
+ + {selectDetections && ( +
+ {zones?.map((zone) => ( + { + return ( + + + { + return checked + ? field.onChange([ + ...field.value, + zone.name, + ]) + : field.onChange( + field.value?.filter( + (value) => + value !== zone.name, + ), + ); + }} + /> + + + {zone.name.replaceAll("_", " ")} + + + ); + }} + /> + ))} +
+ )} + + +
+ +
+ +
+
+ + )} + +
+ All {detectionsLabels} objects{" "} + not classified as Alerts{" "} + {watchedDetectionsZones && + watchedDetectionsZones.length > 0 + ? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}` + : ""}{" "} + on{" "} + {capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " ")}{" "} + will be shown as Detections + {(!selectDetections || + (watchedDetectionsZones && + watchedDetectionsZones.length === 0)) && + ", regardless of zone"} + . +
+
+ )} + /> +
+ + +
+ + +
+ + +
+
+ + ); +}