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 = make_response(jpg_bytes)
response.headers["Content-Type"] = "image/jpeg" response.headers["Content-Type"] = "image/webp"
response.headers["Cache-Control"] = "private, max-age=31536000" response.headers["Cache-Control"] = "private, max-age=31536000"
return response return response

View File

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

View File

@ -79,6 +79,7 @@ INSERT_MANY_RECORDINGS = "insert_many_recordings"
INSERT_PREVIEW = "insert_preview" INSERT_PREVIEW = "insert_preview"
REQUEST_REGION_GRID = "request_region_grid" REQUEST_REGION_GRID = "request_region_grid"
UPSERT_REVIEW_SEGMENT = "upsert_review_segment" UPSERT_REVIEW_SEGMENT = "upsert_review_segment"
CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments"
# Autotracking # 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.detections_updater import DetectionSubscriber, DetectionTypeEnum
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import CameraConfig, FrigateConfig 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.events.external import ManualEventState
from frigate.models import ReviewSegment from frigate.models import ReviewSegment
from frigate.object_processing import TrackedObject from frigate.object_processing import TrackedObject
@ -146,25 +151,64 @@ class ReviewSegmentMaintainer(threading.Thread):
self.stop_event = stop_event self.stop_event = stop_event
def update_segment(self, segment: PendingReviewSegment) -> None: # clear ongoing review segments from last instance
"""Update segment.""" self.requestor.send_data(CLEAR_ONGOING_REVIEW_SEGMENTS, "")
seg_data = segment.get_data(ended=False)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data) 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( self.requestor.send_data(
"reviews", "reviews",
json.dumps( 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: def end_segment(self, segment: PendingReviewSegment) -> None:
"""End segment.""" """End segment."""
seg_data = segment.get_data(ended=True) final_data = segment.get_data(ended=True)
self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data) 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( self.requestor.send_data(
"reviews", "reviews",
json.dumps( 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 self.active_review_segments[segment.camera] = None
@ -219,9 +263,10 @@ class ReviewSegmentMaintainer(threading.Thread):
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_id, camera_config.frame_shape_yuv 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.frame_manager.close(frame_id)
self.update_segment(segment)
except FileNotFoundError: except FileNotFoundError:
return return
else: else:
@ -317,7 +362,7 @@ class ReviewSegmentMaintainer(threading.Thread):
camera_config, yuv_frame, active_objects camera_config, yuv_frame, active_objects
) )
self.frame_manager.close(frame_id) self.frame_manager.close(frame_id)
self.update_segment(self.active_review_segments[camera]) self.new_segment(self.active_review_segments[camera])
except FileNotFoundError: except FileNotFoundError:
return return

View File

@ -413,9 +413,7 @@ def track_camera(
object_filters = config.objects.filters object_filters = config.objects.filters
motion_detector = ImprovedMotionDetector( motion_detector = ImprovedMotionDetector(
frame_shape, frame_shape, config.motion, config.detect.fps, name=config.name
config.motion,
config.detect.fps,
) )
object_detector = RemoteObjectDetector( object_detector = RemoteObjectDetector(
name, labelmap, detection_queue, result_connection, model_config, stop_event 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", "idb-keyval": "^6.2.1",
"immer": "^10.0.4", "immer": "^10.0.4",
"konva": "^9.3.6", "konva": "^9.3.6",
"lodash": "^4.17.21",
"lucide-react": "^0.372.0", "lucide-react": "^0.372.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@ -71,6 +72,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/react": "^18.2.79", "@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25", "@types/react-dom": "^18.2.25",
@ -2523,6 +2525,12 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" "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": { "node_modules/@types/mute-stream": {
"version": "0.0.4", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz",
@ -5299,8 +5307,7 @@
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"dev": true
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -104,7 +104,7 @@ export default function ExportCard({
</Dialog> </Dialog>
<div <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={ onMouseEnter={
isDesktop && !exportedRecording.in_progress isDesktop && !exportedRecording.in_progress
? () => setHovered(true) ? () => setHovered(true)
@ -123,7 +123,7 @@ export default function ExportCard({
> >
{hovered && ( {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"> <div className="absolute top-1 right-1 flex items-center gap-2">
<a <a
className="z-20" className="z-20"
@ -172,19 +172,19 @@ export default function ExportCard({
<> <>
{exportedRecording.thumb_path.length > 0 ? ( {exportedRecording.thumb_path.length > 0 ? (
<img <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", "")} src={exportedRecording.thumb_path.replace("/media/frigate", "")}
onLoad={() => setLoading(false)} 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 && ( {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"> <div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm capitalize">
{exportedRecording.name.replaceAll("_", " ")} {exportedRecording.name.replaceAll("_", " ")}
</div> </div>

View File

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

View File

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

View File

@ -130,7 +130,9 @@ export default function ExportDialog({
</Trigger> </Trigger>
<Content <Content
className={ 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 <ExportContent

View File

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

View File

@ -164,7 +164,7 @@ export default function HlsVideoPlayer({
> >
<video <video
ref={videoRef} 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" preload="auto"
autoPlay autoPlay
controls={false} controls={false}

View File

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

View File

@ -238,7 +238,7 @@ function PreviewVideoPlayer({
return ( return (
<div <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} onClick={onClick}
> >
<img <img
@ -283,7 +283,7 @@ function PreviewVideoPlayer({
)} )}
</video> </video>
{cameraPreviews && !currentPreview && ( {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 No Preview Found
</div> </div>
)} )}
@ -481,11 +481,11 @@ function PreviewFramesPlayer({
> >
<img <img
ref={imgRef} 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} onLoad={onImageLoaded}
/> />
{previewFrames?.length === 0 && ( {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 No Preview Found
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { RecoilRoot } from "recoil";
import { ApiProvider } from "@/api"; import { ApiProvider } from "@/api";
import { IconContext } from "react-icons"; import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
type TProvidersProps = { type TProvidersProps = {
children: ReactNode; children: ReactNode;
@ -16,7 +17,7 @@ function providers({ children }: TProvidersProps) {
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme"> <ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<TooltipProvider> <TooltipProvider>
<IconContext.Provider value={{ size: "20" }}> <IconContext.Provider value={{ size: "20" }}>
{children} <StatusBarMessagesProvider>{children}</StatusBarMessagesProvider>
</IconContext.Provider> </IconContext.Provider>
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </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 { FrigateStats, PotentialProblem } from "@/types/stats";
import { useMemo } from "react"; import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import useDeepMemo from "./use-deep-memo";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
export default function useStats(stats: FrigateStats | undefined) { export default function useStats(stats: FrigateStats | undefined) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const memoizedStats = useDeepMemo(stats);
const potentialProblems = useMemo<PotentialProblem[]>(() => { const potentialProblems = useMemo<PotentialProblem[]>(() => {
const problems: PotentialProblem[] = []; const problems: PotentialProblem[] = [];
if (!stats) { if (!memoizedStats) {
return problems; return problems;
} }
// if frigate has just started // if frigate has just started
// don't look for issues // don't look for issues
if (stats.service.uptime < 120) { if (memoizedStats.service.uptime < 120) {
return problems; return problems;
} }
// check detectors for high inference speeds // 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) { if (det["inference_speed"] > InferenceThreshold.error) {
problems.push({ problems.push({
text: `${key} is very slow (${det["inference_speed"]} ms)`, text: `${capitalizeFirstLetter(key)} is very slow (${det["inference_speed"]} ms)`,
color: "text-danger", color: "text-danger",
}); });
} else if (det["inference_speed"] > InferenceThreshold.warning) { } else if (det["inference_speed"] > InferenceThreshold.warning) {
problems.push({ problems.push({
text: `${key} is slow (${det["inference_speed"]} ms)`, text: `${capitalizeFirstLetter(key)} is slow (${det["inference_speed"]} ms)`,
color: "text-orange-400", color: "text-orange-400",
}); });
} }
}); });
// check for offline cameras // check for offline cameras
Object.entries(stats["cameras"]).forEach(([name, cam]) => { Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
if (!config) { if (!config) {
return; return;
} }
if (config.cameras[name].enabled && cam["camera_fps"] == 0) { if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
problems.push({ problems.push({
text: `${name.replaceAll("_", " ")} is offline`, text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} is offline`,
color: "text-danger", color: "text-danger",
}); });
} }
}); });
// check camera cpu usages // check camera cpu usages
Object.entries(stats["cameras"]).forEach(([name, cam]) => { Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
const ffmpegAvg = parseFloat( const ffmpegAvg = parseFloat(
stats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average, memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
); );
const detectAvg = parseFloat( const detectAvg = parseFloat(
stats["cpu_usages"][cam["pid"]]?.cpu_average, memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average,
); );
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) { if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({ problems.push({
text: `${name.replaceAll("_", " ")} has high FFMPEG CPU usage (${ffmpegAvg}%)`, text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`,
color: "text-danger", color: "text-danger",
}); });
} }
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) { if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push({ problems.push({
text: `${name.replaceAll("_", " ")} has high detect CPU usage (${detectAvg}%)`, text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`,
color: "text-danger", color: "text-danger",
}); });
} }
}); });
return problems; return problems;
}, [config, stats]); }, [config, memoizedStats]);
return { potentialProblems }; return { potentialProblems };
} }

View File

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

View File

@ -182,11 +182,11 @@ export default function SubmitPlus() {
return ( return (
<div <div
key={event.id} 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)} onClick={() => setUpload(event)}
> >
<img <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`} src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
loading="lazy" loading="lazy"
/> />

View File

@ -31,7 +31,8 @@ type FrigateObjectState = {
export interface FrigateReview { export interface FrigateReview {
type: "new" | "update" | "end"; type: "new" | "update" | "end";
review: ReviewSegment; before: ReviewSegment;
after: ReviewSegment;
} }
export interface FrigateEvent { 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> </div>
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem <ToggleGroupItem
className={`px-3 py-4 rounded-2xl ${ className={`px-3 py-4 rounded-lg ${
severityToggle == "significant_motion" severityToggle == "significant_motion"
? "" ? ""
: "text-muted-foreground" : "text-muted-foreground"
@ -589,7 +589,8 @@ function DetectionReview({
</div> </div>
); );
}) })
: Array(itemsToReview) : (itemsToReview ?? 0) > 0 &&
Array(itemsToReview)
.fill(0) .fill(0)
.map((_, idx) => ( .map((_, idx) => (
<Skeleton key={idx} className="size-full aspect-video" /> <Skeleton key={idx} className="size-full aspect-video" />
@ -890,7 +891,7 @@ function MotionReview({
{motionData ? ( {motionData ? (
<> <>
<PreviewPlayer <PreviewPlayer
className={`rounded-2xl ${spans} ${grow}`} className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
camera={camera.name} camera={camera.name}
timeRange={currentTimeRange} timeRange={currentTimeRange}
startTime={previewStart} startTime={previewStart}
@ -916,7 +917,7 @@ function MotionReview({
</> </>
) : ( ) : (
<Skeleton <Skeleton
className={`rounded-2xl size-full ${spans} ${grow}`} className={`rounded-lg md:rounded-2xl size-full ${spans} ${grow}`}
/> />
)} )}
</div> </div>

View File

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

View File

@ -66,6 +66,7 @@ export default function CameraMetrics({
[key: string]: { name: string; data: { x: number; y: number }[] }; [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_dps"] = { name: "overall detections per second", data: [] };
series["overall_skipped_dps"] = { series["overall_skipped_dps"] = {
name: "overall skipped detections per second", name: "overall skipped detections per second",
@ -77,6 +78,16 @@ export default function CameraMetrics({
return; 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({ series["overall_dps"].data.push({
x: statsIdx, x: statsIdx,
y: stats.detection_fps, y: stats.detection_fps,
@ -161,6 +172,10 @@ export default function CameraMetrics({
if (!(key in series)) { if (!(key in series)) {
const camName = key.replaceAll("_", " "); const camName = key.replaceAll("_", " ");
series[key] = {}; series[key] = {};
series[key]["fps"] = {
name: `${camName} frames per second`,
data: [],
};
series[key]["det"] = { series[key]["det"] = {
name: `${camName} detections per second`, name: `${camName} detections per second`,
data: [], 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({ series[key]["det"].data.push({
x: statsIdx, x: statsIdx,
y: camStats.detection_fps, 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="text-muted-foreground text-sm font-medium">Overview</div>
<div className="grid grid-cols-1 md:grid-cols-3"> <div className="grid grid-cols-1 md:grid-cols-3">
{statsHistory.length != 0 ? ( {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">DPS</div> <div className="mb-5">Frames / Detections</div>
<CameraLineGraph <CameraLineGraph
graphId="overall-stats" graphId="overall-stats"
unit=" DPS" unit=""
dataLabels={["detect", "skipped"]} dataLabels={["camera", "detect", "skipped"]}
updateTimes={updateTimes} updateTimes={updateTimes}
data={overallFpsSeries} data={overallFpsSeries}
/> />
</div> </div>
) : ( ) : (
<Skeleton className="w-full h-32" /> <Skeleton className="w-full rounded-lg md:rounded-2xl h-32" />
)} )}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -214,7 +233,7 @@ export default function CameraMetrics({
</div> </div>
<div key={camera.name} className="grid sm:grid-cols-2 gap-2"> <div key={camera.name} className="grid sm:grid-cols-2 gap-2">
{Object.keys(cameraCpuSeries).includes(camera.name) ? ( {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> <div className="mb-5">CPU</div>
<CameraLineGraph <CameraLineGraph
graphId={`${camera.name}-cpu`} graphId={`${camera.name}-cpu`}
@ -230,12 +249,12 @@ export default function CameraMetrics({
<Skeleton className="size-full aspect-video" /> <Skeleton className="size-full aspect-video" />
)} )}
{Object.keys(cameraFpsSeries).includes(camera.name) ? ( {Object.keys(cameraFpsSeries).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">DPS</div> <div className="mb-5">Frames / Detections</div>
<CameraLineGraph <CameraLineGraph
graphId={`${camera.name}-dps`} graphId={`${camera.name}-dps`}
unit=" DPS" unit=""
dataLabels={["detect", "skipped"]} dataLabels={["camera", "detect", "skipped"]}
updateTimes={updateTimes} updateTimes={updateTimes}
data={Object.values( data={Object.values(
cameraFpsSeries[camera.name] || {}, 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"}`} className={`w-full mt-4 grid grid-cols-1 gap-2 ${detTempSeries == undefined ? "sm:grid-cols-3" : "sm:grid-cols-4"}`}
> >
{statsHistory.length != 0 ? ( {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> <div className="mb-5">Detector Inference Speed</div>
{detInferenceTimeSeries.map((series) => ( {detInferenceTimeSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -349,12 +349,12 @@ export default function GeneralMetrics({
))} ))}
</div> </div>
) : ( ) : (
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full rounded-lg md:rounded-2xl aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<> <>
{detTempSeries && ( {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> <div className="mb-5">Detector Temperature</div>
{detTempSeries.map((series) => ( {detTempSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -374,7 +374,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {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> <div className="mb-5">Detector CPU Usage</div>
{detCpuSeries.map((series) => ( {detCpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -392,7 +392,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {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> <div className="mb-5">Detector Memory Usage</div>
{detMemSeries.map((series) => ( {detMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -429,7 +429,7 @@ export default function GeneralMetrics({
</div> </div>
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? ( {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> <div className="mb-5">GPU Usage</div>
{gpuSeries.map((series) => ( {gpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -449,7 +449,7 @@ export default function GeneralMetrics({
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<> <>
{gpuMemSeries && ( {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> <div className="mb-5">GPU Memory</div>
{gpuMemSeries.map((series) => ( {gpuMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -477,7 +477,7 @@ export default function GeneralMetrics({
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? ( {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> <div className="mb-5">Process CPU Usage</div>
{otherProcessCpuSeries.map((series) => ( {otherProcessCpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -495,7 +495,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-tall" /> <Skeleton className="w-full aspect-tall" />
)} )}
{statsHistory.length != 0 ? ( {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> <div className="mb-5">Process Memory Usage</div>
{otherProcessMemSeries.map((series) => ( {otherProcessMemSeries.map((series) => (
<ThresholdBarGraph <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="size-full mt-4 flex flex-col overflow-y-auto">
<div className="text-muted-foreground text-sm font-medium">Overview</div> <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="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> <div className="mb-5">Recordings</div>
<StorageGraph <StorageGraph
graphId="general-recordings" graphId="general-recordings"
@ -53,7 +53,7 @@ export default function StorageMetrics({
total={totalStorage.total} total={totalStorage.total}
/> />
</div> </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> <div className="mb-5">/tmp/cache</div>
<StorageGraph <StorageGraph
graphId="general-cache" graphId="general-cache"
@ -61,7 +61,7 @@ export default function StorageMetrics({
total={stats.service.storage["/tmp/cache"]["total"]} total={stats.service.storage["/tmp/cache"]["total"]}
/> />
</div> </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> <div className="mb-5">/dev/shm</div>
<StorageGraph <StorageGraph
graphId="general-shared-memory" graphId="general-shared-memory"
@ -75,7 +75,7 @@ export default function StorageMetrics({
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{Object.keys(cameraStorage).map((camera) => ( {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> <div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div>
<StorageGraph <StorageGraph
graphId={`${camera}-storage`} graphId={`${camera}-storage`}