diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 1202f560e..ff95e2fc6 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -104,12 +104,14 @@ "regenerate": "A new description has been requested from {{provider}}. Depending on the speed of your provider, the new description may take some time to regenerate.", "updatedSublabel": "Successfully updated sub label.", "updatedLPR": "Successfully updated license plate.", + "updatedAttributes": "Successfully updated attributes.", "audioTranscription": "Successfully requested audio transcription. Depending on the speed of your Frigate server, the transcription may take some time to complete." }, "error": { "regenerate": "Failed to call {{provider}} for a new description: {{errorMessage}}", "updatedSublabelFailed": "Failed to update sub label: {{errorMessage}}", "updatedLPRFailed": "Failed to update license plate: {{errorMessage}}", + "updatedAttributesFailed": "Failed to update attributes: {{errorMessage}}", "audioTranscription": "Failed to request audio transcription: {{errorMessage}}" } } @@ -125,6 +127,10 @@ "desc": "Enter a new license plate value for this {{label}}", "descNoLabel": "Enter a new license plate value for this tracked object" }, + "editAttributes": { + "title": "Edit attributes", + "desc": "Select classification attributes for this {{label}}" + }, "snapshotScore": { "label": "Snapshot Score" }, diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index b5472d622..392e929eb 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -84,6 +84,7 @@ import { LuInfo } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { FaPencilAlt } from "react-icons/fa"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import AttributeSelectDialog from "@/components/overlay/dialog/AttributeSelectDialog"; import { Trans, useTranslation } from "react-i18next"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { getTranslatedLabel } from "@/utils/i18n"; @@ -297,6 +298,7 @@ type DialogContentComponentProps = { isPopoverOpen: boolean; setIsPopoverOpen: (open: boolean) => void; dialogContainer: HTMLDivElement | null; + setShowNavigationButtons: React.Dispatch>; }; function DialogContentComponent({ @@ -314,6 +316,7 @@ function DialogContentComponent({ isPopoverOpen, setIsPopoverOpen, dialogContainer, + setShowNavigationButtons, }: DialogContentComponentProps) { if (page === "tracking_details") { return ( @@ -399,6 +402,7 @@ function DialogContentComponent({ config={config} setSearch={setSearch} setInputFocused={setInputFocused} + setShowNavigationButtons={setShowNavigationButtons} /> @@ -415,6 +419,7 @@ function DialogContentComponent({ config={config} setSearch={setSearch} setInputFocused={setInputFocused} + setShowNavigationButtons={setShowNavigationButtons} /> ); @@ -459,6 +464,7 @@ export default function SearchDetailDialog({ const [isOpen, setIsOpen] = useState(search != undefined); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [showNavigationButtons, setShowNavigationButtons] = useState(false); const dialogContentRef = useRef(null); const [dialogContainer, setDialogContainer] = useState( null, @@ -540,9 +546,9 @@ export default function SearchDetailDialog({ onOpenChange={handleOpenChange} enableHistoryBack={true} > - {isDesktop && onPrevious && onNext && ( + {isDesktop && onPrevious && onNext && showNavigationButtons && ( -
+
@@ -664,12 +671,14 @@ type ObjectDetailsTabProps = { config?: FrigateConfig; setSearch: (search: SearchResult | undefined) => void; setInputFocused: React.Dispatch>; + setShowNavigationButtons?: React.Dispatch>; }; function ObjectDetailsTab({ search, config, setSearch, setInputFocused, + setShowNavigationButtons, }: ObjectDetailsTabProps) { const { t, i18n } = useTranslation([ "views/explore", @@ -682,9 +691,9 @@ function ObjectDetailsTab({ () => Object.keys(config?.classification?.custom ?? {}).length > 0, [config], ); - const { data: allowedAttributes } = useSWR( + const { data: modelAttributes } = useSWR>( hasCustomClassificationModels && search - ? `classification/attributes?object_type=${encodeURIComponent(search.label)}` + ? `classification/attributes?object_type=${encodeURIComponent(search.label)}&group_by_model=true` : null, ); @@ -717,6 +726,7 @@ function ObjectDetailsTab({ const [desc, setDesc] = useState(search?.data.description); const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false); const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false); + const [isAttributesDialogOpen, setIsAttributesDialogOpen] = useState(false); const [isEditingDesc, setIsEditingDesc] = useState(false); const originalDescRef = useRef(null); @@ -731,6 +741,19 @@ function ObjectDetailsTab({ // we have to make sure the current selected search item stays in sync useEffect(() => setDesc(search?.data.description ?? ""), [search]); + useEffect(() => setIsAttributesDialogOpen(false), [search?.id]); + + useEffect(() => { + const anyDialogOpen = + isSubLabelDialogOpen || isLPRDialogOpen || isAttributesDialogOpen; + setShowNavigationButtons?.(!anyDialogOpen); + }, [ + isSubLabelDialogOpen, + isLPRDialogOpen, + isAttributesDialogOpen, + setShowNavigationButtons, + ]); + const formattedDate = useFormattedTimestamp( search?.start_time ?? 0, config?.ui.time_format == "24hour" @@ -816,23 +839,40 @@ function ObjectDetailsTab({ } }, [search]); - const eventAttributes = useMemo(() => { - if (!search || !allowedAttributes || allowedAttributes.length === 0) { - return []; + // Extract current attribute selections grouped by model + const selectedAttributesByModel = useMemo(() => { + if (!search || !modelAttributes) { + return {}; } - const collected = new Set(); const dataAny = search.data as Record; + const selections: Record = {}; - // Check top-level keys in data that match allowed attributes - allowedAttributes.forEach((attr) => { - if (dataAny[attr] !== undefined && dataAny[attr] !== null) { - collected.add(attr); + // Initialize all models with null + Object.keys(modelAttributes).forEach((modelName) => { + selections[modelName] = null; + }); + + // Find which attribute is selected for each model + Object.keys(modelAttributes).forEach((modelName) => { + const value = dataAny[modelName]; + if ( + typeof value === "string" && + modelAttributes[modelName].includes(value) + ) { + selections[modelName] = value; } }); - return Array.from(collected).sort((a, b) => a.localeCompare(b)); - }, [search, allowedAttributes]); + return selections; + }, [search, modelAttributes]); + + // Get flat list of selected attributes for display + const eventAttributes = useMemo(() => { + return Object.values(selectedAttributesByModel) + .filter((attr): attr is string => attr !== null) + .sort((a, b) => a.localeCompare(b)); + }, [selectedAttributesByModel]); const isEventsKey = useCallback((key: unknown): boolean => { const candidate = Array.isArray(key) ? key[0] : key; @@ -1075,6 +1115,74 @@ function ObjectDetailsTab({ [search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey], ); + const handleAttributesSave = useCallback( + (selectedAttributes: string[]) => { + if (!search) return; + + axios + .post(`${apiHost}api/events/${search.id}/attributes`, { + attributes: selectedAttributes, + }) + .then((response) => { + const applied = Array.isArray(response.data?.applied) + ? (response.data.applied as { + model?: string; + label?: string | null; + score?: number | null; + }[]) + : []; + + toast.success(t("details.item.toast.success.updatedAttributes"), { + position: "top-center", + }); + + const applyUpdatedAttributes = (event: SearchResult) => { + if (event.id !== search.id) return event; + + const updatedData: Record = { ...event.data }; + + applied.forEach(({ model, label, score }) => { + if (!model) return; + updatedData[model] = label ?? null; + updatedData[`${model}_score`] = score ?? null; + }); + + return { ...event, data: updatedData } as SearchResult; + }; + + mutate( + (key) => isEventsKey(key), + (currentData: SearchResult[][] | SearchResult[] | undefined) => + mapSearchResults(currentData, applyUpdatedAttributes), + { + optimisticData: true, + rollbackOnError: true, + revalidate: false, + }, + ); + + setSearch(applyUpdatedAttributes(search)); + setIsAttributesDialogOpen(false); + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + toast.error( + t("details.item.toast.error.updatedAttributesFailed", { + errorMessage, + }), + { + position: "top-center", + }, + ); + }); + }, + [search, apiHost, mutate, t, mapSearchResults, isEventsKey, setSearch], + ); + // speech transcription const onTranscribe = useCallback(() => { @@ -1323,14 +1431,37 @@ function ObjectDetailsTab({
)} - {eventAttributes.length > 0 && ( -
-
- {t("details.attributes")} + {hasCustomClassificationModels && + modelAttributes && + Object.keys(modelAttributes).length > 0 && ( +
+
+ {t("details.attributes")} + {isAdmin && ( + + + + setIsAttributesDialogOpen(true)} + /> + + + + + {t("button.edit", { ns: "common" })} + + + + )} +
+
+ {eventAttributes.length > 0 + ? eventAttributes.join(", ") + : t("label.none", { ns: "common" })} +
-
{eventAttributes.join(", ")}
-
- )} + )}
@@ -1631,6 +1762,17 @@ function ObjectDetailsTab({ defaultValue={search?.data.recognized_license_plate || ""} allowEmpty={true} /> +
); diff --git a/web/src/components/overlay/dialog/AttributeSelectDialog.tsx b/web/src/components/overlay/dialog/AttributeSelectDialog.tsx new file mode 100644 index 000000000..b2ddc48ea --- /dev/null +++ b/web/src/components/overlay/dialog/AttributeSelectDialog.tsx @@ -0,0 +1,123 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { useCallback, useEffect, useState } from "react"; +import { isDesktop } from "react-device-detect"; +import { useTranslation } from "react-i18next"; + +type AttributeSelectDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + title: string; + description: string; + onSave: (selectedAttributes: string[]) => void; + selectedAttributes: Record; // model -> selected attribute + modelAttributes: Record; // model -> available attributes + className?: string; +}; + +export default function AttributeSelectDialog({ + open, + setOpen, + title, + description, + onSave, + selectedAttributes, + modelAttributes, + className, +}: AttributeSelectDialogProps) { + const { t } = useTranslation(); + const [internalSelection, setInternalSelection] = useState< + Record + >({}); + + useEffect(() => { + if (open) { + setInternalSelection({ ...selectedAttributes }); + } + }, [open, selectedAttributes]); + + const handleSave = useCallback(() => { + // Convert from model->attribute map to flat list of attributes + const attributes = Object.values(internalSelection).filter( + (attr): attr is string => attr !== null, + ); + onSave(attributes); + }, [internalSelection, onSave]); + + const handleToggle = useCallback((modelName: string, attribute: string) => { + setInternalSelection((prev) => { + const currentSelection = prev[modelName]; + // If clicking the currently selected attribute, deselect it + if (currentSelection === attribute) { + return { ...prev, [modelName]: null }; + } + // Otherwise, select this attribute for this model + return { ...prev, [modelName]: attribute }; + }); + }, []); + + return ( + + e.preventDefault()} + > + + {title} + {description} + +
+
+ {Object.entries(modelAttributes).map(([modelName, attributes]) => ( +
+
+ {modelName} +
+
+ {attributes.map((attribute) => ( +
+ + + handleToggle(modelName, attribute) + } + /> +
+ ))} +
+
+ ))} +
+
+ + + + +
+
+ ); +}