mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-18 19:16:42 +03:00
Compare commits
5 Commits
1e8d5e6741
...
e8e48b8ac0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e48b8ac0 | ||
|
|
1b57fb15a7 | ||
|
|
33048ebc01 | ||
|
|
665c5c9ea6 | ||
|
|
5febd5e178 |
53
README_CN.md
53
README_CN.md
@ -1,28 +1,31 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img align="center" alt="logo" src="docs/static/img/frigate.png">
|
<img align="center" alt="logo" src="docs/static/img/branding/frigate.png">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Frigate - 一个具有实时目标检测的本地NVR
|
# Frigate NVR™ - 一个具有实时目标检测的本地 NVR
|
||||||
|
|
||||||
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
|
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/frigate-nvr/-/zh_Hans/">
|
<a href="https://hosted.weblate.org/engage/frigate-nvr/-/zh_Hans/">
|
||||||
<img src="https://hosted.weblate.org/widget/frigate-nvr/-/zh_Hans/svg-badge.svg" alt="翻译状态" />
|
<img src="https://hosted.weblate.org/widget/frigate-nvr/-/zh_Hans/svg-badge.svg" alt="翻译状态" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。
|
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备 AI 目标/物体检测功能。使用 OpenCV 和 TensorFlow 在本地为 IP 摄像头执行实时物体检测。
|
||||||
|
|
||||||
强烈推荐使用GPU或者AI加速器(例如[Google Coral加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/))。它们的性能甚至超过目前的顶级CPU,并且可以以极低的耗电实现更优的性能。
|
强烈推荐使用 GPU 或者 AI 加速器(例如[Google Coral 加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/)等)。它们的运行效率远远高于现在的顶级 CPU,并且功耗也极低。
|
||||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
|
|
||||||
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
|
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与 Home Assistant 紧密集成
|
||||||
|
- 设计上通过仅在必要时和必要地点寻找目标,最大限度地减少资源使用并最大化性能
|
||||||
- 大量利用多进程处理,强调实时性而非处理每一帧
|
- 大量利用多进程处理,强调实时性而非处理每一帧
|
||||||
- 使用非常低开销的运动检测来确定运行物体检测的位置
|
- 使用非常低开销的画面变动检测(也叫运动检测)来确定运行目标检测的位置
|
||||||
- 使用TensorFlow进行物体检测,运行在单独的进程中以达到最大FPS
|
- 使用 TensorFlow 进行目标检测,并运行在单独的进程中以达到最大 FPS
|
||||||
- 通过MQTT进行通信,便于集成到其他系统中
|
- 通过 MQTT 进行通信,便于集成到其他系统中
|
||||||
- 根据检测到的物体设置保留时间进行视频录制
|
- 根据检测到的物体设置保留时间进行视频录制
|
||||||
- 24/7全天候录制
|
- 24/7 全天候录制
|
||||||
- 通过RTSP重新流传输以减少摄像头的连接数
|
- 通过 RTSP 重新流传输以减少摄像头的连接数
|
||||||
- 支持WebRTC和MSE,实现低延迟的实时观看
|
- 支持 WebRTC 和 MSE,实现低延迟的实时观看
|
||||||
|
|
||||||
## 社区中文翻译文档
|
## 社区中文翻译文档
|
||||||
|
|
||||||
@ -32,39 +35,55 @@
|
|||||||
|
|
||||||
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
|
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
|
||||||
|
|
||||||
|
## 协议
|
||||||
|
|
||||||
|
本项目采用 **MIT 许可证**授权。
|
||||||
|
**代码部分**:本代码库中的源代码、配置文件和文档均遵循 [MIT 许可证](LICENSE)。您可以自由使用、修改和分发这些代码,但必须保留原始版权声明。
|
||||||
|
|
||||||
|
**商标部分**:“Frigate”名称、“Frigate NVR”品牌以及 Frigate 的 Logo 为 **Frigate LLC 的商标**,**不在** MIT 许可证覆盖范围内。
|
||||||
|
有关品牌资产的规范使用详情,请参阅我们的[《商标政策》](TRADEMARK.md)。
|
||||||
|
|
||||||
## 截图
|
## 截图
|
||||||
|
|
||||||
### 实时监控面板
|
### 实时监控面板
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
|
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 简单的核查工作流程
|
### 简单的核查工作流程
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
|
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 多摄像头可按时间轴查看
|
### 多摄像头可按时间轴查看
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
|
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 内置遮罩和区域编辑器
|
### 内置遮罩和区域编辑器
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
|
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
## 翻译
|
## 翻译
|
||||||
|
|
||||||
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
|
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
|
||||||
|
|
||||||
|
|
||||||
## 非官方中文讨论社区
|
## 非官方中文讨论社区
|
||||||
欢迎加入中文讨论QQ群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
|
||||||
|
欢迎加入中文讨论 QQ 群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
||||||
|
|
||||||
Bilibili:https://space.bilibili.com/3546894915602564
|
Bilibili:https://space.bilibili.com/3546894915602564
|
||||||
|
|
||||||
|
|
||||||
## 中文社区赞助商
|
## 中文社区赞助商
|
||||||
|
|
||||||
[](https://edgeone.ai/zh?from=github)
|
[](https://edgeone.ai/zh?from=github)
|
||||||
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
|
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Copyright © 2025 Frigate LLC.**
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
.alert {
|
.alert {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #fff8e6;
|
background: #fff8e6;
|
||||||
border-bottom: 1px solid #ffd166;
|
border-bottom: 1px solid #ffd166;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert a {
|
[data-theme="dark"] .alert {
|
||||||
color: #1890ff;
|
background: #3b2f0b;
|
||||||
font-weight: 500;
|
border-bottom: 1px solid #665c22;
|
||||||
margin-left: 6px;
|
}
|
||||||
}
|
|
||||||
|
.alert a {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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('_', ' '))}"
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ?? {})
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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",
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user