From c08fa15724bec631a3a51a9e6b842bd666a667c8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:23:33 -0600 Subject: [PATCH] Miscellaneous Fixes (0.17 beta) (#21575) * icon improvements add type to getIconForLabel provide default icon for audio events * Add preferred language to review docs * prevent react Suspense crash during auth redirect add redirect-check guards to stop rendering lazy routes while navigation is pending (fixes some users seeing React error #426 when auth expires) * Uppsercase model name --------- Co-authored-by: Nicolas Mowen --- .../configuration/genai/review_summaries.md | 11 +++++ web/src/App.tsx | 11 +++++ web/src/components/auth/ProtectedRoute.tsx | 8 ++++ web/src/components/card/ReviewCard.tsx | 4 +- web/src/components/card/SearchThumbnail.tsx | 6 ++- .../overlay/detail/SearchDetailDialog.tsx | 6 ++- .../overlay/detail/TrackingDetails.tsx | 1 + web/src/components/player/LivePlayer.tsx | 6 ++- .../player/PreviewThumbnailPlayer.tsx | 12 +++++- web/src/components/timeline/DetailStream.tsx | 41 ++++++++++++------- web/src/utils/iconUtil.tsx | 31 ++++++++++---- .../classification/ModelTrainingView.tsx | 2 +- web/src/views/settings/ObjectSettingsView.tsx | 4 +- 13 files changed, 112 insertions(+), 31 deletions(-) diff --git a/docs/docs/configuration/genai/review_summaries.md b/docs/docs/configuration/genai/review_summaries.md index 99ee48d0f..9851ec2f6 100644 --- a/docs/docs/configuration/genai/review_summaries.md +++ b/docs/docs/configuration/genai/review_summaries.md @@ -112,6 +112,17 @@ review: - animals in the garden ``` +### Preferred Language + +By default, review summaries are generated in English. You can configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option: + +```yaml +review: + genai: + enabled: true + preferred_language: Spanish +``` + ## Review Reports Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review. diff --git a/web/src/App.tsx b/web/src/App.tsx index b458d9ec3..d7a9ec3e9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import { AuthProvider } from "@/context/auth-context"; import useSWR from "swr"; import { FrigateConfig } from "./types/frigateConfig"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { isRedirectingToLogin } from "@/api/auth-redirect"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -58,6 +59,16 @@ function DefaultAppView() { ? Object.keys(config.auth.roles) : undefined; + // Show loading indicator during redirect to prevent React from attempting to render + // lazy components, which would cause error #426 (suspension during synchronous navigation) + if (isRedirectingToLogin()) { + return ( +
+ +
+ ); + } + return (
{isDesktop && } diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx index 55edc60bd..cedf5a15a 100644 --- a/web/src/components/auth/ProtectedRoute.tsx +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -28,6 +28,14 @@ export default function ProtectedRoute({ } }, [auth.isLoading, auth.isAuthenticated, auth.user]); + // Show loading indicator during redirect to prevent React from attempting to render + // lazy components, which would cause error #426 (suspension during synchronous navigation) + if (isRedirectingToLogin()) { + return ( + + ); + } + if (auth.isLoading) { return ( diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index 85b5df235..b5ba5cfea 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -181,7 +181,7 @@ export default function ReviewCard({ key={`${object}-${idx}`} className="rounded-full bg-muted-foreground p-1" > - {getIconForLabel(object, "size-3 text-white")} + {getIconForLabel(object, "object", "size-3 text-white")}
))} {event.data.audio.map((audio, idx) => ( @@ -189,7 +189,7 @@ export default function ReviewCard({ key={`${audio}-${idx}`} className="rounded-full bg-muted-foreground p-1" > - {getIconForLabel(audio, "size-3 text-white")} + {getIconForLabel(audio, "audio", "size-3 text-white")} ))} diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index 0b82475c8..66f58f4fd 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -133,7 +133,11 @@ export default function SearchThumbnail({ className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`} onClick={() => onClick(searchResult, false, true)} > - {getIconForLabel(objectLabel, "size-3 text-white")} + {getIconForLabel( + objectLabel, + searchResult.data.type, + "size-3 text-white", + )} {Math.floor( (searchResult.data.score ?? searchResult.data.top_score ?? diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index bd4368ebe..01e211eec 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -1296,7 +1296,11 @@ function ObjectDetailsTab({ {t("details.label")}
- {getIconForLabel(search.label, "size-4 text-primary")} + {getIconForLabel( + search.label, + search.data.type, + "size-4 text-primary", + )} {getTranslatedLabel(search.label, search.data.type)} {search.sub_label && ` (${search.sub_label})`} {isAdmin && search.end_time && ( diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index f2eb11143..767a7072a 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -665,6 +665,7 @@ export function TrackingDetails({ > {getIconForLabel( event.sub_label ? event.label + "-verified" : event.label, + event.data.type, "size-4 text-white", )}
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index ed359a0c9..1dd7a10a6 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -359,7 +359,11 @@ export default function LivePlayer({ ]), ] .map((label) => { - return getIconForLabel(label, "size-3 text-white"); + return getIconForLabel( + label, + "object", + "size-3 text-white", + ); }) .sort()} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 872f7c98a..30339599a 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -262,10 +262,18 @@ export default function PreviewThumbnailPlayer({ onClick={() => onClick(review, false, true)} > {review.data.objects.sort().map((object) => { - return getIconForLabel(object, "size-3 text-white"); + return getIconForLabel( + object, + "object", + "size-3 text-white", + ); })} {review.data.audio.map((audio) => { - return getIconForLabel(audio, "size-3 text-white"); + return getIconForLabel( + audio, + "audio", + "size-3 text-white", + ); })} diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index c6413ed97..ac560a4df 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -14,6 +14,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; import { Event } from "@/types/event"; +import { EventType } from "@/types/search"; import { getIconForLabel } from "@/utils/iconUtil"; import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu"; @@ -346,22 +347,29 @@ function ReviewGroup({ : null, ); - const rawIconLabels: string[] = [ + const rawIconLabels: Array<{ label: string; type: EventType }> = [ ...(fetchedEvents - ? fetchedEvents.map((e) => - e.sub_label ? e.label + "-verified" : e.label, - ) - : (review.data?.objects ?? [])), - ...(review.data?.audio ?? []), + ? fetchedEvents.map((e) => ({ + label: e.sub_label ? e.label + "-verified" : e.label, + type: e.data.type, + })) + : (review.data?.objects ?? []).map((obj) => ({ + label: obj, + type: "object" as EventType, + }))), + ...(review.data?.audio ?? []).map((audio) => ({ + label: audio, + type: "audio" as EventType, + })), ]; // limit to 5 icons const seen = new Set(); - const iconLabels: string[] = []; - for (const lbl of rawIconLabels) { - if (!seen.has(lbl)) { - seen.add(lbl); - iconLabels.push(lbl); + const iconLabels: Array<{ label: string; type: EventType }> = []; + for (const item of rawIconLabels) { + if (!seen.has(item.label)) { + seen.add(item.label); + iconLabels.push(item); if (iconLabels.length >= 5) break; } } @@ -418,12 +426,12 @@ function ReviewGroup({
{displayTime}
- {iconLabels.slice(0, 5).map((lbl, idx) => ( + {iconLabels.slice(0, 5).map(({ label: lbl, type }, idx) => (
- {getIconForLabel(lbl, "size-3 text-white")} + {getIconForLabel(lbl, type, "size-3 text-white")}
))}
@@ -516,7 +524,11 @@ function ReviewGroup({ >
- {getIconForLabel(audioLabel, "size-3 text-white")} + {getIconForLabel( + audioLabel, + "audio", + "size-3 text-white", + )}
{getTranslatedLabel(audioLabel, "audio")}
@@ -618,6 +630,7 @@ function EventList({ > {getIconForLabel( event.sub_label ? event.label + "-verified" : event.label, + event.data.type, "size-3 text-white", )}
diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index 11be7cb9e..156b4529b 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -1,5 +1,6 @@ import { IconName } from "@/components/icons/IconPicker"; import { FrigateConfig } from "@/types/frigateConfig"; +import { EventType } from "@/types/search"; import { BsPersonWalking } from "react-icons/bs"; import { FaAmazon, @@ -32,6 +33,7 @@ import { GiRabbit, GiRaccoonHead, GiSailboat, + GiSoundWaves, GiSquirrel, } from "react-icons/gi"; import { LuBox, LuLassoSelect, LuScanBarcode } from "react-icons/lu"; @@ -56,11 +58,15 @@ export function isValidIconName(value: string): value is IconName { return Object.keys(LuIcons).includes(value as IconName); } -export function getIconForLabel(label: string, className?: string) { +export function getIconForLabel( + label: string, + type: EventType = "object", + className?: string, +) { if (label.endsWith("-verified")) { - return getVerifiedIcon(label, className); + return getVerifiedIcon(label, className, type); } else if (label.endsWith("-plate")) { - return getRecognizedPlateIcon(label, className); + return getRecognizedPlateIcon(label, className, type); } switch (label) { @@ -152,27 +158,38 @@ export function getIconForLabel(label: string, className?: string) { case "usps": return ; default: + if (type === "audio") { + return ; + } return ; } } -function getVerifiedIcon(label: string, className?: string) { +function getVerifiedIcon( + label: string, + className?: string, + type: EventType = "object", +) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return (
- {getIconForLabel(simpleLabel, className)} + {getIconForLabel(simpleLabel, type, className)}
); } -function getRecognizedPlateIcon(label: string, className?: string) { +function getRecognizedPlateIcon( + label: string, + className?: string, + type: EventType = "object", +) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return (
- {getIconForLabel(simpleLabel, className)} + {getIconForLabel(simpleLabel, type, className)}
); diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx index ec7ce0472..10d52075d 100644 --- a/web/src/views/classification/ModelTrainingView.tsx +++ b/web/src/views/classification/ModelTrainingView.tsx @@ -88,7 +88,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { // title useEffect(() => { - document.title = `${model.name} - ${t("documentTitle")}`; + document.title = `${model.name.toUpperCase()} - ${t("documentTitle")}`; }, [model.name, t]); // model state diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index 82977e80c..3c03269b5 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -406,7 +406,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) { : getColorForObjectName(obj.label), }} > - {getIconForLabel(obj.label, "size-5 text-white")} + {getIconForLabel(obj.label, "object", "size-5 text-white")}
{getTranslatedLabel(obj.label)} @@ -494,7 +494,7 @@ function AudioList({ cameraConfig, audioDetections }: AudioListProps) {
- {getIconForLabel(key, "size-5 text-white")} + {getIconForLabel(key, "audio", "size-5 text-white")}
{getTranslatedLabel(key)}