Add notifications to frontend

This commit is contained in:
Nick Mowen 2022-06-30 09:00:08 -06:00
parent f7c5e02a35
commit 93ae8b0c79
7 changed files with 200 additions and 4 deletions

View File

@ -6,6 +6,7 @@ import logging
import os import os
import subprocess as sp import subprocess as sp
import time import time
from typing import Any, Dict, List
from functools import reduce from functools import reduce
from pathlib import Path from pathlib import Path
from urllib.parse import unquote from urllib.parse import unquote
@ -26,7 +27,7 @@ from flask import (
from peewee import SqliteDatabase, operator, fn, DoesNotExist from peewee import SqliteDatabase, operator, fn, DoesNotExist
from playhouse.shortcuts import model_to_dict 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.models import Event, Recordings
from frigate.stats import stats_snapshot from frigate.stats import stats_snapshot
from frigate.version import VERSION from frigate.version import VERSION
@ -597,6 +598,52 @@ def stats():
return jsonify(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("/<camera_name>") @bp.route("/<camera_name>")
def mjpeg_feed(camera_name): def mjpeg_feed(camera_name):
fps = int(request.args.get("fps", "3")) fps = int(request.args.get("fps", "3"))

View File

@ -29,7 +29,6 @@ class TestHttp(unittest.TestCase):
self.db = SqliteQueueDatabase(TEST_DB) self.db = SqliteQueueDatabase(TEST_DB)
models = [Event, Recordings] models = [Event, Recordings]
self.db.bind(models) self.db.bind(models)
self.minimal_config = { self.minimal_config = {
"mqtt": {"host": "mqtt"}, "mqtt": {"host": "mqtt"},
"cameras": { "cameras": {
@ -293,6 +292,51 @@ class TestHttp(unittest.TestCase):
stats = client.get("/stats").json stats = client.get("/stats").json
assert stats == self.test_stats 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: def _insert_mock_event(id: str) -> Event:
"""Inserts a basic event model with a given id.""" """Inserts a basic event model with a given id."""

View File

@ -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) => { rest.get(`${API_HOST}api/events`, (req, res, ctx) => {
return res( return res(
ctx.status(200), ctx.status(200),

View File

@ -1,4 +1,5 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import useSWR from 'swr';
import BaseAppBar from './components/AppBar'; import BaseAppBar from './components/AppBar';
import LinkedLogo from './components/LinkedLogo'; import LinkedLogo from './components/LinkedLogo';
import Menu, { MenuItem, MenuSeparator } from './components/Menu'; import Menu, { MenuItem, MenuSeparator } from './components/Menu';
@ -10,8 +11,10 @@ import Prompt from './components/Prompt';
import { useDarkMode } from './context'; import { useDarkMode } from './context';
import { useCallback, useRef, useState } from 'preact/hooks'; import { useCallback, useRef, useState } from 'preact/hooks';
import { useRestart } from './api/mqtt'; import { useRestart } from './api/mqtt';
import NotificationMenu, { NotificationItem } from './components/NotificationMenu';
export default function AppBar() { export default function AppBar() {
const [showNotifications, setShowNotifications] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false); const [showMoreMenu, setShowMoreMenu] = useState(false);
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [showDialogWait, setShowDialogWait] = useState(false); const [showDialogWait, setShowDialogWait] = useState(false);
@ -26,6 +29,18 @@ export default function AppBar() {
[setDarkMode, setShowMoreMenu] [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 moreRef = useRef(null);
const handleShowMenu = useCallback(() => { const handleShowMenu = useCallback(() => {
@ -53,7 +68,14 @@ export default function AppBar() {
return ( return (
<Fragment> <Fragment>
<BaseAppBar title={LinkedLogo} overflowRef={moreRef} onOverflowClick={handleShowMenu} /> <BaseAppBar title={LinkedLogo} notificationRef={(notifications && notifications.length > 0) ? notifRef : null} overflowRef={moreRef} onNotificationClick={handleShowNotifications} onOverflowClick={handleShowMenu} />
{showNotifications ? (
<NotificationMenu onDismiss={handleDismissNotifications} relativeTo={notifRef}>
{notifications.map((item) => (
<NotificationItem key={item.title} title={item.title} desc={item.desc} type={item.type} href={item.url} />
))}
</NotificationMenu>
) : null}
{showMoreMenu ? ( {showMoreMenu ? (
<Menu onDismiss={handleDismissMoreMenu} relativeTo={moreRef}> <Menu onDismiss={handleDismissMoreMenu} relativeTo={moreRef}>
<MenuItem icon={AutoAwesomeIcon} label="Auto dark mode" value="media" onSelect={handleSelectDarkMode} /> <MenuItem icon={AutoAwesomeIcon} label="Auto dark mode" value="media" onSelect={handleSelectDarkMode} />

View File

@ -1,5 +1,6 @@
import { h } from 'preact'; import { h } from 'preact';
import Button from './Button'; import Button from './Button';
import NotificationIcon from '../icons/Notification';
import MenuIcon from '../icons/Menu'; import MenuIcon from '../icons/Menu';
import MoreIcon from '../icons/More'; import MoreIcon from '../icons/More';
import { useDrawer } from '../context'; import { useDrawer } from '../context';
@ -9,7 +10,7 @@ import { useLayoutEffect, useCallback, useState } from 'preact/hooks';
// But need to avoid too many re-renders // But need to avoid too many re-renders
let lastScrollY = window.scrollY; 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 [show, setShow] = useState(true);
const [atZero, setAtZero] = useState(window.scrollY === 0); const [atZero, setAtZero] = useState(window.scrollY === 0);
const { setShowDrawer } = useDrawer(); const { setShowDrawer } = useDrawer();
@ -50,6 +51,19 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
</div> </div>
<Title /> <Title />
<div className="flex-grow-1 flex justify-end w-full"> <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 ? ( {overflowRef && onOverflowClick ? (
<div className="w-auto" ref={overflowRef}> <div className="w-auto" ref={overflowRef}>
<Button <Button

View File

@ -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"
}

View File

@ -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);