mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-28 19:18:22 +03:00
Review labels widget (#22664)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add review labels widget * register widget and add to review section * i18n * add border to switches widget * padding tweaks * don't show audio labels if audio is not enabled * add docs links * ability to add custom labels to review * add hint for empty selection in review labels and SwitchesWidget * language consistency
This commit is contained in:
parent
1a01513223
commit
c35cee2d2f
@ -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,
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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}}"
|
||||
},
|
||||
@ -1474,7 +1479,8 @@
|
||||
"timestamp_style": {
|
||||
"title": "Timestamp Settings"
|
||||
},
|
||||
"searchPlaceholder": "Search..."
|
||||
"searchPlaceholder": "Search...",
|
||||
"addCustomLabel": "Add custom label..."
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Global Configuration",
|
||||
|
||||
@ -3,14 +3,16 @@ 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",
|
||||
],
|
||||
@ -18,11 +20,25 @@ const review: SectionConfigOverrides = {
|
||||
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",
|
||||
},
|
||||
|
||||
@ -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";
|
||||
@ -76,6 +77,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
switches: SwitchesWidget,
|
||||
objectLabels: ObjectLabelSwitchesWidget,
|
||||
audioLabels: AudioLabelSwitchesWidget,
|
||||
reviewLabels: ReviewLabelSwitchesWidget,
|
||||
zoneNames: ZoneSwitchesWidget,
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
@ -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[] {
|
||||
@ -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) {
|
||||
@ -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"
|
||||
@ -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>
|
||||
) : (
|
||||
<>
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user