Miscellaneous Fixes (#21063)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled

* Fix history management failing when updating URL

* 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.

* Still create all classes

We stil need to create all classes even if the user didn't assign images to them.

* 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)

* Adjust threat level interaction to be less strict

* use base path when fetching go2rtc data

* show config error message when starting in safe mode

* fix genai migration

* fix genai

* Fix genai migration

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen 2025-11-27 07:58:35 -06:00 committed by GitHub
parent cd606ad240
commit 1b57fb15a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 349 additions and 98 deletions

View File

@ -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( @router.post(
"/classification/{name}/train/delete", "/classification/{name}/train/delete",
response_model=GenericResponse, response_model=GenericResponse,

View File

@ -375,7 +375,19 @@ class WebPushClient(Communicator):
ended = state == "end" or state == "genai" ended = state == "end" or state == "genai"
if state == "genai" and payload["after"]["data"]["metadata"]: 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"] message = payload["after"]["data"]["metadata"]["scene"]
else: else:
title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}" title = f"{titlecase(', '.join(sorted_objects).replace('_', ' '))}{' was' if state == 'end' else ''} detected in {titlecase(', '.join(payload['after']['data']['zones']).replace('_', ' '))}"

View File

@ -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). - Group bullets under subheadings when multiple events fall into the same category (e.g., Vehicle Activity, Porch Activity, Unusual Behavior).
- Threat levels - 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. - If multiple events at the same time share the same threat level, only state it once.
- Final assessment - Final assessment
- End with a Final Assessment section. - 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. 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 - Conciseness
- Do not repeat benign clothing/appearance details unless they distinguish individuals. - Do not repeat benign clothing/appearance details unless they distinguish individuals.

View File

@ -348,7 +348,7 @@ 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]]: 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() new_config = config.copy()
# migrate global to new recording configuration # migrate global to new recording configuration
@ -380,7 +380,7 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
if global_genai: if global_genai:
new_genai_config = {} new_genai_config = {}
new_object_config = config.get("objects", {}) new_object_config = new_config.get("objects", {})
new_object_config["genai"] = {} new_object_config["genai"] = {}
for key in global_genai.keys(): for key in global_genai.keys():
@ -389,7 +389,8 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
else: else:
new_object_config["genai"][key] = global_genai[key] new_object_config["genai"][key] = global_genai[key]
config["genai"] = new_genai_config new_config["genai"] = new_genai_config
new_config["objects"] = new_object_config
for name, camera in config.get("cameras", {}).items(): for name, camera in config.get("cameras", {}).items():
camera_config: dict[str, dict[str, Any]] = camera.copy() camera_config: dict[str, dict[str, Any]] = camera.copy()
@ -415,8 +416,9 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
camera_genai = camera_config.get("genai", {}) camera_genai = camera_config.get("genai", {})
if camera_genai: if camera_genai:
new_object_config = config.get("objects", {}) camera_object_config = camera_config.get("objects", {})
new_object_config["genai"] = camera_genai camera_object_config["genai"] = camera_genai
camera_config["objects"] = camera_object_config
del camera_config["genai"] del camera_config["genai"]
new_config["cameras"][name] = camera_config new_config["cameras"][name] = camera_config

View File

@ -166,6 +166,7 @@
"noImages": "No sample images generated", "noImages": "No sample images generated",
"classifying": "Classifying & Training...", "classifying": "Classifying & Training...",
"trainingStarted": "Training started successfully", "trainingStarted": "Training started successfully",
"modelCreated": "Model created successfully. Use the Recent Classifications view to add images for missing states, then train the model.",
"errors": { "errors": {
"noCameras": "No cameras configured", "noCameras": "No cameras configured",
"noObjectLabel": "No object label selected", "noObjectLabel": "No object label selected",
@ -173,7 +174,11 @@
"generationFailed": "Generation failed. Please try again.", "generationFailed": "Generation failed. Please try again.",
"classifyFailed": "Failed to classify images: {{error}}" "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."
}
} }
} }
} }

View File

@ -54,6 +54,7 @@
"selected_other": "{{count}} selected", "selected_other": "{{count}} selected",
"camera": "Camera", "camera": "Camera",
"detected": "detected", "detected": "detected",
"suspiciousActivity": "Suspicious Activity", "normalActivity": "Normal",
"threateningActivity": "Threatening Activity" "needsReview": "Needs review",
"securityConcern": "Security concern"
} }

View File

@ -10,12 +10,8 @@ import useSWR from "swr";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
Tooltip, import { IoIosWarning } from "react-icons/io";
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
export type Step3FormData = { export type Step3FormData = {
examplesGenerated: boolean; examplesGenerated: boolean;
@ -145,20 +141,67 @@ export default function Step3ChooseExamples({
); );
await Promise.all(categorizePromises); await Promise.all(categorizePromises);
// Step 3: Kick off training // Step 2.5: Create empty folders for classes that don't have any images
await axios.post(`/classification/${step1Data.modelName}/train`); // 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"), { // Step 3: Determine if we should train
closeButton: true, // For state models, we need ALL states to have examples
}); // For object models, we need at least 2 classes with images
setIsTraining(true); 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 () => { const handleContinueClassification = useCallback(async () => {
// Mark selected images with current class // Mark selected images with current class
const newClassifications = { ...imageClassifications }; 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) => { selectedImages.forEach((imageName) => {
newClassifications[imageName] = currentClass; newClassifications[imageName] = currentClass;
}); });
@ -329,8 +372,43 @@ export default function Step3ChooseExamples({
return unclassifiedImages.length === 0; return unclassifiedImages.length === 0;
}, [unclassifiedImages]); }, [unclassifiedImages]);
// For state models on the last class, require all images to be classified
const isLastClass = currentClassIndex === allClasses.length - 1; const isLastClass = currentClassIndex === allClasses.length - 1;
const statesWithExamples = useMemo(() => {
if (step1Data.modelType !== "state") return new Set<string>();
const states = new Set<string>();
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(() => { const canProceed = useMemo(() => {
if (step1Data.modelType === "state" && isLastClass) { if (step1Data.modelType === "state" && isLastClass) {
// Check if all 24 images will be classified after current selections are applied // Check if all 24 images will be classified after current selections are applied
@ -353,6 +431,28 @@ export default function Step3ChooseExamples({
selectedImages, 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(() => { const handleBack = useCallback(() => {
if (currentClassIndex > 0) { if (currentClassIndex > 0) {
const previousClass = allClasses[currentClassIndex - 1]; const previousClass = allClasses[currentClassIndex - 1];
@ -399,6 +499,17 @@ export default function Step3ChooseExamples({
</div> </div>
) : hasGenerated ? ( ) : hasGenerated ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{showMissingStatesWarning && (
<Alert variant="destructive">
<IoIosWarning className="size-5" />
<AlertTitle>
{t("wizard.step3.missingStatesWarning.title")}
</AlertTitle>
<AlertDescription>
{t("wizard.step3.missingStatesWarning.description")}
</AlertDescription>
</Alert>
)}
{!allImagesClassified && ( {!allImagesClassified && (
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-medium"> <h3 className="text-lg font-medium">
@ -474,35 +585,22 @@ export default function Step3ChooseExamples({
<Button type="button" onClick={handleBack} className="sm:flex-1"> <Button type="button" onClick={handleBack} className="sm:flex-1">
{t("button.back", { ns: "common" })} {t("button.back", { ns: "common" })}
</Button> </Button>
<Tooltip> <Button
<TooltipTrigger asChild> type="button"
<Button onClick={
type="button" allImagesClassified
onClick={ ? handleContinue
allImagesClassified : handleContinueClassification
? handleContinue }
: handleContinueClassification variant="select"
} className="flex items-center justify-center gap-2 sm:flex-1"
variant="select" disabled={
className="flex items-center justify-center gap-2 sm:flex-1" !hasGenerated || isGenerating || isProcessing || !canProceed
disabled={ }
!hasGenerated || isGenerating || isProcessing || !canProceed >
} {isProcessing && <ActivityIndicator className="size-4" />}
> {t("button.continue", { ns: "common" })}
{isProcessing && <ActivityIndicator className="size-4" />} </Button>
{t("button.continue", { ns: "common" })}
</Button>
</TooltipTrigger>
{!canProceed && (
<TooltipPortal>
<TooltipContent>
{t("wizard.step3.allImagesRequired", {
count: unclassifiedImages.length,
})}
</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
</div> </div>
)} )}
</div> </div>

View File

@ -78,7 +78,7 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role"; import { useIsAdmin } from "@/hooks/use-is-admin";
type CameraGroupSelectorProps = { type CameraGroupSelectorProps = {
className?: string; className?: string;
@ -88,7 +88,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const { t } = useTranslation(["components/camera"]); const { t } = useTranslation(["components/camera"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras(); const allowedCameras = useAllowedCameras();
const isCustomRole = useIsCustomRole(); const isAdmin = useIsAdmin();
// tooltip // tooltip
@ -124,7 +124,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const allGroups = Object.entries(config.camera_groups); const allGroups = Object.entries(config.camera_groups);
// If custom role, filter out groups where user has no accessible cameras // If custom role, filter out groups where user has no accessible cameras
if (isCustomRole) { if (!isAdmin) {
return allGroups return allGroups
.filter(([, groupConfig]) => { .filter(([, groupConfig]) => {
// Check if user has access to at least one camera in this group // 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); return allGroups.sort((a, b) => a[1].order - b[1].order);
}, [config, allowedCameras, isCustomRole]); }, [config, allowedCameras, isAdmin]);
// add group // add group
@ -153,7 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
activeGroup={group} activeGroup={group}
setGroup={setGroup} setGroup={setGroup}
deleteGroup={deleteGroup} deleteGroup={deleteGroup}
isCustomRole={isCustomRole} isAdmin={isAdmin}
/> />
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}> <Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
<div <div
@ -221,7 +221,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
); );
})} })}
{!isCustomRole && ( {isAdmin && (
<Button <Button
className="bg-secondary text-muted-foreground" className="bg-secondary text-muted-foreground"
aria-label={t("group.add")} aria-label={t("group.add")}
@ -245,7 +245,7 @@ type NewGroupDialogProps = {
activeGroup?: string; activeGroup?: string;
setGroup: (value: string | undefined, replace?: boolean | undefined) => void; setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
deleteGroup: () => void; deleteGroup: () => void;
isCustomRole?: boolean; isAdmin?: boolean;
}; };
function NewGroupDialog({ function NewGroupDialog({
open, open,
@ -254,7 +254,7 @@ function NewGroupDialog({
activeGroup, activeGroup,
setGroup, setGroup,
deleteGroup, deleteGroup,
isCustomRole, isAdmin,
}: NewGroupDialogProps) { }: NewGroupDialogProps) {
const { t } = useTranslation(["components/camera"]); const { t } = useTranslation(["components/camera"]);
const { mutate: updateConfig } = useSWR<FrigateConfig>("config"); const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
@ -390,7 +390,7 @@ function NewGroupDialog({
> >
<Title>{t("group.label")}</Title> <Title>{t("group.label")}</Title>
<Description className="sr-only">{t("group.edit")}</Description> <Description className="sr-only">{t("group.edit")}</Description>
{!isCustomRole && ( {isAdmin && (
<div <div
className={cn( className={cn(
"absolute", "absolute",
@ -422,7 +422,7 @@ function NewGroupDialog({
group={group} group={group}
onDeleteGroup={() => onDeleteGroup(group[0])} onDeleteGroup={() => onDeleteGroup(group[0])}
onEditGroup={() => onEditGroup(group)} onEditGroup={() => onEditGroup(group)}
isReadOnly={isCustomRole} isReadOnly={!isAdmin}
/> />
))} ))}
</div> </div>
@ -677,7 +677,7 @@ export function CameraGroupEdit({
); );
const allowedCameras = useAllowedCameras(); const allowedCameras = useAllowedCameras();
const isCustomRole = useIsCustomRole(); const isAdmin = useIsAdmin();
const [openCamera, setOpenCamera] = useState<string | null>(); const [openCamera, setOpenCamera] = useState<string | null>();
@ -867,7 +867,7 @@ export function CameraGroupEdit({
<FormMessage /> <FormMessage />
{[ {[
...(birdseyeConfig?.enabled && ...(birdseyeConfig?.enabled &&
(!isCustomRole || "birdseye" in allowedCameras) (isAdmin || "birdseye" in allowedCameras)
? ["birdseye"] ? ["birdseye"]
: []), : []),
...Object.keys(config?.cameras ?? {}) ...Object.keys(config?.cameras ?? {})

View File

@ -1,7 +1,11 @@
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { cn } from "@/lib/utils"; 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 { useEffect, useMemo, useState } from "react";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -55,13 +59,22 @@ export function GenAISummaryDialog({
} }
let concerns = ""; let concerns = "";
switch (aiAnalysis.potential_threat_level) { const threatLevel = aiAnalysis.potential_threat_level ?? 0;
case ThreatLevel.SUSPICIOUS:
concerns = `${t("suspiciousActivity", { ns: "views/events" })}\n`; if (threatLevel > 0) {
break; let label = "";
case ThreatLevel.DANGER:
concerns = `${t("threateningActivity", { ns: "views/events" })}\n`; switch (threatLevel) {
break; 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) => { (aiAnalysis.other_concerns ?? []).forEach((c) => {

View File

@ -1,7 +1,11 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { isCurrentHour } from "@/utils/dateUtil"; 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 { getIconForLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr"; import useSWR from "swr";
@ -44,7 +48,7 @@ export default function PreviewThumbnailPlayer({
onClick, onClick,
onTimeUpdate, onTimeUpdate,
}: PreviewPlayerProps) { }: PreviewPlayerProps) {
const { t } = useTranslation(["components/player"]); const { t } = useTranslation(["components/player", "views/events"]);
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@ -319,11 +323,21 @@ export default function PreviewThumbnailPlayer({
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipContent className="smart-capitalize"> <TooltipContent className="smart-capitalize">
{review.data.metadata.potential_threat_level == 1 ? ( {(() => {
<>{t("suspiciousActivity", { ns: "views/events" })}</> const threatLevel =
) : ( review.data.metadata.potential_threat_level ?? 0;
<>{t("threateningActivity", { ns: "views/events" })}</> 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"
);
}
})()}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}

View File

@ -1,3 +1,4 @@
import { baseUrl } from "@/api/baseUrl";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useState, useMemo } from "react"; import { useCallback, useEffect, useState, useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
@ -41,9 +42,12 @@ export default function useCameraLiveMode(
const metadataPromises = streamNames.map(async (streamName) => { const metadataPromises = streamNames.map(async (streamName) => {
try { try {
const response = await fetch(`/api/go2rtc/streams/${streamName}`, { const response = await fetch(
priority: "low", `${baseUrl}api/go2rtc/streams/${streamName}`,
}); {
priority: "low",
},
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();

View File

@ -17,6 +17,7 @@ export function useHistoryBack({
}: UseHistoryBackOptions): void { }: UseHistoryBackOptions): void {
const historyPushedRef = React.useRef(false); const historyPushedRef = React.useRef(false);
const closedByBackRef = React.useRef(false); const closedByBackRef = React.useRef(false);
const urlWhenOpenedRef = React.useRef<string | null>(null);
// Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes // Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes
const onCloseRef = React.useRef(onClose); const onCloseRef = React.useRef(onClose);
@ -30,6 +31,9 @@ export function useHistoryBack({
if (open) { if (open) {
// Only push history state if we haven't already (prevents duplicates in strict mode) // Only push history state if we haven't already (prevents duplicates in strict mode)
if (!historyPushedRef.current) { 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 }, ""); window.history.pushState({ overlayOpen: true }, "");
historyPushedRef.current = true; historyPushedRef.current = true;
} }
@ -37,6 +41,7 @@ export function useHistoryBack({
const handlePopState = () => { const handlePopState = () => {
closedByBackRef.current = true; closedByBackRef.current = true;
historyPushedRef.current = false; historyPushedRef.current = false;
urlWhenOpenedRef.current = null;
onCloseRef.current(); onCloseRef.current();
}; };
@ -48,10 +53,22 @@ export function useHistoryBack({
} else { } else {
// Overlay is closing - clean up history if we pushed and it wasn't via back button // Overlay is closing - clean up history if we pushed and it wasn't via back button
if (historyPushedRef.current && !closedByBackRef.current) { 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; historyPushedRef.current = false;
closedByBackRef.current = false; closedByBackRef.current = false;
urlWhenOpenedRef.current = null;
} }
}, [enabled, open]); }, [enabled, open]);
} }

View File

@ -49,6 +49,7 @@ function ConfigEditor() {
const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart(); const { send: sendRestart } = useRestart();
const initialValidationRef = useRef(false);
const onHandleSaveConfig = useCallback( const onHandleSaveConfig = useCallback(
async (save_option: SaveOptions): Promise<void> => { async (save_option: SaveOptions): Promise<void> => {
@ -171,6 +172,33 @@ function ConfigEditor() {
}; };
}, [rawConfig, apiHost, systemTheme, theme, onHandleSaveConfig]); }, [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<ApiErrorResponse>) => {
const errorMessage =
e.response?.data?.message ||
e.response?.data?.detail ||
"Unknown error";
setError(errorMessage);
});
}
}, [config?.safe_mode, rawConfig, error]);
// monitoring state // monitoring state
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);

View File

@ -14,12 +14,12 @@ import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role"; import { useIsAdmin } from "@/hooks/use-is-admin";
function Live() { function Live() {
const { t } = useTranslation(["views/live"]); const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const isCustomRole = useIsCustomRole(); const isAdmin = useIsAdmin();
// selection // selection
@ -94,7 +94,7 @@ function Live() {
const includesBirdseye = useMemo(() => { const includesBirdseye = useMemo(() => {
// Restricted users should never have access to birdseye // Restricted users should never have access to birdseye
if (isCustomRole) { if (!isAdmin) {
return false; return false;
} }
@ -109,7 +109,7 @@ function Live() {
} else { } else {
return false; return false;
} }
}, [config, cameraGroup, isCustomRole]); }, [config, cameraGroup, isAdmin]);
const cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config) { if (!config) {

View File

@ -87,6 +87,13 @@ export type ZoomLevel = {
}; };
export enum ThreatLevel { export enum ThreatLevel {
SUSPICIOUS = 1, NORMAL = 0,
DANGER = 2, NEEDS_REVIEW = 1,
SECURITY_CONCERN = 2,
} }
export const THREAT_LEVEL_LABELS: Record<ThreatLevel, string> = {
[ThreatLevel.NORMAL]: "Normal",
[ThreatLevel.NEEDS_REVIEW]: "Needs review",
[ThreatLevel.SECURITY_CONCERN]: "Security concern",
};

View File

@ -1,3 +1,4 @@
import { baseUrl } from "@/api/baseUrl";
import { generateFixedHash, isValidId } from "./stringUtil"; import { generateFixedHash, isValidId } from "./stringUtil";
/** /**
@ -52,9 +53,12 @@ export async function detectReolinkCamera(
password, password,
}); });
const response = await fetch(`/api/reolink/detect?${params.toString()}`, { const response = await fetch(
method: "GET", `${baseUrl}api/reolink/detect?${params.toString()}`,
}); {
method: "GET",
},
);
if (!response.ok) { if (!response.ok) {
return null; return null;

View File

@ -54,7 +54,7 @@ import { useTranslation } from "react-i18next";
import { EmptyCard } from "@/components/card/EmptyCard"; import { EmptyCard } from "@/components/card/EmptyCard";
import { BsFillCameraVideoOffFill } from "react-icons/bs"; import { BsFillCameraVideoOffFill } from "react-icons/bs";
import { AuthContext } from "@/context/auth-context"; import { AuthContext } from "@/context/auth-context";
import { useIsCustomRole } from "@/hooks/use-is-custom-role"; import { useIsAdmin } from "@/hooks/use-is-admin";
type LiveDashboardViewProps = { type LiveDashboardViewProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
@ -661,10 +661,10 @@ export default function LiveDashboardView({
function NoCameraView() { function NoCameraView() {
const { t } = useTranslation(["views/live"]); const { t } = useTranslation(["views/live"]);
const { auth } = useContext(AuthContext); const { auth } = useContext(AuthContext);
const isCustomRole = useIsCustomRole(); const isAdmin = useIsAdmin();
// Check if this is a restricted user with no cameras in this group // Check if this is a restricted user with no cameras in this group
const isRestricted = isCustomRole && auth.isAuthenticated; const isRestricted = !isAdmin && auth.isAuthenticated;
return ( return (
<div className="flex size-full items-center justify-center"> <div className="flex size-full items-center justify-center">