Skip to content

Commit

Permalink
feat(web): Delete events from Event page and API (#991)
Browse files Browse the repository at this point in the history
Co-authored-by: Scott Roach <[email protected]>
Co-authored-by: Paul Armstrong <[email protected]>
  • Loading branch information
3 people committed May 12, 2021
1 parent 482399d commit ebb6d34
Show file tree
Hide file tree
Showing 11 changed files with 225 additions and 20 deletions.
4 changes: 2 additions & 2 deletions docker/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ RUN apt-get -qq update \
&& apt-get -qq install --no-install-recommends -y \
gnupg wget unzip tzdata nginx libnginx-mod-rtmp \
&& apt-get -qq install --no-install-recommends -y \
python3-pip \
python3-pip \
&& pip3 install -U /wheels/*.whl \
&& APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \
&& echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \
&& echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \
&& apt-get -qq update && apt-get -qq install --no-install-recommends -y \
libedgetpu1-max=15.0 \
libedgetpu1-max=15.0 \
&& rm -rf /var/lib/apt/lists/* /wheels \
&& (apt-get autoremove -y; apt-get autoclean -y)

Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile.wheels
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ RUN pip3 wheel --wheel-dir=/wheels \
click \
setproctitle \
peewee \
gevent
gevent

FROM scratch

Expand Down
26 changes: 15 additions & 11 deletions docs/docs/usage/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: HTTP API

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.

Expand All @@ -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:https://localhost:5000/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http:https://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.

Expand All @@ -33,7 +33,7 @@ Example parameters:
- `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

### `/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.

Expand All @@ -53,7 +53,7 @@ Example parameters:

- `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.

Expand Down Expand Up @@ -150,15 +150,15 @@ Sample response:
}
```

### `/api/config`
### `GET /api/config`

A json representation of your configuration

### `/api/version`
### `GET /api/version`

Version info

### `/api/events`
### `GET /api/events`

Events from the database. Accepts the following query string parameters:

Expand All @@ -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) |
| `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.

### `/api/events/<id>`
### `GET /api/events/<id>`

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.

### `/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.

Expand Down
26 changes: 24 additions & 2 deletions frigate/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import time
from functools import reduce
from pathlib import Path

import cv2
import gevent
Expand Down Expand Up @@ -178,15 +179,36 @@ def events_summary():
return jsonify([e for e in groups.dicts()])


@bp.route("/events/<id>")
@bp.route("/events/<id>", methods=("GET",))
def event(id):
try:
return model_to_dict(Event.get(Event.id == id))
except DoesNotExist:
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)


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")
@bp.route('/events/<id>/thumbnail.jpg')
def event_thumbnail(id):
format = request.args.get("format", "ios")
thumbnail_bytes = None
Expand Down
1 change: 1 addition & 0 deletions nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ http {

location /api/ {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header Cache-Control "no-store";
proxy_pass http:https://frigate_api/;
proxy_pass_request_headers on;
Expand Down
1 change: 1 addition & 0 deletions web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</head>
<body>
<div id="root" class="z-0"></div>
<div id="dialogs" class="z-0"></div>
<div id="menus" class="z-0"></div>
<div id="tooltips" class="z-0"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
Expand Down
47 changes: 47 additions & 0 deletions web/src/components/Dialog.jsx
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 38 additions & 0 deletions web/src/components/__tests__/Dialog.test.jsx
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions web/src/icons/Delete.jsx
Original file line number Diff line number Diff line change
@@ -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);
61 changes: 57 additions & 4 deletions web/src/routes/Event.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { h, Fragment } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { route } from 'preact-router';
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 Link from '../components/Link';
import { FetchStatus, useApiHost, useEvent } from '../api';
Expand All @@ -8,19 +13,67 @@ import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
export default function Event({ eventId }) {
const apiHost = useApiHost();
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) {
return <ActivityIndicator />;
return <ActivityIndicator />
}

const startime = new Date(data.start_time * 1000);
const endtime = new Date(data.end_time * 1000);

return (
<div className="space-y-4">
<Heading>
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
</Heading>
<div className="flex">
<Heading className="flex-grow">
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
</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">
<Thead>
Expand Down
Loading

0 comments on commit ebb6d34

Please sign in to comment.