From 0a28bd7425eb9f0ef6f480fbe110874153b753cf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:14:28 -0600 Subject: [PATCH] add attributes to tracked object details pane --- web/public/locales/en/views/explore.json | 1 + .../overlay/detail/SearchDetailDialog.tsx | 36 +++++++++++++++++++ .../overlay/dialog/SearchFilterDialog.tsx | 32 +++++++++++------ web/src/views/search/SearchView.tsx | 11 ++++-- 4 files changed, 67 insertions(+), 13 deletions(-) diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 6c938c109..1202f560e 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -136,6 +136,7 @@ "label": "Score" }, "recognizedLicensePlate": "Recognized License Plate", + "attributes": "Classification Attributes", "estimatedSpeed": "Estimated Speed", "objects": "Objects", "camera": "Camera", diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 1c46213df..b5472d622 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -678,6 +678,15 @@ function ObjectDetailsTab({ ]); const apiHost = useApiHost(); + const hasCustomClassificationModels = useMemo( + () => Object.keys(config?.classification?.custom ?? {}).length > 0, + [config], + ); + const { data: allowedAttributes } = useSWR( + hasCustomClassificationModels && search + ? `classification/attributes?object_type=${encodeURIComponent(search.label)}` + : null, + ); // mutation / revalidation @@ -807,6 +816,24 @@ function ObjectDetailsTab({ } }, [search]); + const eventAttributes = useMemo(() => { + if (!search || !allowedAttributes || allowedAttributes.length === 0) { + return []; + } + + const collected = new Set(); + const dataAny = search.data as Record; + + // Check top-level keys in data that match allowed attributes + allowedAttributes.forEach((attr) => { + if (dataAny[attr] !== undefined && dataAny[attr] !== null) { + collected.add(attr); + } + }); + + return Array.from(collected).sort((a, b) => a.localeCompare(b)); + }, [search, allowedAttributes]); + const isEventsKey = useCallback((key: unknown): boolean => { const candidate = Array.isArray(key) ? key[0] : key; const EVENTS_KEY_PATTERNS = ["events", "events/search", "events/explore"]; @@ -1295,6 +1322,15 @@ function ObjectDetailsTab({ )} + + {eventAttributes.length > 0 && ( +
+
+ {t("details.attributes")} +
+
{eventAttributes.join(", ")}
+
+ )} diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx index 2e63a2f84..eb1188257 100644 --- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx +++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx @@ -65,7 +65,13 @@ export default function SearchFilterDialog({ const { t } = useTranslation(["components/filter"]); const [currentFilter, setCurrentFilter] = useState(filter ?? {}); const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]); - const { data: allAttributes } = useSWR("classification/attributes"); + const hasCustomClassificationModels = useMemo( + () => Object.keys(config?.classification?.custom ?? {}).length > 0, + [config], + ); + const { data: allAttributes } = useSWR( + hasCustomClassificationModels ? "classification/attributes" : null, + ); const { data: allRecognizedLicensePlates } = useSWR( "recognized_license_plates", ); @@ -92,9 +98,10 @@ export default function SearchFilterDialog({ (currentFilter.max_speed ?? 150) < 150 || (currentFilter.zones?.length ?? 0) > 0 || (currentFilter.sub_labels?.length ?? 0) > 0 || - (currentFilter.attributes?.length ?? 0) > 0 || + (hasCustomClassificationModels && + (currentFilter.attributes?.length ?? 0) > 0) || (currentFilter.recognized_license_plate?.length ?? 0) > 0), - [currentFilter], + [currentFilter, hasCustomClassificationModels], ); const trigger = ( @@ -135,13 +142,15 @@ export default function SearchFilterDialog({ setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels }) } /> - - setCurrentFilter({ ...currentFilter, attributes: newAttributes }) - } - /> + {hasCustomClassificationModels && ( + + setCurrentFilter({ ...currentFilter, attributes: newAttributes }) + } + /> + )} void; }; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index e16bda83a..a373acc82 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -143,7 +143,13 @@ export default function SearchView({ }, [config, searchFilter, allowedCameras]); const { data: allSubLabels } = useSWR("sub_labels"); - const { data: allAttributes } = useSWR("classification/attributes"); + const hasCustomClassificationModels = useMemo( + () => Object.keys(config?.classification?.custom ?? {}).length > 0, + [config], + ); + const { data: allAttributes } = useSWR( + hasCustomClassificationModels ? "classification/attributes" : null, + ); const { data: allRecognizedLicensePlates } = useSWR( "recognized_license_plates", ); @@ -183,7 +189,7 @@ export default function SearchView({ labels: Object.values(allLabels || {}), zones: Object.values(allZones || {}), sub_labels: allSubLabels, - attributes: allAttributes, + ...(hasCustomClassificationModels && { attributes: allAttributes }), search_type: ["thumbnail", "description"] as SearchSource[], time_range: config?.ui.time_format == "24hour" @@ -210,6 +216,7 @@ export default function SearchView({ allRecognizedLicensePlates, searchFilter, allowedCameras, + hasCustomClassificationModels, ], );