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 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("/<camera_name>")
def mjpeg_feed(camera_name):
fps = int(request.args.get("fps", "3"))

View File

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

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) => {
return res(
ctx.status(200),

View File

@ -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 (
<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 ? (
<Menu onDismiss={handleDismissMoreMenu} relativeTo={moreRef}>
<MenuItem icon={AutoAwesomeIcon} label="Auto dark mode" value="media" onSelect={handleSelectDarkMode} />

View File

@ -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>
<Title />
<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

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