mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-18 19:16:42 +03:00
Compare commits
1 Commits
8796d2eb83
...
90a0c39137
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a0c39137 |
@ -870,46 +870,6 @@ 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,
|
||||
|
||||
@ -375,19 +375,7 @@ class WebPushClient(Communicator):
|
||||
ended = state == "end" or state == "genai"
|
||||
|
||||
if state == "genai" and payload["after"]["data"]["metadata"]:
|
||||
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
|
||||
|
||||
title = payload["after"]["data"]["metadata"]["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('_', ' '))}"
|
||||
|
||||
@ -205,20 +205,14 @@ 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 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).
|
||||
- Always show (threat level: X) for each event.
|
||||
- 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 0:
|
||||
- If all events are threat level 1 with no escalation:
|
||||
Final assessment: Only normal residential activity observed during this period.
|
||||
- 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.
|
||||
- If threat level 2+ events are present, clearly summarize them as Potential concerns requiring review.
|
||||
|
||||
- Conciseness
|
||||
- Do not repeat benign clothing/appearance details unless they distinguish individuals.
|
||||
|
||||
@ -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]]:
|
||||
"""Handle migrating frigate config to 0.17-0"""
|
||||
"""Handle migrating frigate config to 0.16-0"""
|
||||
new_config = config.copy()
|
||||
|
||||
# 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:
|
||||
new_genai_config = {}
|
||||
new_object_config = new_config.get("objects", {})
|
||||
new_object_config = config.get("objects", {})
|
||||
new_object_config["genai"] = {}
|
||||
|
||||
for key in global_genai.keys():
|
||||
@ -389,8 +389,7 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
else:
|
||||
new_object_config["genai"][key] = global_genai[key]
|
||||
|
||||
new_config["genai"] = new_genai_config
|
||||
new_config["objects"] = new_object_config
|
||||
config["genai"] = new_genai_config
|
||||
|
||||
for name, camera in config.get("cameras", {}).items():
|
||||
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||
@ -416,9 +415,8 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
camera_genai = camera_config.get("genai", {})
|
||||
|
||||
if camera_genai:
|
||||
camera_object_config = camera_config.get("objects", {})
|
||||
camera_object_config["genai"] = camera_genai
|
||||
camera_config["objects"] = camera_object_config
|
||||
new_object_config = config.get("objects", {})
|
||||
new_object_config["genai"] = camera_genai
|
||||
del camera_config["genai"]
|
||||
|
||||
new_config["cameras"][name] = camera_config
|
||||
|
||||
@ -166,7 +166,6 @@
|
||||
"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",
|
||||
@ -174,11 +173,7 @@
|
||||
"generationFailed": "Generation failed. Please try again.",
|
||||
"classifyFailed": "Failed to classify images: {{error}}"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
"generateSuccess": "Successfully generated sample images"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +54,6 @@
|
||||
"selected_other": "{{count}} selected",
|
||||
"camera": "Camera",
|
||||
"detected": "detected",
|
||||
"normalActivity": "Normal",
|
||||
"needsReview": "Needs review",
|
||||
"securityConcern": "Security concern"
|
||||
"suspiciousActivity": "Suspicious Activity",
|
||||
"threateningActivity": "Threatening Activity"
|
||||
}
|
||||
|
||||
@ -10,8 +10,12 @@ import useSWR from "swr";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
export type Step3FormData = {
|
||||
examplesGenerated: boolean;
|
||||
@ -141,67 +145,20 @@ export default function Step3ChooseExamples({
|
||||
);
|
||||
await Promise.all(categorizePromises);
|
||||
|
||||
// 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);
|
||||
// Step 3: Kick off training
|
||||
await axios.post(`/classification/${step1Data.modelName}/train`);
|
||||
|
||||
// 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();
|
||||
}
|
||||
toast.success(t("wizard.step3.trainingStarted"), {
|
||||
closeButton: true,
|
||||
});
|
||||
setIsTraining(true);
|
||||
},
|
||||
[step1Data, step2Data, t, onClose],
|
||||
[step1Data, step2Data, t],
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
@ -372,43 +329,8 @@ export default function Step3ChooseExamples({
|
||||
return unclassifiedImages.length === 0;
|
||||
}, [unclassifiedImages]);
|
||||
|
||||
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 isLastClass = currentClassIndex === allClasses.length - 1;
|
||||
const canProceed = useMemo(() => {
|
||||
if (step1Data.modelType === "state" && isLastClass) {
|
||||
// Check if all 24 images will be classified after current selections are applied
|
||||
@ -431,28 +353,6 @@ 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];
|
||||
@ -499,17 +399,6 @@ 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">
|
||||
@ -585,22 +474,35 @@ export default function Step3ChooseExamples({
|
||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<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>
|
||||
<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>
|
||||
</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 { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||
|
||||
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 isAdmin = useIsAdmin();
|
||||
const isCustomRole = useIsCustomRole();
|
||||
|
||||
// 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 (!isAdmin) {
|
||||
if (isCustomRole) {
|
||||
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, isAdmin]);
|
||||
}, [config, allowedCameras, isCustomRole]);
|
||||
|
||||
// add group
|
||||
|
||||
@ -153,7 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
activeGroup={group}
|
||||
setGroup={setGroup}
|
||||
deleteGroup={deleteGroup}
|
||||
isAdmin={isAdmin}
|
||||
isCustomRole={isCustomRole}
|
||||
/>
|
||||
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||
<div
|
||||
@ -221,7 +221,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
{!isCustomRole && (
|
||||
<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;
|
||||
isAdmin?: boolean;
|
||||
isCustomRole?: boolean;
|
||||
};
|
||||
function NewGroupDialog({
|
||||
open,
|
||||
@ -254,7 +254,7 @@ function NewGroupDialog({
|
||||
activeGroup,
|
||||
setGroup,
|
||||
deleteGroup,
|
||||
isAdmin,
|
||||
isCustomRole,
|
||||
}: 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>
|
||||
{isAdmin && (
|
||||
{!isCustomRole && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute",
|
||||
@ -422,7 +422,7 @@ function NewGroupDialog({
|
||||
group={group}
|
||||
onDeleteGroup={() => onDeleteGroup(group[0])}
|
||||
onEditGroup={() => onEditGroup(group)}
|
||||
isReadOnly={!isAdmin}
|
||||
isReadOnly={isCustomRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -677,7 +677,7 @@ export function CameraGroupEdit({
|
||||
);
|
||||
|
||||
const allowedCameras = useAllowedCameras();
|
||||
const isAdmin = useIsAdmin();
|
||||
const isCustomRole = useIsCustomRole();
|
||||
|
||||
const [openCamera, setOpenCamera] = useState<string | null>();
|
||||
|
||||
@ -867,7 +867,7 @@ export function CameraGroupEdit({
|
||||
<FormMessage />
|
||||
{[
|
||||
...(birdseyeConfig?.enabled &&
|
||||
(isAdmin || "birdseye" in allowedCameras)
|
||||
(!isCustomRole || "birdseye" in allowedCameras)
|
||||
? ["birdseye"]
|
||||
: []),
|
||||
...Object.keys(config?.cameras ?? {})
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ReviewSegment,
|
||||
ThreatLevel,
|
||||
THREAT_LEVEL_LABELS,
|
||||
} from "@/types/review";
|
||||
import { ReviewSegment, ThreatLevel } from "@/types/review";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -59,22 +55,13 @@ export function GenAISummaryDialog({
|
||||
}
|
||||
|
||||
let concerns = "";
|
||||
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`;
|
||||
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;
|
||||
}
|
||||
|
||||
(aiAnalysis.other_concerns ?? []).forEach((c) => {
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import {
|
||||
ReviewSegment,
|
||||
ThreatLevel,
|
||||
THREAT_LEVEL_LABELS,
|
||||
} from "@/types/review";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import useSWR from "swr";
|
||||
@ -48,7 +44,7 @@ export default function PreviewThumbnailPlayer({
|
||||
onClick,
|
||||
onTimeUpdate,
|
||||
}: PreviewPlayerProps) {
|
||||
const { t } = useTranslation(["components/player", "views/events"]);
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||
@ -323,21 +319,11 @@ export default function PreviewThumbnailPlayer({
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipContent className="smart-capitalize">
|
||||
{(() => {
|
||||
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"
|
||||
);
|
||||
}
|
||||
})()}
|
||||
{review.data.metadata.potential_threat_level == 1 ? (
|
||||
<>{t("suspiciousActivity", { ns: "views/events" })}</>
|
||||
) : (
|
||||
<>{t("threateningActivity", { ns: "views/events" })}</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
@ -42,12 +41,9 @@ export default function useCameraLiveMode(
|
||||
|
||||
const metadataPromises = streamNames.map(async (streamName) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}api/go2rtc/streams/${streamName}`,
|
||||
{
|
||||
priority: "low",
|
||||
},
|
||||
);
|
||||
const response = await fetch(`/api/go2rtc/streams/${streamName}`, {
|
||||
priority: "low",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
@ -17,7 +17,6 @@ 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);
|
||||
@ -31,9 +30,6 @@ 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;
|
||||
}
|
||||
@ -41,7 +37,6 @@ export function useHistoryBack({
|
||||
const handlePopState = () => {
|
||||
closedByBackRef.current = true;
|
||||
historyPushedRef.current = false;
|
||||
urlWhenOpenedRef.current = null;
|
||||
onCloseRef.current();
|
||||
};
|
||||
|
||||
@ -53,22 +48,10 @@ 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) {
|
||||
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
|
||||
window.history.back();
|
||||
}
|
||||
historyPushedRef.current = false;
|
||||
closedByBackRef.current = false;
|
||||
urlWhenOpenedRef.current = null;
|
||||
}
|
||||
}, [enabled, open]);
|
||||
}
|
||||
|
||||
@ -49,7 +49,6 @@ function ConfigEditor() {
|
||||
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const { send: sendRestart } = useRestart();
|
||||
const initialValidationRef = useRef(false);
|
||||
|
||||
const onHandleSaveConfig = useCallback(
|
||||
async (save_option: SaveOptions): Promise<void> => {
|
||||
@ -172,33 +171,6 @@ 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);
|
||||
|
||||
@ -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 { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||
|
||||
function Live() {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const isAdmin = useIsAdmin();
|
||||
const isCustomRole = useIsCustomRole();
|
||||
|
||||
// selection
|
||||
|
||||
@ -94,7 +94,7 @@ function Live() {
|
||||
|
||||
const includesBirdseye = useMemo(() => {
|
||||
// Restricted users should never have access to birdseye
|
||||
if (!isAdmin) {
|
||||
if (isCustomRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -109,7 +109,7 @@ function Live() {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, [config, cameraGroup, isAdmin]);
|
||||
}, [config, cameraGroup, isCustomRole]);
|
||||
|
||||
const cameras = useMemo(() => {
|
||||
if (!config) {
|
||||
|
||||
@ -87,13 +87,6 @@ export type ZoomLevel = {
|
||||
};
|
||||
|
||||
export enum ThreatLevel {
|
||||
NORMAL = 0,
|
||||
NEEDS_REVIEW = 1,
|
||||
SECURITY_CONCERN = 2,
|
||||
SUSPICIOUS = 1,
|
||||
DANGER = 2,
|
||||
}
|
||||
|
||||
export const THREAT_LEVEL_LABELS: Record<ThreatLevel, string> = {
|
||||
[ThreatLevel.NORMAL]: "Normal",
|
||||
[ThreatLevel.NEEDS_REVIEW]: "Needs review",
|
||||
[ThreatLevel.SECURITY_CONCERN]: "Security concern",
|
||||
};
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { generateFixedHash, isValidId } from "./stringUtil";
|
||||
|
||||
/**
|
||||
@ -53,12 +52,9 @@ export async function detectReolinkCamera(
|
||||
password,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}api/reolink/detect?${params.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
const response = await fetch(`/api/reolink/detect?${params.toString()}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
|
||||
@ -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 { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||
|
||||
type LiveDashboardViewProps = {
|
||||
cameras: CameraConfig[];
|
||||
@ -661,10 +661,10 @@ export default function LiveDashboardView({
|
||||
function NoCameraView() {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { auth } = useContext(AuthContext);
|
||||
const isAdmin = useIsAdmin();
|
||||
const isCustomRole = useIsCustomRole();
|
||||
|
||||
// Check if this is a restricted user with no cameras in this group
|
||||
const isRestricted = !isAdmin && auth.isAuthenticated;
|
||||
const isRestricted = isCustomRole && auth.isAuthenticated;
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user