From 7f916dfb476a4ebb975f817f88ad5b1c86c99741 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Wed, 4 Feb 2026 09:26:47 -0600
Subject: [PATCH] add review classification zones to review form
---
.../config-form/section-configs/review.ts | 12 +-
.../CameraReviewClassification.tsx | 368 ++++++++++++++++++
.../CameraReviewStatusToggles.tsx | 164 ++++++++
.../config-form/sectionExtras/registry.ts | 21 +-
.../theme/templates/FieldTemplate.tsx | 4 +-
web/src/types/configForm.ts | 3 +
6 files changed, 551 insertions(+), 21 deletions(-)
create mode 100644 web/src/components/config-form/sectionExtras/CameraReviewClassification.tsx
create mode 100644 web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx
diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts
index ddea33f9d..0d6d6b37e 100644
--- a/web/src/components/config-form/section-configs/review.ts
+++ b/web/src/components/config-form/section-configs/review.ts
@@ -9,16 +9,22 @@ const review: SectionConfigOverrides = {
"enabled_in_config",
"alerts.labels",
"alerts.enabled_in_config",
- "alerts.required_zones",
"detections.labels",
"detections.enabled_in_config",
- "detections.required_zones",
"genai.enabled_in_config",
],
advancedFields: [],
uiSchema: {
alerts: {
- "ui:before": { render: "CameraReviewSettingsView" },
+ "ui:before": { render: "CameraReviewStatusToggles" },
+ required_zones: {
+ "ui:widget": "hidden",
+ },
+ },
+ detections: {
+ required_zones: {
+ "ui:widget": "hidden",
+ },
},
genai: {
additional_concerns: {
diff --git a/web/src/components/config-form/sectionExtras/CameraReviewClassification.tsx b/web/src/components/config-form/sectionExtras/CameraReviewClassification.tsx
new file mode 100644
index 000000000..3f2146d88
--- /dev/null
+++ b/web/src/components/config-form/sectionExtras/CameraReviewClassification.tsx
@@ -0,0 +1,368 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Link } from "react-router-dom";
+import { Trans, useTranslation } from "react-i18next";
+import cloneDeep from "lodash/cloneDeep";
+import get from "lodash/get";
+import set from "lodash/set";
+import { LuExternalLink } from "react-icons/lu";
+import { MdCircle } from "react-icons/md";
+import Heading from "@/components/ui/heading";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+import { useDocDomain } from "@/hooks/use-doc-domain";
+import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
+import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
+import { getTranslatedLabel } from "@/utils/i18n";
+import { formatList } from "@/utils/stringUtil";
+import type { ConfigSectionData, JsonObject } from "@/types/configForm";
+import type { SectionRendererProps } from "./registry";
+
+const EMPTY_ZONES: string[] = [];
+
+function getRequiredZones(
+ formData: JsonObject | undefined,
+ path: string,
+): string[] {
+ const value = get(formData, path);
+ return Array.isArray(value) ? (value as string[]) : EMPTY_ZONES;
+}
+
+export default function CameraReviewClassification({
+ formContext,
+ selectedCamera,
+}: SectionRendererProps) {
+ const { t } = useTranslation(["views/settings", "common"]);
+ const { getLocaleDocUrl } = useDocDomain();
+ const cameraName = formContext?.cameraName ?? selectedCamera;
+ const fullFormData = formContext?.formData as JsonObject | undefined;
+ const cameraConfig = formContext?.fullCameraConfig;
+
+ const alertsZones = useMemo(
+ () => getRequiredZones(fullFormData, "alerts.required_zones"),
+ [fullFormData],
+ );
+ const detectionsZones = useMemo(
+ () => getRequiredZones(fullFormData, "detections.required_zones"),
+ [fullFormData],
+ );
+
+ const [selectDetections, setSelectDetections] = useState(
+ detectionsZones.length > 0,
+ );
+ const previousCameraRef = useRef(cameraName);
+ const isSynced = formContext?.hasChanges === false;
+
+ useEffect(() => {
+ const cameraChanged = previousCameraRef.current !== cameraName;
+ if (cameraChanged) {
+ previousCameraRef.current = cameraName;
+ }
+
+ if (cameraChanged || isSynced) {
+ setSelectDetections(detectionsZones.length > 0);
+ }
+ }, [cameraName, detectionsZones.length, isSynced]);
+
+ const zones = useMemo(() => {
+ if (!cameraConfig) {
+ return undefined;
+ }
+ return Object.entries(cameraConfig.zones).map(([name, zoneData]) => {
+ const zone =
+ zoneData as (typeof cameraConfig.zones)[keyof typeof cameraConfig.zones];
+ return {
+ camera: cameraConfig.name,
+ name,
+ friendly_name: cameraConfig.zones[name].friendly_name,
+ objects: zone.objects,
+ color: zone.color,
+ };
+ });
+ }, [cameraConfig]);
+
+ const alertsLabels = useMemo(() => {
+ return cameraConfig?.review.alerts.labels
+ ? formatList(
+ cameraConfig.review.alerts.labels.map((label: string) =>
+ getTranslatedLabel(
+ label,
+ cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
+ ),
+ ),
+ )
+ : "";
+ }, [cameraConfig]);
+
+ const detectionsLabels = useMemo(() => {
+ return cameraConfig?.review.detections.labels
+ ? formatList(
+ cameraConfig.review.detections.labels.map((label: string) =>
+ getTranslatedLabel(
+ label,
+ cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
+ ),
+ ),
+ )
+ : "";
+ }, [cameraConfig]);
+
+ const selectCameraName = useCameraFriendlyName(cameraName);
+
+ const getZoneName = useCallback(
+ (zoneId: string, camId?: string) =>
+ resolveZoneName(formContext?.fullConfig, zoneId, camId),
+ [formContext?.fullConfig],
+ );
+
+ const updateFormData = useCallback(
+ (path: string, nextValue: string[]) => {
+ if (!formContext?.onFormDataChange || !fullFormData) {
+ return;
+ }
+ const nextData = cloneDeep(fullFormData) as JsonObject;
+ set(nextData, path, nextValue);
+ formContext.onFormDataChange(nextData as ConfigSectionData);
+ },
+ [formContext, fullFormData],
+ );
+
+ const handleZoneToggle = useCallback(
+ (path: string, zoneName: string) => {
+ const currentZones = getRequiredZones(fullFormData, path);
+ const nextZones = currentZones.includes(zoneName)
+ ? currentZones.filter((value) => value !== zoneName)
+ : [...currentZones, zoneName];
+ updateFormData(path, nextZones);
+ },
+ [fullFormData, updateFormData],
+ );
+
+ const handleDetectionsToggle = useCallback(
+ (checked: boolean | string) => {
+ const isChecked = checked === true;
+ if (!isChecked) {
+ updateFormData("detections.required_zones", []);
+ }
+ setSelectDetections(isChecked);
+ },
+ [updateFormData],
+ );
+
+ if (!cameraName || formContext?.level !== "camera") {
+ return null;
+ }
+
+ return (
+
+
+
+ cameraReview.reviewClassification.title
+
+
+
+
+
+
+
+ cameraReview.reviewClassification.desc
+
+
+
+
+ {t("readTheDocumentation", { ns: "common" })}
+
+
+
+
+
+
+
0 && "grid items-start gap-5 md:grid-cols-2",
+ )}
+ >
+
+ {zones && zones.length > 0 ? (
+ <>
+
+
+
+
+ cameraReview.reviewClassification.selectAlertsZones
+
+
+
+
+ {zones.map((zone) => (
+
+
+ handleZoneToggle("alerts.required_zones", zone.name)
+ }
+ />
+
+
+ ))}
+
+ >
+ ) : (
+
+
+ cameraReview.reviewClassification.noDefinedZones
+
+
+ )}
+
+
+ {alertsZones.length > 0
+ ? t("cameraReview.reviewClassification.zoneObjectAlertsTips", {
+ alertsLabels,
+ zone: formatList(
+ alertsZones.map((zone) => getZoneName(zone, cameraName)),
+ ),
+ cameraName: selectCameraName,
+ })
+ : t("cameraReview.reviewClassification.objectAlertsTips", {
+ alertsLabels,
+ cameraName: selectCameraName,
+ })}
+
+
+
+
+ {zones && zones.length > 0 && (
+ <>
+
+
+ {selectDetections && (
+
+
+ cameraReview.reviewClassification.selectDetectionsZones
+
+
+ )}
+
+
+ {selectDetections && (
+
+ {zones.map((zone) => (
+
+
+ handleZoneToggle(
+ "detections.required_zones",
+ zone.name,
+ )
+ }
+ />
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ {detectionsZones.length > 0 ? (
+ !selectDetections ? (
+
+ getZoneName(zone, cameraName),
+ ),
+ ),
+ cameraName: selectCameraName,
+ }}
+ ns="views/settings"
+ />
+ ) : (
+
+ getZoneName(zone, cameraName),
+ ),
+ ),
+ cameraName: selectCameraName,
+ }}
+ ns="views/settings"
+ />
+ )
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx
new file mode 100644
index 000000000..a6d02253b
--- /dev/null
+++ b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx
@@ -0,0 +1,164 @@
+import { useMemo } from "react";
+import useSWR from "swr";
+import { Trans } from "react-i18next";
+import Heading from "@/components/ui/heading";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { FrigateConfig } from "@/types/frigateConfig";
+import {
+ useAlertsState,
+ useDetectionsState,
+ useObjectDescriptionState,
+ useReviewDescriptionState,
+} from "@/api/ws";
+import type { SectionRendererProps } from "./registry";
+import CameraReviewClassification from "./CameraReviewClassification";
+
+export default function CameraReviewStatusToggles({
+ selectedCamera,
+ formContext,
+}: SectionRendererProps) {
+ const { data: config } = useSWR("config");
+ const cameraId = selectedCamera ?? "";
+
+ const cameraConfig = useMemo(() => {
+ if (config && selectedCamera) {
+ return config.cameras[selectedCamera];
+ }
+ }, [config, selectedCamera]);
+
+ const { payload: alertsState, send: sendAlerts } = useAlertsState(cameraId);
+ const { payload: detectionsState, send: sendDetections } =
+ useDetectionsState(cameraId);
+
+ const { payload: objDescState, send: sendObjDesc } =
+ useObjectDescriptionState(cameraId);
+ const { payload: revDescState, send: sendRevDesc } =
+ useReviewDescriptionState(cameraId);
+
+ if (!selectedCamera || !cameraConfig) {
+ return null;
+ }
+
+ return (
+
+
+ cameraReview.title
+
+
+
+
+
{
+ sendAlerts(isChecked ? "ON" : "OFF");
+ }}
+ />
+
+
+
+
+
+
+
{
+ sendDetections(isChecked ? "ON" : "OFF");
+ }}
+ />
+
+
+
+
+
+ cameraReview.review.desc
+
+
+
+
+ {cameraConfig?.objects?.genai?.enabled_in_config && (
+ <>
+
+
+
+
+ cameraReview.object_descriptions.title
+
+
+
+
+
+
{
+ sendObjDesc(isChecked ? "ON" : "OFF");
+ }}
+ />
+
+
+
+
+
+
+ cameraReview.object_descriptions.desc
+
+
+
+ >
+ )}
+
+ {cameraConfig?.review?.genai?.enabled_in_config && (
+ <>
+
+
+
+
+ cameraReview.review_descriptions.title
+
+
+
+
+
+
{
+ sendRevDesc(isChecked ? "ON" : "OFF");
+ }}
+ />
+
+
+
+
+
+
+ cameraReview.review_descriptions.desc
+
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts
index 908577019..6057cd066 100644
--- a/web/src/components/config-form/sectionExtras/registry.ts
+++ b/web/src/components/config-form/sectionExtras/registry.ts
@@ -1,12 +1,13 @@
-import { createElement } from "react";
import type { ComponentType } from "react";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
-import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView.tsx";
+import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
+import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
export type SectionRendererProps = {
selectedCamera?: string;
setUnsavedChanges?: (hasChanges: boolean) => void;
+ formContext?: ConfigFormContext;
[key: string]: unknown; // Allow additional props from uiSchema
};
@@ -17,20 +18,6 @@ export type SectionRenderers = Record<
Record
>;
-const CameraReviewSettingsRenderer: RendererComponent = ({
- selectedCamera,
- setUnsavedChanges,
-}) => {
- if (!selectedCamera) {
- return null;
- }
-
- return createElement(CameraReviewSettingsView, {
- selectedCamera,
- setUnsavedChanges,
- });
-};
-
// Section renderers registry
// Used to register custom renderer components for specific config sections.
// Maps a section key (e.g., `semantic_search`) to a mapping of renderer
@@ -55,7 +42,7 @@ export const sectionRenderers: SectionRenderers = {
SemanticSearchReindex,
},
review: {
- CameraReviewSettingsView: CameraReviewSettingsRenderer,
+ CameraReviewStatusToggles,
},
};
diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx
index fc77b4d97..c651a2cda 100644
--- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx
+++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx
@@ -324,7 +324,9 @@ export function FieldTemplate(props: FieldTemplateProps) {
const renderers = formContext?.renderers;
const RenderComponent = renderers?.[renderKey];
if (RenderComponent) {
- return ;
+ return (
+
+ );
}
}
diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts
index ca4ea25d4..02e718060 100644
--- a/web/src/types/configForm.ts
+++ b/web/src/types/configForm.ts
@@ -18,6 +18,9 @@ export type ConfigFormContext = {
cameraName?: string;
globalValue?: JsonValue;
cameraValue?: JsonValue;
+ hasChanges?: boolean;
+ formData?: JsonObject;
+ onFormDataChange?: (data: ConfigSectionData) => void;
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;
i18nNamespace?: string;