feat(web): Delete events from Event page and API (#991)

Co-authored-by: Scott Roach <scott@thinkpivot.io>
Co-authored-by: Paul Armstrong <paul@spaceyak.com>
This commit is contained in:
Mitch Ross 2021-05-12 11:19:02 -04:00 committed by GitHub
parent 482399d82f
commit ebb6d348a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 225 additions and 20 deletions

View File

@ -5,7 +5,7 @@ title: HTTP API
A web server is available on port 5000 with the following endpoints. A web server is available on port 5000 with the following endpoints.
### `/api/<camera_name>` ### `GET /api/<camera_name>`
An mjpeg stream for debugging. Keep in mind the mjpeg endpoint is for debugging only and will put additional load on the system when in use. An mjpeg stream for debugging. Keep in mind the mjpeg endpoint is for debugging only and will put additional load on the system when in use.
@ -24,7 +24,7 @@ Accepts the following query string parameters:
You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:5000/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:5000/back?fps=10` or both with `?fps=10&h=1000`. You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:5000/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:5000/back?fps=10` or both with `?fps=10&h=1000`.
### `/api/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]` ### `GET /api/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]`
The best snapshot for any object type. It is a full resolution image by default. The best snapshot for any object type. It is a full resolution image by default.
@ -33,7 +33,7 @@ Example parameters:
- `h=300`: resizes the image to 300 pixes tall - `h=300`: resizes the image to 300 pixes tall
- `crop=1`: crops the image to the region of the detection rather than returning the entire image - `crop=1`: crops the image to the region of the detection rather than returning the entire image
### `/api/<camera_name>/latest.jpg[?h=300]` ### `GET /api/<camera_name>/latest.jpg[?h=300]`
The most recent frame that frigate has finished processing. It is a full resolution image by default. The most recent frame that frigate has finished processing. It is a full resolution image by default.
@ -53,7 +53,7 @@ Example parameters:
- `h=300`: resizes the image to 300 pixes tall - `h=300`: resizes the image to 300 pixes tall
### `/api/stats` ### `GET /api/stats`
Contains some granular debug info that can be used for sensors in HomeAssistant. Contains some granular debug info that can be used for sensors in HomeAssistant.
@ -150,15 +150,15 @@ Sample response:
} }
``` ```
### `/api/config` ### `GET /api/config`
A json representation of your configuration A json representation of your configuration
### `/api/version` ### `GET /api/version`
Version info Version info
### `/api/events` ### `GET /api/events`
Events from the database. Accepts the following query string parameters: Events from the database. Accepts the following query string parameters:
@ -174,19 +174,23 @@ Events from the database. Accepts the following query string parameters:
| `has_clip` | int | Filter to events that have clips (0 or 1) | | `has_clip` | int | Filter to events that have clips (0 or 1) |
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) | | `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
### `/api/events/summary` ### `GET /api/events/summary`
Returns summary data for events in the database. Used by the HomeAssistant integration. Returns summary data for events in the database. Used by the HomeAssistant integration.
### `/api/events/<id>` ### `GET /api/events/<id>`
Returns data for a single event. Returns data for a single event.
### `/api/events/<id>/thumbnail.jpg` ### `DELETE /api/events/<id>`
Permanently deletes the event along with any clips/snapshots.
### `GET /api/events/<id>/thumbnail.jpg`
Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio. Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio.
### `/api/events/<id>/snapshot.jpg` ### `GET /api/events/<id>/snapshot.jpg`
Returns the snapshot image for the event id. Works while the event is in progress and after completion. Returns the snapshot image for the event id. Works while the event is in progress and after completion.

View File

@ -5,6 +5,7 @@ import logging
import os import os
import time import time
from functools import reduce from functools import reduce
from pathlib import Path
import cv2 import cv2
import gevent import gevent
@ -178,15 +179,36 @@ def events_summary():
return jsonify([e for e in groups.dicts()]) return jsonify([e for e in groups.dicts()])
@bp.route("/events/<id>") @bp.route("/events/<id>", methods=("GET",))
def event(id): def event(id):
try: try:
return model_to_dict(Event.get(Event.id == id)) return model_to_dict(Event.get(Event.id == id))
except DoesNotExist: except DoesNotExist:
return "Event not found", 404 return "Event not found", 404
@bp.route('/events/<id>', methods=('DELETE',))
def delete_event(id):
try:
event = Event.get(Event.id == id)
except DoesNotExist:
return make_response(jsonify({"success": False, "message": "Event" + id + " not found"}),404)
@bp.route("/events/<id>/thumbnail.jpg")
media_name = f"{event.camera}-{event.id}"
if event.has_snapshot:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media.unlink(missing_ok=True)
if event.has_clip:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
media.unlink(missing_ok=True)
event.delete_instance()
return make_response(jsonify({"success": True, "message": "Event" + id + " deleted"}),200)
@bp.route('/events/<id>/thumbnail.jpg')
def event_thumbnail(id): def event_thumbnail(id):
format = request.args.get("format", "ios") format = request.args.get("format", "ios")
thumbnail_bytes = None thumbnail_bytes = None

View File

@ -112,6 +112,7 @@ http {
location /api/ { location /api/ {
add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
proxy_pass http://frigate_api/; proxy_pass http://frigate_api/;
proxy_pass_request_headers on; proxy_pass_request_headers on;

View File

@ -15,6 +15,7 @@
</head> </head>
<body> <body>
<div id="root" class="z-0"></div> <div id="root" class="z-0"></div>
<div id="dialogs" class="z-0"></div>
<div id="menus" class="z-0"></div> <div id="menus" class="z-0"></div>
<div id="tooltips" class="z-0"></div> <div id="tooltips" class="z-0"></div>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -0,0 +1,47 @@
import { h, Fragment } from 'preact';
import Button from './Button';
import Heading from './Heading';
import { createPortal } from 'preact/compat';
import { useState, useEffect } from 'preact/hooks';
export default function Dialog({ actions = [], portalRootID = 'dialogs', title, text }) {
const portalRoot = portalRootID && document.getElementById(portalRootID);
const [show, setShow] = useState(false);
useEffect(() => {
window.requestAnimationFrame(() => {
setShow(true);
});
}, []);
const dialog = (
<Fragment>
<div
data-testid="scrim"
key="scrim"
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
>
<div
role="modal"
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 max-w-sm text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
show ? 'scale-100 opacity-100' : ''
}`}
>
<div className="p-4">
<Heading size="lg">{title}</Heading>
<p>{text}</p>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
{actions.map(({ color, text, onClick, ...props }, i) => (
<Button className="ml-2" color={color} key={i} onClick={onClick} type="text" {...props}>
{text}
</Button>
))}
</div>
</div>
</div>
</Fragment>
);
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
}

View File

@ -0,0 +1,38 @@
import { h } from 'preact';
import Dialog from '../Dialog';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('Dialog', () => {
let portal;
beforeAll(() => {
portal = document.createElement('div');
portal.id = 'dialogs';
document.body.appendChild(portal);
});
afterAll(() => {
document.body.removeChild(portal);
});
test('renders to a portal', async () => {
render(<Dialog title="Tacos" text="This is the dialog" />);
expect(screen.getByText('Tacos')).toBeInTheDocument();
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
});
test('renders action buttons', async () => {
const handleClick = jest.fn();
render(
<Dialog
actions={[
{ color: 'red', text: 'Delete' },
{ text: 'Okay', onClick: handleClick },
]}
title="Tacos"
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
expect(handleClick).toHaveBeenCalled();
});
});

13
web/src/icons/Delete.jsx Normal file
View File

@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Delete({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M6 21h12V7H6v14zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
);
}
export default memo(Delete);

View File

@ -1,5 +1,10 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { route } from 'preact-router';
import ActivityIndicator from '../components/ActivityIndicator'; import ActivityIndicator from '../components/ActivityIndicator';
import Button from '../components/Button';
import Delete from '../icons/Delete'
import Dialog from '../components/Dialog';
import Heading from '../components/Heading'; import Heading from '../components/Heading';
import Link from '../components/Link'; import Link from '../components/Link';
import { FetchStatus, useApiHost, useEvent } from '../api'; import { FetchStatus, useApiHost, useEvent } from '../api';
@ -8,9 +13,39 @@ import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
export default function Event({ eventId }) { export default function Event({ eventId }) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data, status } = useEvent(eventId); const { data, status } = useEvent(eventId);
const [showDialog, setShowDialog] = useState(false);
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
const handleClickDelete = () => {
setShowDialog(true);
};
const handleDismissDeleteDialog = () => {
setShowDialog(false);
};
const handleClickDeleteDialog = useCallback(async () => {
let success;
try {
const response = await fetch(`${apiHost}/api/events/${eventId}`, { method: 'DELETE' });
success = await (response.status < 300 ? response.json() : { success: true });
setDeleteStatus(success ? FetchStatus.LOADED : FetchStatus.ERROR);
} catch (e) {
setDeleteStatus(FetchStatus.ERROR);
}
if (success) {
setDeleteStatus(FetchStatus.LOADED);
setShowDialog(false);
route('/events', true);
}
}, [apiHost, eventId, setShowDialog]);
if (status !== FetchStatus.LOADED) { if (status !== FetchStatus.LOADED) {
return <ActivityIndicator />; return <ActivityIndicator />
} }
const startime = new Date(data.start_time * 1000); const startime = new Date(data.start_time * 1000);
@ -18,9 +53,27 @@ export default function Event({ eventId }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Heading> <div className="flex">
<Heading className="flex-grow">
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span> {data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
</Heading> </Heading>
<Button className="self-start" color="red" onClick={handleClickDelete}>
<Delete className="w-6" /> Delete event
</Button>
{showDialog ? (
<Dialog
onDismiss={handleDismissDeleteDialog}
title="Delete Event?"
text="This event will be permanently deleted along with any related clips and snapshots"
actions={[
deleteStatus !== FetchStatus.LOADING
? { text: 'Delete', color: 'red', onClick: handleClickDeleteDialog }
: { text: 'Deleting…', color: 'red', disabled: true },
{ text: 'Cancel', onClick: handleDismissDeleteDialog },
]}
/>
) : null}
</div>
<Table class="w-full"> <Table class="w-full">
<Thead> <Thead>

View File

@ -2,6 +2,7 @@ import { h } from 'preact';
import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup'; import ArrowDropup from '../icons/ArrowDropup';
import Button from '../components/Button'; import Button from '../components/Button';
import Dialog from '../components/Dialog';
import Heading from '../components/Heading'; import Heading from '../components/Heading';
import Select from '../components/Select'; import Select from '../components/Select';
import Switch from '../components/Switch'; import Switch from '../components/Switch';
@ -10,6 +11,7 @@ import { useCallback, useState } from 'preact/hooks';
export default function StyleGuide() { export default function StyleGuide() {
const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false }); const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false });
const [showDialog, setShowDialog] = useState(false);
const handleSwitch = useCallback( const handleSwitch = useCallback(
(id, checked) => { (id, checked) => {
@ -18,6 +20,10 @@ export default function StyleGuide() {
[switches] [switches]
); );
const handleDismissDialog = () => {
setShowDialog(false);
};
return ( return (
<div> <div>
<Heading size="md">Button</Heading> <Heading size="md">Button</Heading>
@ -59,6 +65,26 @@ export default function StyleGuide() {
</Button> </Button>
</div> </div>
<Heading size="md">Dialog</Heading>
<Button
onClick={() => {
setShowDialog(true);
}}
>
Show Dialog
</Button>
{showDialog ? (
<Dialog
onDismiss={handleDismissDialog}
title="This is a dialog"
text="Would you like to see more?"
actions={[
{ text: 'Yes', color: 'red', onClick: handleDismissDialog },
{ text: 'No', onClick: handleDismissDialog },
]}
/>
) : null}
<Heading size="md">Switch</Heading> <Heading size="md">Switch</Heading>
<div className="flex-col space-y-4 max-w-4xl"> <div className="flex-col space-y-4 max-w-4xl">
<Switch label="Disabled, off" labelPosition="after" /> <Switch label="Disabled, off" labelPosition="after" />