Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
Remon Nashid 2024-04-24 11:19:09 -06:00 committed by GitHub
commit 966025e00b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 399 additions and 120 deletions

View File

@ -1351,6 +1351,6 @@ def preview_thumbnail(file_name: str):
)
response = make_response(jpg_bytes)
response.headers["Content-Type"] = "image/jpeg"
response.headers["Content-Type"] = "image/webp"
response.headers["Cache-Control"] = "private, max-age=31536000"
return response

View File

@ -1,5 +1,6 @@
"""Handle communication between Frigate and other applications."""
import datetime
import logging
from abc import ABC, abstractmethod
from typing import Any, Callable, Optional
@ -7,6 +8,7 @@ from typing import Any, Callable, Optional
from frigate.comms.config_updater import ConfigPublisher
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import (
CLEAR_ONGOING_REVIEW_SEGMENTS,
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
REQUEST_REGION_GRID,
@ -116,6 +118,10 @@ class Dispatcher:
)
.execute()
)
elif topic == CLEAR_ONGOING_REVIEW_SEGMENTS:
ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where(
ReviewSegment.end_time == None
).execute()
else:
self.publish(topic, payload, retain=False)

View File

@ -79,6 +79,7 @@ INSERT_MANY_RECORDINGS = "insert_many_recordings"
INSERT_PREVIEW = "insert_preview"
REQUEST_REGION_GRID = "request_region_grid"
UPSERT_REVIEW_SEGMENT = "upsert_review_segment"
CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
# Autotracking

View File

@ -19,7 +19,12 @@ from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import CameraConfig, FrigateConfig
from frigate.const import ALL_ATTRIBUTE_LABELS, CLIPS_DIR, UPSERT_REVIEW_SEGMENT
from frigate.const import (
ALL_ATTRIBUTE_LABELS,
CLEAR_ONGOING_REVIEW_SEGMENTS,
CLIPS_DIR,
UPSERT_REVIEW_SEGMENT,
)
from frigate.events.external import ManualEventState
from frigate.models import ReviewSegment
from frigate.object_processing import TrackedObject
@ -146,25 +151,64 @@ class ReviewSegmentMaintainer(threading.Thread):
self.stop_event = stop_event
def update_segment(self, segment: PendingReviewSegment) -> None:
"""Update segment."""
seg_data = segment.get_data(ended=False)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
# clear ongoing review segments from last instance
self.requestor.send_data(CLEAR_ONGOING_REVIEW_SEGMENTS, "")
def new_segment(
self,
segment: PendingReviewSegment,
) -> None:
"""New segment."""
new_data = segment.get_data(ended=False)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, new_data)
start_data = {k.name: v for k, v in new_data.items()}
self.requestor.send_data(
"reviews",
json.dumps(
{"type": "update", "review": {k.name: v for k, v in seg_data.items()}}
{
"type": "new",
"before": start_data,
"after": start_data,
}
),
)
def update_segment(
self,
segment: PendingReviewSegment,
camera_config: CameraConfig,
frame,
objects: list[TrackedObject],
) -> None:
"""Update segment."""
prev_data = segment.get_data(ended=False)
segment.update_frame(camera_config, frame, objects)
new_data = segment.get_data(ended=False)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, new_data)
self.requestor.send_data(
"reviews",
json.dumps(
{
"type": "update",
"before": {k.name: v for k, v in prev_data.items()},
"after": {k.name: v for k, v in new_data.items()},
}
),
)
def end_segment(self, segment: PendingReviewSegment) -> None:
"""End segment."""
seg_data = segment.get_data(ended=True)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data)
final_data = segment.get_data(ended=True)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, final_data)
end_data = {k.name: v for k, v in final_data.items()}
self.requestor.send_data(
"reviews",
json.dumps(
{"type": "end", "review": {k.name: v for k, v in seg_data.items()}}
{
"type": "end",
"before": end_data,
"after": end_data,
}
),
)
self.active_review_segments[segment.camera] = None
@ -219,9 +263,10 @@ class ReviewSegmentMaintainer(threading.Thread):
yuv_frame = self.frame_manager.get(
frame_id, camera_config.frame_shape_yuv
)
segment.update_frame(camera_config, yuv_frame, active_objects)
self.update_segment(
segment, camera_config, yuv_frame, active_objects
)
self.frame_manager.close(frame_id)
self.update_segment(segment)
except FileNotFoundError:
return
else:
@ -317,7 +362,7 @@ class ReviewSegmentMaintainer(threading.Thread):
camera_config, yuv_frame, active_objects
)
self.frame_manager.close(frame_id)
self.update_segment(self.active_review_segments[camera])
self.new_segment(self.active_review_segments[camera])
except FileNotFoundError:
return

View File

@ -413,9 +413,7 @@ def track_camera(
object_filters = config.objects.filters
motion_detector = ImprovedMotionDetector(
frame_shape,
config.motion,
config.detect.fps,
frame_shape, config.motion, config.detect.fps, name=config.name
)
object_detector = RemoteObjectDetector(
name, labelmap, detection_queue, result_connection, model_config, stop_event

11
web/package-lock.json generated
View File

@ -39,6 +39,7 @@
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"konva": "^9.3.6",
"lodash": "^4.17.21",
"lucide-react": "^0.372.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
@ -71,6 +72,7 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.7",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
@ -2523,6 +2525,12 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"node_modules/@types/lodash": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==",
"dev": true
},
"node_modules/@types/mute-stream": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz",
@ -5299,8 +5307,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",

View File

@ -44,6 +44,7 @@
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
"konva": "^9.3.6",
"lodash": "^4.17.21",
"lucide-react": "^0.372.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
@ -76,6 +77,7 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.7",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",

View File

@ -1,7 +1,12 @@
import { useFrigateStats } from "@/api/ws";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-provider";
import useStats from "@/hooks/use-stats";
import { FrigateStats } from "@/types/stats";
import { useMemo } from "react";
import { useContext, useEffect, useMemo } from "react";
import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
@ -11,6 +16,10 @@ export default function Statusbar() {
revalidateOnFocus: false,
});
const { payload: latestStats } = useFrigateStats();
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
const stats = useMemo(() => {
if (latestStats) {
return latestStats;
@ -31,6 +40,13 @@ export default function Statusbar() {
const { potentialProblems } = useStats(stats);
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage("stats", problem.text, problem.color);
});
}, [potentialProblems, addMessage, clearMessages]);
return (
<div className="absolute left-0 bottom-0 right-0 w-full h-8 flex justify-between items-center px-4 bg-background_alt z-10 dark:text-secondary-foreground border-t border-secondary-highlight">
<div className="h-full flex items-center gap-2">
@ -86,15 +102,25 @@ export default function Statusbar() {
})}
</div>
<div className="h-full flex items-center gap-2">
{potentialProblems.map((prob) => (
<div
key={prob.text}
className="flex items-center text-sm gap-2 capitalize"
>
<IoIosWarning className={`size-5 ${prob.color}`} />
{prob.text}
{Object.entries(messages).length === 0 ? (
<div className="flex items-center text-sm gap-2">
<FaCheck className="size-3 text-green-500" />
System is healthy
</div>
))}
) : (
Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="h-full flex items-center gap-2">
{messageArray.map(({ id, text, color }: StatusMessage) => (
<div key={id} className="flex items-center text-sm gap-2">
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
))}
</div>
))
)}
</div>
</div>
);

View File

@ -40,7 +40,7 @@ export default function CameraImage({
{enabled ? (
<img
ref={imgRef}
className="object-contain rounded-2xl"
className="object-contain rounded-lg md:rounded-2xl"
onLoad={() => {
setHasLoaded(true);

View File

@ -100,7 +100,7 @@ export default function CameraImage({
>
{enabled ? (
<canvas
className="rounded-2xl"
className="rounded-lg md:rounded-2xl"
data-testid="cameraimage-canvas"
height={scaledHeight}
ref={canvasRef}

View File

@ -12,6 +12,7 @@ import {
InProgressPreview,
VideoPreview,
} from "../player/PreviewThumbnailPlayer";
import { isCurrentHour } from "@/utils/dateUtil";
type AnimatedEventCardProps = {
event: ReviewSegment;
@ -19,10 +20,14 @@ type AnimatedEventCardProps = {
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const currentHour = useMemo(() => isCurrentHour(event.start_time), [event]);
// preview
const { data: previews } = useSWR<Preview[]>(
`/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`,
currentHour
? null
: `/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`,
);
// interaction
@ -63,7 +68,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
}}
>
<div
className="size-full rounded cursor-pointer overflow-hidden"
className="size-full rounded md:rounded-lg cursor-pointer overflow-hidden"
onClick={onOpenReview}
>
{previews ? (

View File

@ -104,7 +104,7 @@ export default function ExportCard({
</Dialog>
<div
className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`}
className={`relative aspect-video bg-black rounded-lg md:rounded-2xl flex justify-center items-center ${className}`}
onMouseEnter={
isDesktop && !exportedRecording.in_progress
? () => setHovered(true)
@ -123,7 +123,7 @@ export default function ExportCard({
>
{hovered && (
<>
<div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" />
<div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-lg md:rounded-2xl" />
<div className="absolute top-1 right-1 flex items-center gap-2">
<a
className="z-20"
@ -172,19 +172,19 @@ export default function ExportCard({
<>
{exportedRecording.thumb_path.length > 0 ? (
<img
className="size-full absolute inset-0 object-contain aspect-video rounded-2xl"
className="size-full absolute inset-0 object-contain aspect-video rounded-lg md:rounded-2xl"
src={exportedRecording.thumb_path.replace("/media/frigate", "")}
onLoad={() => setLoading(false)}
/>
) : (
<div className="absolute inset-0 bg-secondary rounded-2xl" />
<div className="absolute inset-0 bg-secondary rounded-lg md:rounded-2xl" />
)}
</>
)}
{loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-2xl" />
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
)}
<div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none rounded-2xl">
<div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none rounded-lg md:rounded-2xl">
<div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>

View File

@ -210,7 +210,9 @@ export default function GeneralSettings({ className }: GeneralSettings) {
</SubItemTrigger>
<Portal>
<SubItemContent
className={isDesktop ? "" : "w-[92%] rounded-2xl"}
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem
@ -280,7 +282,9 @@ export default function GeneralSettings({ className }: GeneralSettings) {
</SubItemTrigger>
<Portal>
<SubItemContent
className={isDesktop ? "" : "w-[92%] rounded-2xl"}
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => (

View File

@ -4,11 +4,15 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import useSWR from "swr";
import { FrigateStats } from "@/types/stats";
import { useFrigateStats } from "@/api/ws";
import { useMemo } from "react";
import { useContext, useEffect, useMemo } from "react";
import useStats from "@/hooks/use-stats";
import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-provider";
function Bottombar() {
const navItems = useNavigation("secondary");
@ -30,6 +34,11 @@ function StatusAlertNav() {
revalidateOnFocus: false,
});
const { payload: latestStats } = useFrigateStats();
const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext,
)!;
const stats = useMemo(() => {
if (latestStats) {
return latestStats;
@ -39,7 +48,14 @@ function StatusAlertNav() {
}, [initialStats, latestStats]);
const { potentialProblems } = useStats(stats);
if (!potentialProblems || potentialProblems.length == 0) {
useEffect(() => {
clearMessages("stats");
potentialProblems.forEach((problem) => {
addMessage("stats", problem.text, problem.color);
});
}, [potentialProblems, addMessage, clearMessages]);
if (!messages || Object.keys(messages).length === 0) {
return;
}
@ -50,13 +66,16 @@ function StatusAlertNav() {
</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] px-2 mx-1 rounded-t-2xl overflow-hidden">
<div className="w-full h-auto py-4 overflow-y-auto overflow-x-hidden flex flex-col items-center gap-2">
{potentialProblems.map((prob) => (
<div
key={prob.text}
className="w-full flex items-center text-xs gap-2 capitalize"
>
<IoIosWarning className={`size-5 ${prob.color}`} />
{prob.text}
{Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="w-full flex items-center gap-2">
{messageArray.map(({ id, text, color }: StatusMessage) => (
<div key={id} className="flex items-center text-xs gap-2">
<IoIosWarning
className={`size-5 ${color || "text-danger"}`}
/>
{text}
</div>
))}
</div>
))}
</div>

View File

@ -130,7 +130,9 @@ export default function ExportDialog({
</Trigger>
<Content
className={
isDesktop ? "sm:rounded-2xl" : "px-4 pb-4 mx-4 rounded-2xl"
isDesktop
? "sm:rounded-lg md:rounded-2xl"
: "px-4 pb-4 mx-4 rounded-lg md:rounded-2xl"
}
>
<ExportContent

View File

@ -21,12 +21,18 @@ export default function BirdseyeLivePlayer({
let player;
if (liveMode == "webrtc") {
player = (
<WebRtcPlayer className={`rounded-2xl size-full`} camera="birdseye" />
<WebRtcPlayer
className={`rounded-lg md:rounded-2xl size-full`}
camera="birdseye"
/>
);
} else if (liveMode == "mse") {
if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = (
<MSEPlayer className={`rounded-2xl size-full`} camera="birdseye" />
<MSEPlayer
className={`rounded-lg md:rounded-2xl size-full`}
camera="birdseye"
/>
);
} else {
player = (
@ -39,7 +45,7 @@ export default function BirdseyeLivePlayer({
} else if (liveMode == "jsmpeg") {
player = (
<JSMpegPlayer
className="size-full flex justify-center rounded-2xl overflow-hidden"
className="size-full flex justify-center rounded-lg md:rounded-2xl overflow-hidden"
camera="birdseye"
width={birdseyeConfig.width}
height={birdseyeConfig.height}
@ -54,8 +60,8 @@ export default function BirdseyeLivePlayer({
className={`relative flex justify-center w-full cursor-pointer ${className ?? ""}`}
onClick={onClick}
>
<div className="absolute top-0 inset-x-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 inset-x-0 rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
<div className="absolute top-0 inset-x-0 rounded-lg md:rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 inset-x-0 rounded-lg md:rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
<div className="size-full">{player}</div>
</div>
);

View File

@ -164,7 +164,7 @@ export default function HlsVideoPlayer({
>
<video
ref={videoRef}
className={`size-full bg-black rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
className={`size-full bg-black rounded-lg md:rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
preload="auto"
autoPlay
controls={false}

View File

@ -95,7 +95,7 @@ export default function LivePlayer({
if (liveMode == "webrtc") {
player = (
<WebRtcPlayer
className={`rounded-2xl size-full ${liveReady ? "" : "hidden"}`}
className={`rounded-lg md:rounded-2xl size-full ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.live.stream_name}
playbackEnabled={cameraActive}
audioEnabled={playAudio}
@ -109,7 +109,7 @@ export default function LivePlayer({
if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = (
<MSEPlayer
className={`rounded-2xl size-full ${liveReady ? "" : "hidden"}`}
className={`rounded-lg md:rounded-2xl size-full ${liveReady ? "" : "hidden"}`}
camera={cameraConfig.name}
playbackEnabled={cameraActive}
audioEnabled={playAudio}
@ -128,7 +128,7 @@ export default function LivePlayer({
} else if (liveMode == "jsmpeg") {
player = (
<JSMpegPlayer
className="size-full flex justify-center rounded-2xl overflow-hidden"
className="size-full flex justify-center rounded-lg md:rounded-2xl overflow-hidden"
camera={cameraConfig.name}
width={cameraConfig.detect.width}
height={cameraConfig.detect.height}
@ -144,13 +144,13 @@ export default function LivePlayer({
data-camera={cameraConfig.name}
className={`relative flex justify-center ${liveMode == "jsmpeg" ? "size-full" : "w-full"} outline cursor-pointer ${
activeTracking
? "outline-severity_alert outline-3 rounded-2xl shadow-severity_alert"
? "outline-severity_alert outline-3 rounded-lg md:rounded-2xl shadow-severity_alert"
: "outline-0 outline-background"
} transition-all duration-500 ${className}`}
onClick={onClick}
>
<div className="absolute top-0 inset-x-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 inset-x-0 rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
<div className="absolute top-0 inset-x-0 rounded-lg md:rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>
<div className="absolute bottom-0 inset-x-0 rounded-lg md:rounded-2xl z-10 w-full h-[10%] bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
{player}
<div

View File

@ -238,7 +238,7 @@ function PreviewVideoPlayer({
return (
<div
className={`relative rounded-2xl w-full flex justify-center bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
className={`relative rounded-lg md:rounded-2xl w-full flex justify-center bg-black overflow-hidden ${onClick ? "cursor-pointer" : ""} ${className ?? ""}`}
onClick={onClick}
>
<img
@ -283,7 +283,7 @@ function PreviewVideoPlayer({
)}
</video>
{cameraPreviews && !currentPreview && (
<div className="absolute inset-0 text-white rounded-2xl flex justify-center items-center">
<div className="absolute inset-0 text-white rounded-lg md:rounded-2xl flex justify-center items-center">
No Preview Found
</div>
)}
@ -481,11 +481,11 @@ function PreviewFramesPlayer({
>
<img
ref={imgRef}
className={`size-full object-contain rounded-2xl bg-black`}
className={`size-full object-contain rounded-lg md:rounded-2xl bg-black`}
onLoad={onImageLoaded}
/>
{previewFrames?.length === 0 && (
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-2xl align-center text-center">
<div className="absolute inset-x-0 top-1/2 -y-translate-1/2 bg-black text-white rounded-lg md:rounded-2xl align-center text-center">
No Preview Found
</div>
)}

View File

@ -1,7 +1,14 @@
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { PolygonCanvas } from "./PolygonCanvas";
import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints, parseCoordinates } from "@/utils/canvasUtil";
@ -25,6 +32,7 @@ import ObjectMaskEditPane from "./ObjectMaskEditPane";
import PolygonItem from "./PolygonItem";
import { Link } from "react-router-dom";
import { isDesktop } from "react-device-detect";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
type MasksAndZoneProps = {
selectedCamera: string;
@ -50,6 +58,8 @@ export default function MasksAndZones({
const containerRef = useRef<HTMLDivElement | null>(null);
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
const { addMessage } = useContext(StatusBarMessagesContext)!;
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
@ -167,7 +177,8 @@ export default function MasksAndZones({
setAllPolygons([...(editingPolygons ?? [])]);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
}, [editingPolygons, setUnsavedChanges]);
addMessage("masks_zones", "Restart required (masks/zones changed)");
}, [editingPolygons, setUnsavedChanges, addMessage]);
useEffect(() => {
if (isLoading) {

View File

@ -4,7 +4,7 @@ import useSWR from "swr";
import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import {
@ -20,6 +20,7 @@ import { toast } from "sonner";
import { Separator } from "../ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
type MotionTunerProps = {
selectedCamera: string;
@ -41,6 +42,8 @@ export default function MotionTuner({
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { addMessage, clearMessages } = useContext(StatusBarMessagesContext)!;
const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera);
const { send: sendMotionContourArea } = useMotionContourArea(selectedCamera);
const { send: sendImproveContrast } = useImproveContrast(selectedCamera);
@ -145,7 +148,16 @@ export default function MotionTuner({
const onCancel = useCallback(() => {
setMotionSettings(origMotionSettings);
setChangedValue(false);
}, [origMotionSettings]);
clearMessages("motion_tuner");
}, [origMotionSettings, clearMessages]);
useEffect(() => {
if (changedValue) {
addMessage("motion_tuner", "Unsaved motion tuner changes");
} else {
clearMessages("motion_tuner");
}
}, [changedValue, addMessage, clearMessages]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
@ -296,7 +308,7 @@ export default function MotionTuner({
</div>
</div>
) : (
<Skeleton className="size-full rounded-2xl" />
<Skeleton className="size-full rounded-lg md:rounded-2xl" />
)}
</div>
);

View File

@ -247,7 +247,7 @@ export function EventSegment({
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
className="rounded-2xl w-[250px] p-2"
className="rounded-lg md:rounded-2xl w-[250px] p-2"
side="left"
>
<img

View File

@ -4,6 +4,7 @@ import { RecoilRoot } from "recoil";
import { ApiProvider } from "@/api";
import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
type TProvidersProps = {
children: ReactNode;
@ -16,7 +17,7 @@ function providers({ children }: TProvidersProps) {
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<TooltipProvider>
<IconContext.Provider value={{ size: "20" }}>
{children}
<StatusBarMessagesProvider>{children}</StatusBarMessagesProvider>
</IconContext.Provider>
</TooltipProvider>
</ThemeProvider>

View File

@ -0,0 +1,83 @@
import {
createContext,
useState,
ReactNode,
useCallback,
useMemo,
} from "react";
export type StatusMessage = {
id: string;
text: string;
color?: string;
};
export type StatusMessagesState = {
[key: string]: StatusMessage[];
};
type StatusBarMessagesProviderProps = {
children: ReactNode;
};
type StatusBarMessagesContextValue = {
messages: StatusMessagesState;
addMessage: (
key: string,
message: string,
color?: string,
messageId?: string,
) => string;
removeMessage: (key: string, messageId: string) => void;
clearMessages: (key: string) => void;
};
export const StatusBarMessagesContext =
createContext<StatusBarMessagesContextValue | null>(null);
export function StatusBarMessagesProvider({
children,
}: StatusBarMessagesProviderProps) {
const [messagesState, setMessagesState] = useState<StatusMessagesState>({});
const messages = useMemo(() => messagesState, [messagesState]);
const addMessage = useCallback(
(key: string, message: string, color?: string, messageId?: string) => {
const id = messageId || Date.now().toString();
const msgColor = color || "text-danger";
setMessagesState((prevMessages) => ({
...prevMessages,
[key]: [
...(prevMessages[key] || []),
{ id, text: message, color: msgColor },
],
}));
return id;
},
[],
);
const removeMessage = useCallback((key: string, messageId: string) => {
setMessagesState((prevMessages) => ({
...prevMessages,
[key]: prevMessages[key].filter((msg) => msg.id !== messageId),
}));
}, []);
const clearMessages = useCallback((key: string) => {
setMessagesState((prevMessages) => {
const updatedMessages = { ...prevMessages };
delete updatedMessages[key];
return updatedMessages;
});
}, []);
return (
<StatusBarMessagesContext.Provider
value={{ messages, addMessage, removeMessage, clearMessages }}
>
{children}
</StatusBarMessagesContext.Provider>
);
}

View File

@ -0,0 +1,12 @@
import { useRef } from "react";
import { isEqual } from "lodash";
export default function useDeepMemo<T>(value: T) {
const ref = useRef<T | undefined>(undefined);
if (!isEqual(ref.current, value)) {
ref.current = value;
}
return ref.current;
}

View File

@ -7,78 +7,82 @@ import {
import { FrigateStats, PotentialProblem } from "@/types/stats";
import { useMemo } from "react";
import useSWR from "swr";
import useDeepMemo from "./use-deep-memo";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
export default function useStats(stats: FrigateStats | undefined) {
const { data: config } = useSWR<FrigateConfig>("config");
const memoizedStats = useDeepMemo(stats);
const potentialProblems = useMemo<PotentialProblem[]>(() => {
const problems: PotentialProblem[] = [];
if (!stats) {
if (!memoizedStats) {
return problems;
}
// if frigate has just started
// don't look for issues
if (stats.service.uptime < 120) {
if (memoizedStats.service.uptime < 120) {
return problems;
}
// check detectors for high inference speeds
Object.entries(stats["detectors"]).forEach(([key, det]) => {
Object.entries(memoizedStats["detectors"]).forEach(([key, det]) => {
if (det["inference_speed"] > InferenceThreshold.error) {
problems.push({
text: `${key} is very slow (${det["inference_speed"]} ms)`,
text: `${capitalizeFirstLetter(key)} is very slow (${det["inference_speed"]} ms)`,
color: "text-danger",
});
} else if (det["inference_speed"] > InferenceThreshold.warning) {
problems.push({
text: `${key} is slow (${det["inference_speed"]} ms)`,
text: `${capitalizeFirstLetter(key)} is slow (${det["inference_speed"]} ms)`,
color: "text-orange-400",
});
}
});
// check for offline cameras
Object.entries(stats["cameras"]).forEach(([name, cam]) => {
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
if (!config) {
return;
}
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
problems.push({
text: `${name.replaceAll("_", " ")} is offline`,
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} is offline`,
color: "text-danger",
});
}
});
// check camera cpu usages
Object.entries(stats["cameras"]).forEach(([name, cam]) => {
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
const ffmpegAvg = parseFloat(
stats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
);
const detectAvg = parseFloat(
stats["cpu_usages"][cam["pid"]]?.cpu_average,
memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average,
);
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({
text: `${name.replaceAll("_", " ")} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
color: "text-danger",
});
}
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push({
text: `${name.replaceAll("_", " ")} has high detect CPU usage (${detectAvg}%)`,
text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
color: "text-danger",
});
}
});
return problems;
}, [config, stats]);
}, [config, memoizedStats]);
return { potentialProblems };
}

View File

@ -113,7 +113,7 @@ function Exports() {
<DialogContent className="max-w-7xl">
<DialogTitle>{selected?.name}</DialogTitle>
<video
className="size-full rounded-2xl"
className="size-full rounded-lg md:rounded-2xl"
playsInline
preload="auto"
autoPlay

View File

@ -182,11 +182,11 @@ export default function SubmitPlus() {
return (
<div
key={event.id}
className="w-full rounded-2xl aspect-video flex justify-center items-center bg-black cursor-pointer"
className="w-full rounded-lg md:rounded-2xl aspect-video flex justify-center items-center bg-black cursor-pointer"
onClick={() => setUpload(event)}
>
<img
className="aspect-video h-full object-contain rounded-2xl"
className="aspect-video h-full object-contain rounded-lg md:rounded-2xl"
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
loading="lazy"
/>

View File

@ -31,7 +31,8 @@ type FrigateObjectState = {
export interface FrigateReview {
type: "new" | "update" | "end";
review: ReviewSegment;
before: ReviewSegment;
after: ReviewSegment;
}
export interface FrigateEvent {

View File

@ -0,0 +1,3 @@
export const capitalizeFirstLetter = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1);
};

View File

@ -249,7 +249,7 @@ export default function EventView({
</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${
className={`px-3 py-4 rounded-lg ${
severityToggle == "significant_motion"
? ""
: "text-muted-foreground"
@ -589,7 +589,8 @@ function DetectionReview({
</div>
);
})
: Array(itemsToReview)
: (itemsToReview ?? 0) > 0 &&
Array(itemsToReview)
.fill(0)
.map((_, idx) => (
<Skeleton key={idx} className="size-full aspect-video" />
@ -890,7 +891,7 @@ function MotionReview({
{motionData ? (
<>
<PreviewPlayer
className={`rounded-2xl ${spans} ${grow}`}
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
camera={camera.name}
timeRange={currentTimeRange}
startTime={previewStart}
@ -916,7 +917,7 @@ function MotionReview({
</>
) : (
<Skeleton
className={`rounded-2xl size-full ${spans} ${grow}`}
className={`rounded-lg md:rounded-2xl size-full ${spans} ${grow}`}
/>
)}
</div>

View File

@ -47,8 +47,19 @@ export default function LiveDashboardView({
}
// if event is ended and was saved, update events list
if (eventUpdate.review.severity == "alert") {
setTimeout(() => updateEvents(), eventUpdate.type == "end" ? 1000 : 6000);
if (eventUpdate.after.severity == "alert") {
if (eventUpdate.type == "end" || eventUpdate.type == "new") {
setTimeout(
() => updateEvents(),
eventUpdate.type == "end" ? 1000 : 6000,
);
} else if (
eventUpdate.before.data.objects.length <
eventUpdate.after.data.objects.length
) {
setTimeout(() => updateEvents(), 5000);
}
return;
}
}, [eventUpdate, updateEvents]);
@ -175,7 +186,7 @@ export default function LiveDashboardView({
)}
<div
className={`mt-2 px-2 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4 *:rounded-2xl *:bg-black`}
className={`mt-2 px-2 grid ${layout == "grid" ? "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" : ""} gap-2 md:gap-4`}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayer
@ -198,7 +209,7 @@ export default function LiveDashboardView({
<LivePlayer
cameraRef={cameraRef}
key={camera.name}
className={grow}
className={`${grow} rounded-lg md:rounded-2xl bg-black`}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}

View File

@ -66,6 +66,7 @@ export default function CameraMetrics({
[key: string]: { name: string; data: { x: number; y: number }[] };
} = {};
series["overall_fps"] = { name: "overall frames per second", data: [] };
series["overall_dps"] = { name: "overall detections per second", data: [] };
series["overall_skipped_dps"] = {
name: "overall skipped detections per second",
@ -77,6 +78,16 @@ export default function CameraMetrics({
return;
}
let frames = 0;
Object.values(stats.cameras).forEach(
(camStat) => (frames += camStat.camera_fps),
);
series["overall_fps"].data.push({
x: statsIdx,
y: Math.round(frames),
});
series["overall_dps"].data.push({
x: statsIdx,
y: stats.detection_fps,
@ -161,6 +172,10 @@ export default function CameraMetrics({
if (!(key in series)) {
const camName = key.replaceAll("_", " ");
series[key] = {};
series[key]["fps"] = {
name: `${camName} frames per second`,
data: [],
};
series[key]["det"] = {
name: `${camName} detections per second`,
data: [],
@ -171,6 +186,10 @@ export default function CameraMetrics({
};
}
series[key]["fps"].data.push({
x: statsIdx,
y: camStats.camera_fps,
});
series[key]["det"].data.push({
x: statsIdx,
y: camStats.detection_fps,
@ -189,18 +208,18 @@ export default function CameraMetrics({
<div className="text-muted-foreground text-sm font-medium">Overview</div>
<div className="grid grid-cols-1 md:grid-cols-3">
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">DPS</div>
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Frames / Detections</div>
<CameraLineGraph
graphId="overall-stats"
unit=" DPS"
dataLabels={["detect", "skipped"]}
unit=""
dataLabels={["camera", "detect", "skipped"]}
updateTimes={updateTimes}
data={overallFpsSeries}
/>
</div>
) : (
<Skeleton className="w-full h-32" />
<Skeleton className="w-full rounded-lg md:rounded-2xl h-32" />
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -214,7 +233,7 @@ export default function CameraMetrics({
</div>
<div key={camera.name} className="grid sm:grid-cols-2 gap-2">
{Object.keys(cameraCpuSeries).includes(camera.name) ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">CPU</div>
<CameraLineGraph
graphId={`${camera.name}-cpu`}
@ -230,12 +249,12 @@ export default function CameraMetrics({
<Skeleton className="size-full aspect-video" />
)}
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="mb-5">DPS</div>
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Frames / Detections</div>
<CameraLineGraph
graphId={`${camera.name}-dps`}
unit=" DPS"
dataLabels={["detect", "skipped"]}
unit=""
dataLabels={["camera", "detect", "skipped"]}
updateTimes={updateTimes}
data={Object.values(
cameraFpsSeries[camera.name] || {},

View File

@ -334,7 +334,7 @@ export default function GeneralMetrics({
className={`w-full mt-4 grid grid-cols-1 gap-2 ${detTempSeries == undefined ? "sm:grid-cols-3" : "sm:grid-cols-4"}`}
>
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Detector Inference Speed</div>
{detInferenceTimeSeries.map((series) => (
<ThresholdBarGraph
@ -349,12 +349,12 @@ export default function GeneralMetrics({
))}
</div>
) : (
<Skeleton className="w-full aspect-video" />
<Skeleton className="w-full rounded-lg md:rounded-2xl aspect-video" />
)}
{statsHistory.length != 0 ? (
<>
{detTempSeries && (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Detector Temperature</div>
{detTempSeries.map((series) => (
<ThresholdBarGraph
@ -374,7 +374,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" />
)}
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Detector CPU Usage</div>
{detCpuSeries.map((series) => (
<ThresholdBarGraph
@ -392,7 +392,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" />
)}
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Detector Memory Usage</div>
{detMemSeries.map((series) => (
<ThresholdBarGraph
@ -429,7 +429,7 @@ export default function GeneralMetrics({
</div>
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">GPU Usage</div>
{gpuSeries.map((series) => (
<ThresholdBarGraph
@ -449,7 +449,7 @@ export default function GeneralMetrics({
{statsHistory.length != 0 ? (
<>
{gpuMemSeries && (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">GPU Memory</div>
{gpuMemSeries.map((series) => (
<ThresholdBarGraph
@ -477,7 +477,7 @@ export default function GeneralMetrics({
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Process CPU Usage</div>
{otherProcessCpuSeries.map((series) => (
<ThresholdBarGraph
@ -495,7 +495,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-tall" />
)}
{statsHistory.length != 0 ? (
<div className="p-2.5 bg-background_alt rounded-2xl">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl">
<div className="mb-5">Process Memory Usage</div>
{otherProcessMemSeries.map((series) => (
<ThresholdBarGraph

View File

@ -45,7 +45,7 @@ export default function StorageMetrics({
<div className="size-full mt-4 flex flex-col overflow-y-auto">
<div className="text-muted-foreground text-sm font-medium">Overview</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl flex-col">
<div className="mb-5">Recordings</div>
<StorageGraph
graphId="general-recordings"
@ -53,7 +53,7 @@ export default function StorageMetrics({
total={totalStorage.total}
/>
</div>
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl flex-col">
<div className="mb-5">/tmp/cache</div>
<StorageGraph
graphId="general-cache"
@ -61,7 +61,7 @@ export default function StorageMetrics({
total={stats.service.storage["/tmp/cache"]["total"]}
/>
</div>
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl flex-col">
<div className="mb-5">/dev/shm</div>
<StorageGraph
graphId="general-shared-memory"
@ -75,7 +75,7 @@ export default function StorageMetrics({
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{Object.keys(cameraStorage).map((camera) => (
<div className="p-2.5 bg-background_alt rounded-2xl flex-col">
<div className="p-2.5 bg-background_alt rounded-lg md:rounded-2xl flex-col">
<div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div>
<StorageGraph
graphId={`${camera}-storage`}