From ba3930ab02913c3d9368efe9b084d0788c3bdf6b Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 22 Apr 2024 09:20:23 -0500
Subject: [PATCH] Add status bar provider (#11066)
---
web/package-lock.json | 11 ++-
web/package.json | 2 +
web/src/components/Statusbar.tsx | 44 ++++++++--
web/src/components/navigation/Bottombar.tsx | 37 +++++++--
web/src/components/settings/MasksAndZones.tsx | 15 +++-
web/src/components/settings/MotionTuner.tsx | 16 +++-
web/src/context/providers.tsx | 3 +-
web/src/context/statusbar-provider.tsx | 83 +++++++++++++++++++
web/src/hooks/use-deep-memo.ts | 12 +++
web/src/hooks/use-stats.ts | 30 ++++---
web/src/utils/stringUtil.ts | 3 +
11 files changed, 218 insertions(+), 38 deletions(-)
create mode 100644 web/src/context/statusbar-provider.tsx
create mode 100644 web/src/hooks/use-deep-memo.ts
create mode 100644 web/src/utils/stringUtil.ts
diff --git a/web/package-lock.json b/web/package-lock.json
index d11e354ff..122c26570 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -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",
diff --git a/web/package.json b/web/package.json
index 9ffeace0a..3e8fc2907 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx
index 8599ab9b6..88d00d3b7 100644
--- a/web/src/components/Statusbar.tsx
+++ b/web/src/components/Statusbar.tsx
@@ -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 (
@@ -86,15 +102,25 @@ export default function Statusbar() {
})}
- {potentialProblems.map((prob) => (
-
-
- {prob.text}
+ {Object.entries(messages).length === 0 ? (
+
+
+ System is healthy
- ))}
+ ) : (
+ Object.entries(messages).map(([key, messageArray]) => (
+
+ {messageArray.map(({ id, text, color }: StatusMessage) => (
+
+
+ {text}
+
+ ))}
+
+ ))
+ )}
);
diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx
index e21556aaa..38086892e 100644
--- a/web/src/components/navigation/Bottombar.tsx
+++ b/web/src/components/navigation/Bottombar.tsx
@@ -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() {
- {potentialProblems.map((prob) => (
-
-
- {prob.text}
+ {Object.entries(messages).map(([key, messageArray]) => (
+
+ {messageArray.map(({ id, text, color }: StatusMessage) => (
+
+
+ {text}
+
+ ))}
))}
diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx
index 381b1d952..84999da8f 100644
--- a/web/src/components/settings/MasksAndZones.tsx
+++ b/web/src/components/settings/MasksAndZones.tsx
@@ -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
(null);
const [editPane, setEditPane] = useState(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) {
diff --git a/web/src/components/settings/MotionTuner.tsx b/web/src/components/settings/MotionTuner.tsx
index 584eab83e..7bcd73428 100644
--- a/web/src/components/settings/MotionTuner.tsx
+++ b/web/src/components/settings/MotionTuner.tsx
@@ -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 ;
diff --git a/web/src/context/providers.tsx b/web/src/context/providers.tsx
index b239aff47..fe5e931e7 100644
--- a/web/src/context/providers.tsx
+++ b/web/src/context/providers.tsx
@@ -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) {
- {children}
+ {children}
diff --git a/web/src/context/statusbar-provider.tsx b/web/src/context/statusbar-provider.tsx
new file mode 100644
index 000000000..6d17fa4de
--- /dev/null
+++ b/web/src/context/statusbar-provider.tsx
@@ -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(null);
+
+export function StatusBarMessagesProvider({
+ children,
+}: StatusBarMessagesProviderProps) {
+ const [messagesState, setMessagesState] = useState({});
+
+ 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 (
+
+ {children}
+
+ );
+}
diff --git a/web/src/hooks/use-deep-memo.ts b/web/src/hooks/use-deep-memo.ts
new file mode 100644
index 000000000..a950f5c7e
--- /dev/null
+++ b/web/src/hooks/use-deep-memo.ts
@@ -0,0 +1,12 @@
+import { useRef } from "react";
+import { isEqual } from "lodash";
+
+export default function useDeepMemo(value: T) {
+ const ref = useRef(undefined);
+
+ if (!isEqual(ref.current, value)) {
+ ref.current = value;
+ }
+
+ return ref.current;
+}
diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts
index 02db404f1..1cb30dbb7 100644
--- a/web/src/hooks/use-stats.ts
+++ b/web/src/hooks/use-stats.ts
@@ -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("config");
+ const memoizedStats = useDeepMemo(stats);
+
const potentialProblems = useMemo(() => {
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 };
}
diff --git a/web/src/utils/stringUtil.ts b/web/src/utils/stringUtil.ts
new file mode 100644
index 000000000..34556ddb3
--- /dev/null
+++ b/web/src/utils/stringUtil.ts
@@ -0,0 +1,3 @@
+export const capitalizeFirstLetter = (text: string): string => {
+ return text.charAt(0).toUpperCase() + text.slice(1);
+};