Miscellaneous Fixes (#21063)
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
co-authored by Josh Hawkins
parent cd606ad240
commit 1b57fb15a7
17 changed files with 349 additions and 98 deletions
@@ -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;
@@ -145,20 +141,67 @@ 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 () => {
// 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 +372,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<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(() => {
if (step1Data.modelType === "state" && isLastClass) {
// Check if all 24 images will be classified after current selections are applied
@@ -353,6 +431,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 +499,17 @@ export default function Step3ChooseExamples({
</div>
) : hasGenerated ? (
<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 && (
<div className="text-center">
<h3 className="text-lg font-medium">
@@ -474,35 +585,22 @@ export default function Step3ChooseExamples({
<Button type="button" onClick={handleBack} className="sm:flex-1">
{t("button.back", { ns: "common" })}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={
allImagesClassified
? handleContinue
: handleContinueClassification
}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
disabled={
!hasGenerated || isGenerating || isProcessing || !canProceed
}
>
{isProcessing && <ActivityIndicator className="size-4" />}
{t("button.continue", { ns: "common" })}
</Button>
</TooltipTrigger>
{!canProceed && (
<TooltipPortal>
<TooltipContent>
{t("wizard.step3.allImagesRequired", {
count: unclassifiedImages.length,
})}
</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
<Button
type="button"
onClick={
allImagesClassified
? handleContinue
: handleContinueClassification
}
variant="select"
className="flex items-center justify-center gap-2 sm:flex-1"
disabled={
!hasGenerated || isGenerating || isProcessing || !canProceed
}
>
{isProcessing && <ActivityIndicator className="size-4" />}
{t("button.continue", { ns: "common" })}
</Button>
</div>
)}
</div>
@@ -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<FrigateConfig>("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}
/>
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
<div
@@ -221,7 +221,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
);
})}
{!isCustomRole && (
{isAdmin && (
<Button
className="bg-secondary text-muted-foreground"
aria-label={t("group.add")}
@@ -245,7 +245,7 @@ type NewGroupDialogProps = {
activeGroup?: string;
setGroup: (value: string | undefined, replace?: boolean | undefined) => 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<FrigateConfig>("config");
@@ -390,7 +390,7 @@ function NewGroupDialog({
>
<Title>{t("group.label")}</Title>
<Description className="sr-only">{t("group.edit")}</Description>
{!isCustomRole && (
{isAdmin && (
<div
className={cn(
"absolute",
@@ -422,7 +422,7 @@ function NewGroupDialog({
group={group}
onDeleteGroup={() => onDeleteGroup(group[0])}
onEditGroup={() => onEditGroup(group)}
isReadOnly={isCustomRole}
isReadOnly={!isAdmin}
/>
))}
</div>
@@ -677,7 +677,7 @@ export function CameraGroupEdit({
);
const allowedCameras = useAllowedCameras();
const isCustomRole = useIsCustomRole();
const isAdmin = useIsAdmin();
const [openCamera, setOpenCamera] = useState<string | null>();
@@ -867,7 +867,7 @@ export function CameraGroupEdit({
<FormMessage />
{[
...(birdseyeConfig?.enabled &&
(!isCustomRole || "birdseye" in allowedCameras)
(isAdmin || "birdseye" in allowedCameras)
? ["birdseye"]
: []),
...Object.keys(config?.cameras ?? {})
@@ -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) => {
@@ -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<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@@ -319,11 +323,21 @@ export default function PreviewThumbnailPlayer({
</TooltipTrigger>
</div>
<TooltipContent className="smart-capitalize">
{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"
);
}
})()}
</TooltipContent>
</Tooltip>
)}
+7 -3
View File
@@ -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();
+18 -1
View File
@@ -17,6 +17,7 @@ export function useHistoryBack({
}: UseHistoryBackOptions): void {
const historyPushedRef = 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
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]);
}
+28
View File
@@ -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<void> => {
@@ -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<ApiErrorResponse>) => {
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);
+4 -4
View File
@@ -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<FrigateConfig>("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) {
+9 -2
View File
@@ -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, string> = {
[ThreatLevel.NORMAL]: "Normal",
[ThreatLevel.NEEDS_REVIEW]: "Needs review",
[ThreatLevel.SECURITY_CONCERN]: "Security concern",
};
+7 -3
View File
@@ -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;
+3 -3
View File
@@ -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 (
<div className="flex size-full items-center justify-center">