diff --git a/frigate/http.py b/frigate/http.py index 84a8c855e..f18e16d99 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -6,6 +6,7 @@ import logging import os import subprocess as sp import time +from typing import Any, Dict, List from functools import reduce from pathlib import Path from urllib.parse import unquote @@ -26,7 +27,7 @@ from flask import ( from peewee import SqliteDatabase, operator, fn, DoesNotExist from playhouse.shortcuts import model_to_dict -from frigate.const import CLIPS_DIR +from frigate.const import CLIPS_DIR, RECORD_DIR from frigate.models import Event, Recordings from frigate.stats import stats_snapshot from frigate.version import VERSION @@ -597,6 +598,52 @@ def stats(): return jsonify(stats) +@bp.route("/notifications") +def notifications(): + notifications: List[Dict[str, Any]] = [] + stats = stats_snapshot(current_app.stats_tracking) + + # check if update is available + if stats["service"]["version"] < stats["service"]["latest_version"]: + notifications.append( + { + "title": "New Update Available", + "desc": f"An update to version {stats['service']['latest_version']} is available.", + "type": "success", + "url": "https://github.com/blakeblackshear/frigate/releases", + } + ) + + # check if the recording dir is almost full + recording_dir = stats["service"]["storage"][RECORD_DIR] + + if (recording_dir["used"] / recording_dir["total"]) > 0.9: + notifications.append( + { + "title": "Recording Storage Almost Full", + "desc": "The storage for saving recordings is almost full, this may cause issues with cameras.", + "type": "warning", + "url": "/debug", + } + ) + + # check if cameras are not connected + for camera_name in current_app.frigate_config.cameras.keys(): + camera = stats.get(camera_name) + + if camera and camera["camera_fps"] == 0.0: + notifications.append( + { + "title": f"{camera_name.replace('_', ' ').title()} is not connected.", + "desc": "Check the logs for more info on the cameras connection.", + "type": "error", + "url": "/debug", + } + ) + + return jsonify(notifications) + + @bp.route("/") def mjpeg_feed(camera_name): fps = int(request.args.get("fps", "3")) diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 04acdd681..4360bcbb4 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -29,7 +29,6 @@ class TestHttp(unittest.TestCase): self.db = SqliteQueueDatabase(TEST_DB) models = [Event, Recordings] self.db.bind(models) - self.minimal_config = { "mqtt": {"host": "mqtt"}, "cameras": { @@ -293,6 +292,51 @@ class TestHttp(unittest.TestCase): stats = client.get("/stats").json assert stats == self.test_stats + @patch("frigate.http.stats_snapshot") + def test_no_notifications(self, mock_stats): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + + mock_stats.return_value = self.test_stats + + with app.test_client() as client: + notifications = client.get("/notifications").json + + assert notifications == [] + + @patch("frigate.http.stats_snapshot") + def test_all_notifications(self, mock_stats): + app = create_app( + FrigateConfig(**self.minimal_config), self.db, None, None, None + ) + + mock_stats.return_value = self.test_stats + + with app.test_client() as client: + notifications = client.get("/notifications").json + + assert notifications == [ + { + "title": "New Update Available", + "desc": "An update to version 0.11 is available.", + "type": "success", + "url": "https://github.com/blakeblackshear/frigate/releases", + }, + { + "title": "Recording Storage Almost Full", + "desc": "The storage for saving recordings is almost full, this may cause issues with cameras.", + "type": "warning", + "url": "/debug", + }, + { + "title": "Front Door is not connected.", + "desc": "Check the logs for more info on the cameras connection.", + "type": "error", + "url": "/debug", + }, + ] + def _insert_mock_event(id: str) -> Event: """Inserts a basic event model with a given id.""" diff --git a/web/config/handlers.js b/web/config/handlers.js index 0166d0c3a..fa0a44905 100644 --- a/web/config/handlers.js +++ b/web/config/handlers.js @@ -54,6 +54,18 @@ export const handlers = [ }) ); }), + rest.get(`${API_HOST}api/notifications`, (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json([ + { + title: 'Notification Title', + desc: 'Notification Desc', + type: 'success', + }, + ]) + ); + }), rest.get(`${API_HOST}api/events`, (req, res, ctx) => { return res( ctx.status(200), diff --git a/web/src/AppBar.jsx b/web/src/AppBar.jsx index 09fc2e9d8..67d997666 100644 --- a/web/src/AppBar.jsx +++ b/web/src/AppBar.jsx @@ -1,4 +1,5 @@ import { h, Fragment } from 'preact'; +import useSWR from 'swr'; import BaseAppBar from './components/AppBar'; import LinkedLogo from './components/LinkedLogo'; import Menu, { MenuItem, MenuSeparator } from './components/Menu'; @@ -10,8 +11,10 @@ import Prompt from './components/Prompt'; import { useDarkMode } from './context'; import { useCallback, useRef, useState } from 'preact/hooks'; import { useRestart } from './api/mqtt'; +import NotificationMenu, { NotificationItem } from './components/NotificationMenu'; export default function AppBar() { + const [showNotifications, setShowNotifications] = useState(false); const [showMoreMenu, setShowMoreMenu] = useState(false); const [showDialog, setShowDialog] = useState(false); const [showDialogWait, setShowDialogWait] = useState(false); @@ -26,6 +29,18 @@ export default function AppBar() { [setDarkMode, setShowMoreMenu] ); + const { data: notifications } = useSWR('notifications'); + + const notifRef = useRef(null); + + const handleShowNotifications = useCallback(() => { + setShowNotifications(true); + }, [setShowNotifications]); + + const handleDismissNotifications = useCallback(() => { + setShowNotifications(false); + }, [setShowNotifications]); + const moreRef = useRef(null); const handleShowMenu = useCallback(() => { @@ -53,7 +68,14 @@ export default function AppBar() { return ( - + 0) ? notifRef : null} overflowRef={moreRef} onNotificationClick={handleShowNotifications} onOverflowClick={handleShowMenu} /> + {showNotifications ? ( + + {notifications.map((item) => ( + + ))} + + ) : null} {showMoreMenu ? ( diff --git a/web/src/components/AppBar.jsx b/web/src/components/AppBar.jsx index 1003fee55..17f291919 100644 --- a/web/src/components/AppBar.jsx +++ b/web/src/components/AppBar.jsx @@ -1,5 +1,6 @@ import { h } from 'preact'; import Button from './Button'; +import NotificationIcon from '../icons/Notification'; import MenuIcon from '../icons/Menu'; import MoreIcon from '../icons/More'; import { useDrawer } from '../context'; @@ -9,7 +10,7 @@ import { useLayoutEffect, useCallback, useState } from 'preact/hooks'; // But need to avoid too many re-renders let lastScrollY = window.scrollY; -export default function AppBar({ title: Title, overflowRef, onOverflowClick }) { +export default function AppBar({ title: Title, notificationRef, overflowRef, onNotificationClick, onOverflowClick }) { const [show, setShow] = useState(true); const [atZero, setAtZero] = useState(window.scrollY === 0); const { setShowDrawer } = useDrawer(); @@ -50,6 +51,19 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) { <div className="flex-grow-1 flex justify-end w-full"> + {notificationRef && onNotificationClick ? ( + <div className="w-auto" ref={notificationRef}> + <Button + aria-label="Notifications" + color="yellow" + className="rounded-full w-9 h-9" + onClick={onNotificationClick} + type="text" + > + <NotificationIcon className="w-10 h-10" /> + </Button> + </div> + ) : null} {overflowRef && onOverflowClick ? ( <div className="w-auto" ref={overflowRef}> <Button diff --git a/web/src/components/NotificationMenu.jsx b/web/src/components/NotificationMenu.jsx new file mode 100644 index 000000000..1a97cf8b7 --- /dev/null +++ b/web/src/components/NotificationMenu.jsx @@ -0,0 +1,45 @@ +import RelativeModal from './RelativeModal'; + +export default function NotificationMenu({ className, children, onDismiss, relativeTo, widthRelative }) { + return relativeTo ? ( + <RelativeModal + className={`${className || ''} py-2 max-w-xs`} + role="listbox" + onDismiss={onDismiss} + portalRootID="menus" + relativeTo={relativeTo} + widthRelative={widthRelative}> + <div className="p-3 font-bold text-lg">Notifications</div> + <div children={children} /> + </RelativeModal> + ) : null; +} + +export function NotificationItem({ title, desc, type, href }) { + return ( + <a + href={href} + target="_blank" + rel="noopener noreferrer" + > + <div + className={`cursor-pointer m-2 ${getColor(type)}`} + > + <div className="whitespace-nowrap p-2 font-bold">{title}</div> + <div className="p-2">{desc}</div> + </div> + </a> + ); +} + +function getColor(type) { + if (type == "success") { + return "bg-green-500 hover:bg-green-600" + } else if (type == "warning") { + return "bg-yellow-500 hover:bg-yellow-600" + } else if (type == "error") { + return "bg-red-500 hover:bg-red-600" + } + + return "bg-gray-500 hover:bg-gray-600" +} diff --git a/web/src/icons/Notification.jsx b/web/src/icons/Notification.jsx new file mode 100644 index 000000000..787f8de26 --- /dev/null +++ b/web/src/icons/Notification.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Notification({ className = '' }) { + return ( + <svg className={`fill-current ${className}`} viewBox="0 0 24 24"> + <path d="M19 17V11.8C18.5 11.9 18 12 17.5 12H17V18H7V11C7 8.2 9.2 6 12 6C12.1 4.7 12.7 3.6 13.5 2.7C13.2 2.3 12.6 2 12 2C10.9 2 10 2.9 10 4V4.3C7 5.2 5 7.9 5 11V17L3 19V20H21V19L19 17M10 21C10 22.1 10.9 23 12 23S14 22.1 14 21H10M21 6.5C21 8.4 19.4 10 17.5 10S14 8.4 14 6.5 15.6 3 17.5 3 21 4.6 21 6.5" /> + </svg> + ); +} + +export default memo(Notification);