From a5ba3f8e3e12fc3fd780e3102ddcf19a2f155234 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Nov 2025 07:03:17 -0700 Subject: [PATCH 01/10] Fix history management failing when updating URL --- web/src/hooks/use-history-back.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/use-history-back.ts b/web/src/hooks/use-history-back.ts index ee04b7531..764312c7b 100644 --- a/web/src/hooks/use-history-back.ts +++ b/web/src/hooks/use-history-back.ts @@ -17,6 +17,7 @@ export function useHistoryBack({ }: UseHistoryBackOptions): void { const historyPushedRef = React.useRef(false); const closedByBackRef = React.useRef(false); + const urlWhenOpenedRef = React.useRef(null); // Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes const onCloseRef = React.useRef(onClose); @@ -30,6 +31,9 @@ export function useHistoryBack({ if (open) { // Only push history state if we haven't already (prevents duplicates in strict mode) if (!historyPushedRef.current) { + // Store the current URL (pathname + search, without hash) before pushing history state + urlWhenOpenedRef.current = + window.location.pathname + window.location.search; window.history.pushState({ overlayOpen: true }, ""); historyPushedRef.current = true; } @@ -37,6 +41,7 @@ export function useHistoryBack({ const handlePopState = () => { closedByBackRef.current = true; historyPushedRef.current = false; + urlWhenOpenedRef.current = null; onCloseRef.current(); }; @@ -48,10 +53,22 @@ export function useHistoryBack({ } else { // Overlay is closing - clean up history if we pushed and it wasn't via back button if (historyPushedRef.current && !closedByBackRef.current) { - window.history.back(); + const currentUrl = window.location.pathname + window.location.search; + const urlWhenOpened = urlWhenOpenedRef.current; + + // If the URL has changed (e.g., filters were applied via search params), + // don't go back as it would undo the filter update. + // The history entry we pushed will remain, but that's acceptable compared + // to losing the user's filter changes. + if (!urlWhenOpened || currentUrl === urlWhenOpened) { + // URL hasn't changed, safe to go back and remove our history entry + window.history.back(); + } + // If URL changed, we skip history.back() to preserve the filter updates } historyPushedRef.current = false; closedByBackRef.current = false; + urlWhenOpenedRef.current = null; } }, [enabled, open]); } From 397d4e5b49f3cf9321cce23ec7947c9ee340a5f8 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Nov 2025 08:47:09 -0700 Subject: [PATCH 02/10] Handle case where user doesn't have images that represent all states If a user selects all imags and can't proceed we show a warning that they can still proceed but the model won't be trained until they get at least one image for every state. --- .../locales/en/views/classificationModel.json | 6 +- .../wizard/Step3ChooseExamples.tsx | 136 +++++++++++++----- 2 files changed, 105 insertions(+), 37 deletions(-) diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index f8aef1b8f..6641b607e 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -173,7 +173,11 @@ "generationFailed": "Generation failed. Please try again.", "classifyFailed": "Failed to classify images: {{error}}" }, - "generateSuccess": "Successfully generated sample images" + "generateSuccess": "Successfully generated sample images", + "missingStatesWarning": { + "title": "Missing State Examples", + "description": "You haven't selected examples for all states. The model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model." + } } } } diff --git a/web/src/components/classification/wizard/Step3ChooseExamples.tsx b/web/src/components/classification/wizard/Step3ChooseExamples.tsx index 6e4311cec..bae697f6e 100644 --- a/web/src/components/classification/wizard/Step3ChooseExamples.tsx +++ b/web/src/components/classification/wizard/Step3ChooseExamples.tsx @@ -10,12 +10,8 @@ import useSWR from "swr"; import { baseUrl } from "@/api/baseUrl"; import { isMobile } from "react-device-detect"; import { cn } from "@/lib/utils"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { TooltipPortal } from "@radix-ui/react-tooltip"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { IoIosWarning } from "react-icons/io"; export type Step3FormData = { examplesGenerated: boolean; @@ -159,6 +155,19 @@ export default function Step3ChooseExamples({ const handleContinueClassification = useCallback(async () => { // Mark selected images with current class const newClassifications = { ...imageClassifications }; + + // Handle user going back and de-selecting images + const imagesToCheck = unknownImages.slice(0, 24); + imagesToCheck.forEach((imageName) => { + if ( + newClassifications[imageName] === currentClass && + !selectedImages.has(imageName) + ) { + delete newClassifications[imageName]; + } + }); + + // Then, add all currently selected images to the current class selectedImages.forEach((imageName) => { newClassifications[imageName] = currentClass; }); @@ -329,8 +338,43 @@ export default function Step3ChooseExamples({ return unclassifiedImages.length === 0; }, [unclassifiedImages]); - // For state models on the last class, require all images to be classified const isLastClass = currentClassIndex === allClasses.length - 1; + const statesWithExamples = useMemo(() => { + if (step1Data.modelType !== "state") return new Set(); + + const states = new Set(); + const allImages = unknownImages.slice(0, 24); + + // Check which states have at least one image classified + allImages.forEach((img) => { + let className: string | undefined; + if (selectedImages.has(img)) { + className = currentClass; + } else { + className = imageClassifications[img]; + } + if (className && allClasses.includes(className)) { + states.add(className); + } + }); + + return states; + }, [ + step1Data.modelType, + unknownImages, + imageClassifications, + selectedImages, + currentClass, + allClasses, + ]); + + const allStatesHaveExamples = useMemo(() => { + if (step1Data.modelType !== "state") return true; + return allClasses.every((className) => statesWithExamples.has(className)); + }, [step1Data.modelType, allClasses, statesWithExamples]); + + // For state models on the last class, require all images to be classified + // But allow proceeding even if not all states have examples (with warning) const canProceed = useMemo(() => { if (step1Data.modelType === "state" && isLastClass) { // Check if all 24 images will be classified after current selections are applied @@ -353,6 +397,28 @@ export default function Step3ChooseExamples({ selectedImages, ]); + const hasUnclassifiedImages = useMemo(() => { + if (!unknownImages) return false; + const allImages = unknownImages.slice(0, 24); + return allImages.some((img) => !imageClassifications[img]); + }, [unknownImages, imageClassifications]); + + const showMissingStatesWarning = useMemo(() => { + return ( + step1Data.modelType === "state" && + isLastClass && + !allStatesHaveExamples && + !hasUnclassifiedImages && + hasGenerated + ); + }, [ + step1Data.modelType, + isLastClass, + allStatesHaveExamples, + hasUnclassifiedImages, + hasGenerated, + ]); + const handleBack = useCallback(() => { if (currentClassIndex > 0) { const previousClass = allClasses[currentClassIndex - 1]; @@ -399,6 +465,17 @@ export default function Step3ChooseExamples({ ) : hasGenerated ? (
+ {showMissingStatesWarning && ( + + + + {t("wizard.step3.missingStatesWarning.title")} + + + {t("wizard.step3.missingStatesWarning.description")} + + + )} {!allImagesClassified && (

@@ -474,35 +551,22 @@ export default function Step3ChooseExamples({ - - - - - {!canProceed && ( - - - {t("wizard.step3.allImagesRequired", { - count: unclassifiedImages.length, - })} - - - )} - +

)}
From 5919b56ffb6c1076d9481a20702d3a4c06b768ff Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Nov 2025 08:52:38 -0700 Subject: [PATCH 03/10] Still create all classes We stil need to create all classes even if the user didn't assign images to them. --- frigate/api/classification.py | 40 ++++++++++++++++ .../locales/en/views/classificationModel.json | 1 + .../wizard/Step3ChooseExamples.tsx | 48 ++++++++++++++++--- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 9b116be10..6a738409e 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -870,6 +870,46 @@ def categorize_classification_image(request: Request, name: str, body: dict = No ) +@router.post( + "/classification/{name}/dataset/{category}/create", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], + summary="Create an empty classification category folder", + description="""Creates an empty folder for a classification category. + This is used to create folders for categories that don't have images yet. + Returns a success message or an error if the name is invalid.""", +) +def create_classification_category(request: Request, name: str, category: str): + config: FrigateConfig = request.app.frigate_config + + if name not in config.classification.custom: + return JSONResponse( + content=( + { + "success": False, + "message": f"{name} is not a known classification model.", + } + ), + status_code=404, + ) + + category_folder = os.path.join( + CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category) + ) + + os.makedirs(category_folder, exist_ok=True) + + return JSONResponse( + content=( + { + "success": True, + "message": f"Successfully created category folder: {category}", + } + ), + status_code=200, + ) + + @router.post( "/classification/{name}/train/delete", response_model=GenericResponse, diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index 6641b607e..7852bb550 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -166,6 +166,7 @@ "noImages": "No sample images generated", "classifying": "Classifying & Training...", "trainingStarted": "Training started successfully", + "modelCreated": "Model created successfully. Use the Recent Classifications view to add images for missing states, then train the model.", "errors": { "noCameras": "No cameras configured", "noObjectLabel": "No object label selected", diff --git a/web/src/components/classification/wizard/Step3ChooseExamples.tsx b/web/src/components/classification/wizard/Step3ChooseExamples.tsx index bae697f6e..47c990071 100644 --- a/web/src/components/classification/wizard/Step3ChooseExamples.tsx +++ b/web/src/components/classification/wizard/Step3ChooseExamples.tsx @@ -141,15 +141,49 @@ export default function Step3ChooseExamples({ ); await Promise.all(categorizePromises); - // Step 3: Kick off training - await axios.post(`/classification/${step1Data.modelName}/train`); + // Step 2.5: Create empty folders for classes that don't have any images + // This ensures all classes are available in the dataset view later + const classesWithImages = new Set( + Object.values(classifications).filter((c) => c && c !== "none"), + ); + const emptyFolderPromises = step1Data.classes + .filter((className) => !classesWithImages.has(className)) + .map((className) => + axios.post( + `/classification/${step1Data.modelName}/dataset/${className}/create`, + ), + ); + await Promise.all(emptyFolderPromises); - toast.success(t("wizard.step3.trainingStarted"), { - closeButton: true, - }); - setIsTraining(true); + // Step 3: Determine if we should train + // For state models, we need ALL states to have examples + // For object models, we need at least 2 classes with images + const allStatesHaveExamplesForTraining = + step1Data.modelType !== "state" || + step1Data.classes.every((className) => + classesWithImages.has(className), + ); + const shouldTrain = + allStatesHaveExamplesForTraining && classesWithImages.size >= 2; + + // Step 4: Kick off training only if we have enough classes with images + if (shouldTrain) { + await axios.post(`/classification/${step1Data.modelName}/train`); + + toast.success(t("wizard.step3.trainingStarted"), { + closeButton: true, + }); + setIsTraining(true); + } else { + // Don't train - not all states have examples + toast.success(t("wizard.step3.modelCreated"), { + closeButton: true, + }); + setIsTraining(false); + onClose(); + } }, - [step1Data, step2Data, t], + [step1Data, step2Data, t, onClose], ); const handleContinueClassification = useCallback(async () => { From 890ae6ae50017dabe43a5f818fbec1dd74b4f2dd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Nov 2025 10:42:22 -0600 Subject: [PATCH 04/10] fix camera group access for non admin users changes from previous PR wrongly included users from the standard viewer role (but excluded custom viewer roles) --- .../components/filter/CameraGroupSelector.tsx | 24 +++++++++---------- web/src/pages/Live.tsx | 8 +++---- web/src/views/live/LiveDashboardView.tsx | 6 ++--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index c772bc2ba..295477b2a 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -78,7 +78,7 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { Trans, useTranslation } from "react-i18next"; import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; -import { useIsCustomRole } from "@/hooks/use-is-custom-role"; +import { useIsAdmin } from "@/hooks/use-is-admin"; type CameraGroupSelectorProps = { className?: string; @@ -88,7 +88,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { const { t } = useTranslation(["components/camera"]); const { data: config } = useSWR("config"); const allowedCameras = useAllowedCameras(); - const isCustomRole = useIsCustomRole(); + const isAdmin = useIsAdmin(); // tooltip @@ -124,7 +124,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { const allGroups = Object.entries(config.camera_groups); // If custom role, filter out groups where user has no accessible cameras - if (isCustomRole) { + if (!isAdmin) { return allGroups .filter(([, groupConfig]) => { // Check if user has access to at least one camera in this group @@ -136,7 +136,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { } return allGroups.sort((a, b) => a[1].order - b[1].order); - }, [config, allowedCameras, isCustomRole]); + }, [config, allowedCameras, isAdmin]); // add group @@ -153,7 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { activeGroup={group} setGroup={setGroup} deleteGroup={deleteGroup} - isCustomRole={isCustomRole} + isAdmin={isAdmin} />
void; deleteGroup: () => void; - isCustomRole?: boolean; + isAdmin?: boolean; }; function NewGroupDialog({ open, @@ -254,7 +254,7 @@ function NewGroupDialog({ activeGroup, setGroup, deleteGroup, - isCustomRole, + isAdmin, }: NewGroupDialogProps) { const { t } = useTranslation(["components/camera"]); const { mutate: updateConfig } = useSWR("config"); @@ -390,7 +390,7 @@ function NewGroupDialog({ > {t("group.label")} {t("group.edit")} - {!isCustomRole && ( + {isAdmin && (
onDeleteGroup(group[0])} onEditGroup={() => onEditGroup(group)} - isReadOnly={isCustomRole} + isReadOnly={!isAdmin} /> ))}
@@ -677,7 +677,7 @@ export function CameraGroupEdit({ ); const allowedCameras = useAllowedCameras(); - const isCustomRole = useIsCustomRole(); + const isAdmin = useIsAdmin(); const [openCamera, setOpenCamera] = useState(); @@ -867,7 +867,7 @@ export function CameraGroupEdit({ {[ ...(birdseyeConfig?.enabled && - (!isCustomRole || "birdseye" in allowedCameras) + (isAdmin || "birdseye" in allowedCameras) ? ["birdseye"] : []), ...Object.keys(config?.cameras ?? {}) diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index a17494a39..18ec7f469 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -14,12 +14,12 @@ import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useRef } from "react"; import useSWR from "swr"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; -import { useIsCustomRole } from "@/hooks/use-is-custom-role"; +import { useIsAdmin } from "@/hooks/use-is-admin"; function Live() { const { t } = useTranslation(["views/live"]); const { data: config } = useSWR("config"); - const isCustomRole = useIsCustomRole(); + const isAdmin = useIsAdmin(); // selection @@ -94,7 +94,7 @@ function Live() { const includesBirdseye = useMemo(() => { // Restricted users should never have access to birdseye - if (isCustomRole) { + if (!isAdmin) { return false; } @@ -109,7 +109,7 @@ function Live() { } else { return false; } - }, [config, cameraGroup, isCustomRole]); + }, [config, cameraGroup, isAdmin]); const cameras = useMemo(() => { if (!config) { diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index c096e05ef..e4e935ac6 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -54,7 +54,7 @@ import { useTranslation } from "react-i18next"; import { EmptyCard } from "@/components/card/EmptyCard"; import { BsFillCameraVideoOffFill } from "react-icons/bs"; import { AuthContext } from "@/context/auth-context"; -import { useIsCustomRole } from "@/hooks/use-is-custom-role"; +import { useIsAdmin } from "@/hooks/use-is-admin"; type LiveDashboardViewProps = { cameras: CameraConfig[]; @@ -661,10 +661,10 @@ export default function LiveDashboardView({ function NoCameraView() { const { t } = useTranslation(["views/live"]); const { auth } = useContext(AuthContext); - const isCustomRole = useIsCustomRole(); + const isAdmin = useIsAdmin(); // Check if this is a restricted user with no cameras in this group - const isRestricted = isCustomRole && auth.isAuthenticated; + const isRestricted = !isAdmin && auth.isAuthenticated; return (
From 376e5b5c258674e0d232bb19e3874e2aee3c64ee Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Nov 2025 10:54:49 -0700 Subject: [PATCH 05/10] Adjust threat level interaction to be less strict --- frigate/comms/webpush.py | 14 ++++++++- frigate/genai/__init__.py | 12 ++++++-- web/public/locales/en/views/events.json | 5 ++-- .../overlay/chip/GenAISummaryChip.tsx | 29 ++++++++++++++----- .../player/PreviewThumbnailPlayer.tsx | 28 +++++++++++++----- web/src/types/review.ts | 11 +++++-- 6 files changed, 76 insertions(+), 23 deletions(-) diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index a858f9eac..890226272 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -375,7 +375,19 @@ class WebPushClient(Communicator): ended = state == "end" or state == "genai" if state == "genai" and payload["after"]["data"]["metadata"]: - title = payload["after"]["data"]["metadata"]["title"] + base_title = payload["after"]["data"]["metadata"]["title"] + threat_level = payload["after"]["data"]["metadata"].get( + "potential_threat_level", 0 + ) + + # Add prefix for threat levels 1 and 2 + if threat_level == 1: + title = f"Needs Review: {base_title}" + elif threat_level == 2: + title = f"Security Concern: {base_title}" + else: + title = base_title + message = payload["after"]["data"]["metadata"]["scene"] else: title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}" diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index dd42fc6dd..d86e3cbc5 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -205,14 +205,20 @@ Rules for the report: - Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior). - Threat levels - - Always show (threat level: X) for each event. + - Always show the threat level for each event using these labels: + - Threat level 0: "Normal" + - Threat level 1: "Needs review" + - Threat level 2: "Security concern" + - Format as (threat level: Normal), (threat level: Needs review), or (threat level: Security concern). - If multiple events at the same time share the same threat level, only state it once. - Final assessment - End with a Final Assessment section. - - If all events are threat level 1 with no escalation: + - If all events are threat level 0: Final assessment: Only normal residential activity observed during this period. - - If threat level 2+ events are present, clearly summarize them as Potential concerns requiring review. + - If threat level 1 events are present: + Final assessment: Some activity requires review but no security concerns identified. + - If threat level 2 events are present, clearly summarize them as Security concerns requiring immediate attention. - Conciseness - Do not repeat benign clothing/appearance details unless they distinguish individuals. diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index d3cf78658..ee4aadef6 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -54,6 +54,7 @@ "selected_other": "{{count}} selected", "camera": "Camera", "detected": "detected", - "suspiciousActivity": "Suspicious Activity", - "threateningActivity": "Threatening Activity" + "normalActivity": "Normal", + "needsReview": "Needs review", + "securityConcern": "Security concern" } diff --git a/web/src/components/overlay/chip/GenAISummaryChip.tsx b/web/src/components/overlay/chip/GenAISummaryChip.tsx index 137eec88a..64eb4a10b 100644 --- a/web/src/components/overlay/chip/GenAISummaryChip.tsx +++ b/web/src/components/overlay/chip/GenAISummaryChip.tsx @@ -1,7 +1,11 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; -import { ReviewSegment, ThreatLevel } from "@/types/review"; +import { + ReviewSegment, + ThreatLevel, + THREAT_LEVEL_LABELS, +} from "@/types/review"; import { useEffect, useMemo, useState } from "react"; import { isDesktop } from "react-device-detect"; import { useTranslation } from "react-i18next"; @@ -55,13 +59,22 @@ export function GenAISummaryDialog({ } let concerns = ""; - switch (aiAnalysis.potential_threat_level) { - case ThreatLevel.SUSPICIOUS: - concerns = `• ${t("suspiciousActivity", { ns: "views/events" })}\n`; - break; - case ThreatLevel.DANGER: - concerns = `• ${t("threateningActivity", { ns: "views/events" })}\n`; - break; + const threatLevel = aiAnalysis.potential_threat_level ?? 0; + + if (threatLevel > 0) { + let label = ""; + + switch (threatLevel) { + case ThreatLevel.NEEDS_REVIEW: + label = t("needsReview", { ns: "views/events" }); + break; + case ThreatLevel.SECURITY_CONCERN: + label = t("securityConcern", { ns: "views/events" }); + break; + default: + label = THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] || "Unknown"; + } + concerns = `• ${label}\n`; } (aiAnalysis.other_concerns ?? []).forEach((c) => { diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index ecf63760a..872f7c98a 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -1,7 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useApiHost } from "@/api"; import { isCurrentHour } from "@/utils/dateUtil"; -import { ReviewSegment } from "@/types/review"; +import { + ReviewSegment, + ThreatLevel, + THREAT_LEVEL_LABELS, +} from "@/types/review"; import { getIconForLabel } from "@/utils/iconUtil"; import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; @@ -44,7 +48,7 @@ export default function PreviewThumbnailPlayer({ onClick, onTimeUpdate, }: PreviewPlayerProps) { - const { t } = useTranslation(["components/player"]); + const { t } = useTranslation(["components/player", "views/events"]); const apiHost = useApiHost(); const { data: config } = useSWR("config"); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); @@ -319,11 +323,21 @@ export default function PreviewThumbnailPlayer({
- {review.data.metadata.potential_threat_level == 1 ? ( - <>{t("suspiciousActivity", { ns: "views/events" })} - ) : ( - <>{t("threateningActivity", { ns: "views/events" })} - )} + {(() => { + const threatLevel = + review.data.metadata.potential_threat_level ?? 0; + switch (threatLevel) { + case ThreatLevel.NEEDS_REVIEW: + return t("needsReview", { ns: "views/events" }); + case ThreatLevel.SECURITY_CONCERN: + return t("securityConcern", { ns: "views/events" }); + default: + return ( + THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] || + "Unknown" + ); + } + })()} )} diff --git a/web/src/types/review.ts b/web/src/types/review.ts index 6c9027950..4e7b22334 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -87,6 +87,13 @@ export type ZoomLevel = { }; export enum ThreatLevel { - SUSPICIOUS = 1, - DANGER = 2, + NORMAL = 0, + NEEDS_REVIEW = 1, + SECURITY_CONCERN = 2, } + +export const THREAT_LEVEL_LABELS: Record = { + [ThreatLevel.NORMAL]: "Normal", + [ThreatLevel.NEEDS_REVIEW]: "Needs review", + [ThreatLevel.SECURITY_CONCERN]: "Security concern", +}; From 70e5ce57716b40cd4a2c0dc439ba6b20516c3d27 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:55:11 -0600 Subject: [PATCH 06/10] use base path when fetching go2rtc data --- web/src/hooks/use-camera-live-mode.ts | 10 +++++++--- web/src/utils/cameraUtil.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/web/src/hooks/use-camera-live-mode.ts b/web/src/hooks/use-camera-live-mode.ts index 2bdb1f866..51170dd11 100644 --- a/web/src/hooks/use-camera-live-mode.ts +++ b/web/src/hooks/use-camera-live-mode.ts @@ -1,3 +1,4 @@ +import { baseUrl } from "@/api/baseUrl"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useState, useMemo } from "react"; import useSWR from "swr"; @@ -41,9 +42,12 @@ export default function useCameraLiveMode( const metadataPromises = streamNames.map(async (streamName) => { try { - const response = await fetch(`/api/go2rtc/streams/${streamName}`, { - priority: "low", - }); + const response = await fetch( + `${baseUrl}api/go2rtc/streams/${streamName}`, + { + priority: "low", + }, + ); if (response.ok) { const data = await response.json(); diff --git a/web/src/utils/cameraUtil.ts b/web/src/utils/cameraUtil.ts index 3a28cfe1a..4c2fac082 100644 --- a/web/src/utils/cameraUtil.ts +++ b/web/src/utils/cameraUtil.ts @@ -1,3 +1,4 @@ +import { baseUrl } from "@/api/baseUrl"; import { generateFixedHash, isValidId } from "./stringUtil"; /** @@ -52,9 +53,12 @@ export async function detectReolinkCamera( password, }); - const response = await fetch(`/api/reolink/detect?${params.toString()}`, { - method: "GET", - }); + const response = await fetch( + `${baseUrl}api/reolink/detect?${params.toString()}`, + { + method: "GET", + }, + ); if (!response.ok) { return null; From d0b80d75900385a54e0f8ca8b90f276909e86560 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:39:26 -0600 Subject: [PATCH 07/10] show config error message when starting in safe mode --- web/src/pages/ConfigEditor.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index 364c13252..f3c4d4099 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -49,6 +49,7 @@ function ConfigEditor() { const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); + const initialValidationRef = useRef(false); const onHandleSaveConfig = useCallback( async (save_option: SaveOptions): Promise => { @@ -171,6 +172,33 @@ function ConfigEditor() { }; }, [rawConfig, apiHost, systemTheme, theme, onHandleSaveConfig]); + // when in safe mode, attempt to validate the existing (invalid) config immediately + // so that the user sees the validation errors without needing to press save + useEffect(() => { + if ( + config?.safe_mode && + rawConfig && + !initialValidationRef.current && + !error + ) { + initialValidationRef.current = true; + axios + .post(`config/save?save_option=saveonly`, rawConfig, { + headers: { "Content-Type": "text/plain" }, + }) + .then(() => { + // if this succeeds while in safe mode, we won't force any UI change + }) + .catch((e: AxiosError) => { + const errorMessage = + e.response?.data?.message || + e.response?.data?.detail || + "Unknown error"; + setError(errorMessage); + }); + } + }, [config?.safe_mode, rawConfig, error]); + // monitoring state const [hasChanges, setHasChanges] = useState(false); From 4c18bc7a823c88c07421297abfc65af728bfa050 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:06:21 -0600 Subject: [PATCH 08/10] fix genai migration --- frigate/util/config.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/frigate/util/config.py b/frigate/util/config.py index 5a14b1fa6..d63eaa2e3 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -348,11 +348,11 @@ def migrate_016_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: - """Handle migrating frigate config to 0.16-0""" + """Handle migrating frigate config to 0.17-0""" new_config = config.copy() # migrate global to new recording configuration - global_record_retain = config.get("record", {}).get("retain") + global_record_retain = new_config.get("record", {}).get("retain") if global_record_retain: continuous = {"days": 0} @@ -376,22 +376,32 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] del new_config["record"]["retain"] # migrate global genai to new objects config - global_genai = config.get("genai", {}) + global_genai = new_config.get("genai", {}) if global_genai: new_genai_config = {} - new_object_config = config.get("objects", {}) - new_object_config["genai"] = {} + new_object_config = new_config.get("objects", {}) + new_object_genai_config = new_object_config.get("genai", {}) - for key in global_genai.keys(): + for key, value in global_genai.items(): if key in ["model", "provider", "base_url", "api_key"]: - new_genai_config[key] = global_genai[key] + new_genai_config[key] = value else: - new_object_config["genai"][key] = global_genai[key] + new_object_genai_config[key] = value - config["genai"] = new_genai_config + # Only set genai if there are provider/connection keys to keep + if new_genai_config: + new_config["genai"] = new_genai_config + elif "genai" in new_config: + # Remove genai block if all keys moved to objects + del new_config["genai"] - for name, camera in config.get("cameras", {}).items(): + # Set objects.genai if there are feature/config keys + if new_object_genai_config: + new_object_config["genai"] = new_object_genai_config + new_config["objects"] = new_object_config + + for name, camera in new_config.get("cameras", {}).items(): camera_config: dict[str, dict[str, Any]] = camera.copy() camera_record_retain = camera_config.get("record", {}).get("retain") @@ -415,8 +425,12 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] camera_genai = camera_config.get("genai", {}) if camera_genai: - new_object_config = config.get("objects", {}) - new_object_config["genai"] = camera_genai + # Move camera-level genai to camera's objects.genai + camera_objects_config = camera_config.get("objects", {}) + camera_object_genai_config = camera_objects_config.get("genai", {}) + camera_object_genai_config.update(camera_genai) + camera_objects_config["genai"] = camera_object_genai_config + camera_config["objects"] = camera_objects_config del camera_config["genai"] new_config["cameras"][name] = camera_config From 53865be36d6b9b9d928ca6344395f8e5766d9156 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:16:52 -0600 Subject: [PATCH 09/10] fix genai --- frigate/util/config.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frigate/util/config.py b/frigate/util/config.py index d63eaa2e3..7843c1d17 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -352,7 +352,7 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] new_config = config.copy() # migrate global to new recording configuration - global_record_retain = new_config.get("record", {}).get("retain") + global_record_retain = config.get("record", {}).get("retain") if global_record_retain: continuous = {"days": 0} @@ -376,12 +376,12 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] del new_config["record"]["retain"] # migrate global genai to new objects config - global_genai = new_config.get("genai", {}) + global_genai = config.get("genai", {}) if global_genai: new_genai_config = {} - new_object_config = new_config.get("objects", {}) - new_object_genai_config = new_object_config.get("genai", {}) + new_object_config = new_config.get("objects", {}).copy() + new_object_genai_config = new_object_config.get("genai", {}).copy() for key, value in global_genai.items(): if key in ["model", "provider", "base_url", "api_key"]: @@ -401,7 +401,7 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] new_object_config["genai"] = new_object_genai_config new_config["objects"] = new_object_config - for name, camera in new_config.get("cameras", {}).items(): + for name, camera in config.get("cameras", {}).items(): camera_config: dict[str, dict[str, Any]] = camera.copy() camera_record_retain = camera_config.get("record", {}).get("retain") From bce95783a3e1b0abbf753a3f25a19e752f31d948 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Nov 2025 18:53:08 -0700 Subject: [PATCH 10/10] Fix genai migration --- frigate/util/config.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/frigate/util/config.py b/frigate/util/config.py index 7843c1d17..c3d796397 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -380,26 +380,17 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] if global_genai: new_genai_config = {} - new_object_config = new_config.get("objects", {}).copy() - new_object_genai_config = new_object_config.get("genai", {}).copy() + new_object_config = new_config.get("objects", {}) + new_object_config["genai"] = {} - for key, value in global_genai.items(): + for key in global_genai.keys(): if key in ["model", "provider", "base_url", "api_key"]: - new_genai_config[key] = value + new_genai_config[key] = global_genai[key] else: - new_object_genai_config[key] = value + new_object_config["genai"][key] = global_genai[key] - # Only set genai if there are provider/connection keys to keep - if new_genai_config: - new_config["genai"] = new_genai_config - elif "genai" in new_config: - # Remove genai block if all keys moved to objects - del new_config["genai"] - - # Set objects.genai if there are feature/config keys - if new_object_genai_config: - new_object_config["genai"] = new_object_genai_config - new_config["objects"] = new_object_config + new_config["genai"] = new_genai_config + new_config["objects"] = new_object_config for name, camera in config.get("cameras", {}).items(): camera_config: dict[str, dict[str, Any]] = camera.copy() @@ -425,12 +416,9 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] camera_genai = camera_config.get("genai", {}) if camera_genai: - # Move camera-level genai to camera's objects.genai - camera_objects_config = camera_config.get("objects", {}) - camera_object_genai_config = camera_objects_config.get("genai", {}) - camera_object_genai_config.update(camera_genai) - camera_objects_config["genai"] = camera_object_genai_config - camera_config["objects"] = camera_objects_config + camera_object_config = camera_config.get("objects", {}) + camera_object_config["genai"] = camera_genai + camera_config["objects"] = camera_object_config del camera_config["genai"] new_config["cameras"][name] = camera_config