mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-03 01:35:22 +03:00
Add notifications to frontend
This commit is contained in:
parent
f7c5e02a35
commit
93ae8b0c79
@ -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"))
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
45
web/src/components/NotificationMenu.jsx
Normal file
45
web/src/components/NotificationMenu.jsx
Normal 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"
|
||||||
|
}
|
||||||
12
web/src/icons/Notification.jsx
Normal file
12
web/src/icons/Notification.jsx
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user