mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-02 17:25: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 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"))
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
|
||||
|
||||
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