Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEAT: Notifications In Frigate Frontend #3243

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion frigate/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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
Expand All @@ -26,7 +27,7 @@
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
Expand Down Expand Up @@ -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"))
Expand Down
46 changes: 45 additions & 1 deletion frigate/test/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ def setUp(self):
self.db = SqliteQueueDatabase(TEST_DB)
models = [Event, Recordings]
self.db.bind(models)

self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
Expand Down Expand Up @@ -293,6 +292,51 @@ def test_stats(self, mock_stats):
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."""
Expand Down
12 changes: 12 additions & 0 deletions web/config/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
24 changes: 23 additions & 1 deletion web/src/AppBar.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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} />
Expand Down
16 changes: 15 additions & 1 deletion web/src/components/AppBar.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions web/src/components/NotificationMenu.jsx
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions web/src/icons/Notification.jsx
Original file line number Diff line number Diff line change
@@ -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);