Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frigate/config/camera/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ class ReviewConfig(FrigateBaseModel):
detections: DetectionsConfig = Field(
default_factory=DetectionsConfig,
title="Detections config",
description="Settings for creating detection events (non-alert) and how long to keep them.",
description="Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
)
genai: GenAIReviewConfig = Field(
default_factory=GenAIReviewConfig,
Expand Down
2 changes: 1 addition & 1 deletion web/public/locales/en/config/cameras.json
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@
},
"detections": {
"label": "Detections config",
"description": "Settings for creating detection events (non-alert) and how long to keep them.",
"description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
"enabled": {
"label": "Enable detections",
"description": "Enable or disable detection events for this camera."
Expand Down
2 changes: 1 addition & 1 deletion web/public/locales/en/config/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,7 @@
},
"detections": {
"label": "Detections config",
"description": "Settings for creating detection events (non-alert) and how long to keep them.",
"description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
"enabled": {
"label": "Enable detections",
"description": "Enable or disable detection events for all cameras; can be overridden per-camera."
Expand Down
8 changes: 7 additions & 1 deletion web/public/locales/en/views/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,11 @@
"summary": "{{count}} object types selected",
"empty": "No object labels available"
},
"reviewLabels": {
"summary": "{{count}} labels selected",
"empty": "No labels available",
"allNonAlertDetections": "All non-alert activity will be included as detections."
},
"filters": {
"objectFieldLabel": "{{field}} for {{label}}"
},
Expand Down Expand Up @@ -1474,7 +1479,8 @@
"timestamp_style": {
"title": "Timestamp Settings"
},
"searchPlaceholder": "Search..."
"searchPlaceholder": "Search...",
"addCustomLabel": "Add custom label..."
},
"globalConfig": {
"title": "Global Configuration",
Expand Down
20 changes: 18 additions & 2 deletions web/src/components/config-form/section-configs/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,42 @@ import type { SectionConfigOverrides } from "./types";
const review: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/review",
fieldDocs: {
"alerts.labels": "/configuration/review/#alerts-and-detections",
"detections.labels": "/configuration/review/#alerts-and-detections",
},
restartRequired: [],
fieldOrder: ["alerts", "detections", "genai"],
fieldGroups: {},
hiddenFields: [
"enabled_in_config",
"alerts.labels",
"alerts.enabled_in_config",
"detections.labels",
"detections.enabled_in_config",
"genai.enabled_in_config",
],
advancedFields: [],
uiSchema: {
alerts: {
"ui:before": { render: "CameraReviewStatusToggles" },
labels: {
"ui:widget": "reviewLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
required_zones: {
"ui:widget": "hidden",
},
},
detections: {
labels: {
"ui:widget": "reviewLabels",
"ui:options": {
suppressMultiSchema: true,
emptySelectionHintKey:
"configForm.reviewLabels.allNonAlertDetections",
},
},
required_zones: {
"ui:widget": "hidden",
},
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/config-form/theme/frigateTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { TextareaWidget } from "./widgets/TextareaWidget";
import { SwitchesWidget } from "./widgets/SwitchesWidget";
import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
import { ReviewLabelSwitchesWidget } from "./widgets/ReviewLabelSwitchesWidget";
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
Expand Down Expand Up @@ -76,6 +77,7 @@ export const frigateTheme: FrigateTheme = {
switches: SwitchesWidget,
objectLabels: ObjectLabelSwitchesWidget,
audioLabels: AudioLabelSwitchesWidget,
reviewLabels: ReviewLabelSwitchesWidget,
zoneNames: ZoneSwitchesWidget,
timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Review Label Switches Widget - For selecting review alert/detection labels via switches.
// Combines object labels (from objects.track) and audio labels (from audio.listen)
// since review labels can include both types.
import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { JsonObject } from "@/types/configForm";

function getReviewLabels(context: FormContext): string[] {
const labels = new Set<string>();
const fullConfig = context.fullConfig as FrigateConfig | undefined;
const fullCameraConfig = context.fullCameraConfig;

// Object labels from tracked objects (camera-level, falling back to global)
const trackLabels =
fullCameraConfig?.objects?.track ?? fullConfig?.objects?.track;
if (Array.isArray(trackLabels)) {
trackLabels.forEach((label: string) => labels.add(label));
}

// Audio labels from listen config, only if audio detection is enabled
const audioEnabled =
fullCameraConfig?.audio?.enabled_in_config ??
fullConfig?.audio?.enabled_in_config;
if (audioEnabled) {
const audioLabels =
fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen;
if (Array.isArray(audioLabels)) {
audioLabels.forEach((label: string) => labels.add(label));
}
}

// Include any labels already in the review form data (alerts + detections)
// so that previously saved labels remain visible even if tracking config changed
if (context.formData && typeof context.formData === "object") {
const formData = context.formData as JsonObject;
for (const section of ["alerts", "detections"] as const) {
const sectionData = formData[section];
if (sectionData && typeof sectionData === "object") {
const sectionLabels = (sectionData as JsonObject).labels;
if (Array.isArray(sectionLabels)) {
sectionLabels.forEach((label) => {
if (typeof label === "string") {
labels.add(label);
}
});
}
}
}
}

return [...labels].sort();
}

function getReviewLabelDisplayName(
label: string,
context?: FormContext,
): string {
const fullCameraConfig = context?.fullCameraConfig;
const fullConfig = context?.fullConfig as FrigateConfig | undefined;
const audioLabels =
fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen;
const isAudio = Array.isArray(audioLabels) && audioLabels.includes(label);
return getTranslatedLabel(label, isAudio ? "audio" : "object");
}

export function ReviewLabelSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getReviewLabels,
getDisplayLabel: getReviewLabelDisplayName,
i18nKey: "reviewLabels",
allowCustomEntries: true,
listClassName:
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
}}
/>
);
}
75 changes: 68 additions & 7 deletions web/src/components/config-form/theme/widgets/SwitchesWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generic Switches Widget - Reusable component for selecting from any list of entities
import { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -43,6 +43,10 @@ export type SwitchesWidgetOptions = {
listClassName?: string;
/** Enable search input to filter the list */
enableSearch?: boolean;
/** Allow users to add custom entries not in the predefined list */
allowCustomEntries?: boolean;
/** i18n key for a hint shown when no entities are selected */
emptySelectionHintKey?: string;
};

function normalizeValue(value: unknown): string[] {
Expand Down Expand Up @@ -122,20 +126,51 @@ export function SwitchesWidget(props: WidgetProps) {
[props.options],
);

const allowCustomEntries = useMemo(
() => props.options?.allowCustomEntries as boolean | undefined,
[props.options],
);

const emptySelectionHintKey = useMemo(
() => props.options?.emptySelectionHintKey as string | undefined,
[props.options],
);

const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const [searchTerm, setSearchTerm] = useState("");
const [customEntries, setCustomEntries] = useState<string[]>([]);
const [customInput, setCustomInput] = useState("");

const allEntities = useMemo(() => {
if (customEntries.length === 0) {
return availableEntities;
}
const merged = new Set([...availableEntities, ...customEntries]);
return [...merged].sort();
}, [availableEntities, customEntries]);

const filteredEntities = useMemo(() => {
if (!enableSearch || !searchTerm.trim()) {
return availableEntities;
return allEntities;
}
const term = searchTerm.toLowerCase();
return availableEntities.filter((entity) => {
return allEntities.filter((entity) => {
const displayLabel = getDisplayLabel(entity, context);
return displayLabel.toLowerCase().includes(term);
});
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]);
}, [allEntities, searchTerm, enableSearch, getDisplayLabel, context]);

const addCustomEntry = useCallback(() => {
const trimmed = customInput.trim().toLowerCase();
if (!trimmed || allEntities.includes(trimmed)) {
setCustomInput("");
return;
}
setCustomEntries((prev) => [...prev, trimmed]);
onChange([...selectedEntities, trimmed]);
setCustomInput("");
}, [customInput, allEntities, selectedEntities, onChange]);

const toggleEntity = (entity: string, enabled: boolean) => {
if (enabled) {
Expand Down Expand Up @@ -163,7 +198,7 @@ export function SwitchesWidget(props: WidgetProps) {

return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="space-y-3">
<div className="space-y-2">
<CollapsibleTrigger asChild>
<Button
type="button"
Expand All @@ -180,8 +215,14 @@ export function SwitchesWidget(props: WidgetProps) {
</Button>
</CollapsibleTrigger>

<CollapsibleContent className="rounded-lg bg-secondary p-2 pr-0 md:max-w-md">
{availableEntities.length === 0 ? (
{emptySelectionHintKey && selectedEntities.length === 0 && t && (
<div className="mt-0 pb-2 text-sm text-success">
{t(emptySelectionHintKey, { ns: namespace })}
</div>
)}

<CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md">
{allEntities.length === 0 && !allowCustomEntries ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<>
Expand Down Expand Up @@ -223,6 +264,26 @@ export function SwitchesWidget(props: WidgetProps) {
);
})}
</div>
{allowCustomEntries && !disabled && !readonly && (
<div className="mx-2 mt-2 pb-1">
<Input
type="text"
placeholder={t?.("configForm.addCustomLabel", {
ns: "views/settings",
defaultValue: "Add custom label...",
})}
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomEntry();
}
}}
onBlur={addCustomEntry}
/>
</div>
)}
</>
)}
</CollapsibleContent>
Expand Down
Loading