diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 794e81dac..c3e9e4c25 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -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) diff --git a/docker/Dockerfile.wheels b/docker/Dockerfile.wheels index 278d989e0..d81795673 100644 --- a/docker/Dockerfile.wheels +++ b/docker/Dockerfile.wheels @@ -35,7 +35,7 @@ RUN pip3 wheel --wheel-dir=/wheels \ click \ setproctitle \ peewee \ - gevent + gevent FROM scratch diff --git a/docs/docs/usage/api.md b/docs/docs/usage/api.md index b7fb4dbbc..cb2a9f2c4 100644 --- a/docs/docs/usage/api.md +++ b/docs/docs/usage/api.md @@ -5,7 +5,7 @@ title: HTTP API A web server is available on port 5000 with the following endpoints. -### `/api/` +### `GET /api/` 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`. -### `/api///best.jpg[?h=300&crop=1]` +### `GET /api///best.jpg[?h=300&crop=1]` 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 - `crop=1`: crops the image to the region of the detection rather than returning the entire image -### `/api//latest.jpg[?h=300]` +### `GET /api//latest.jpg[?h=300]` 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 -### `/api/stats` +### `GET /api/stats` 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 -### `/api/version` +### `GET /api/version` Version info -### `/api/events` +### `GET /api/events` 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) | | `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/` +### `GET /api/events/` Returns data for a single event. -### `/api/events//thumbnail.jpg` +### `DELETE /api/events/` + +Permanently deletes the event along with any clips/snapshots. + +### `GET /api/events//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//snapshot.jpg` +### `GET /api/events//snapshot.jpg` Returns the snapshot image for the event id. Works while the event is in progress and after completion. diff --git a/frigate/http.py b/frigate/http.py index 3fffa853d..d94b1b597 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -5,6 +5,7 @@ import logging import os import time from functools import reduce +from pathlib import Path import cv2 import gevent @@ -178,15 +179,36 @@ def events_summary(): return jsonify([e for e in groups.dicts()]) -@bp.route("/events/") +@bp.route("/events/", 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/', 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//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//thumbnail.jpg') def event_thumbnail(id): format = request.args.get("format", "ios") thumbnail_bytes = None diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 51842cb70..06760a08e 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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://frigate_api/; proxy_pass_request_headers on; diff --git a/web/public/index.html b/web/public/index.html index 1ee216246..371494bf9 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -15,6 +15,7 @@
+
diff --git a/web/src/components/Dialog.jsx b/web/src/components/Dialog.jsx new file mode 100644 index 000000000..aefc323b4 --- /dev/null +++ b/web/src/components/Dialog.jsx @@ -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 = ( + +
+
+
+ {title} +

{text}

+
+
+ {actions.map(({ color, text, onClick, ...props }, i) => ( + + ))} +
+
+
+
+ ); + + return portalRoot ? createPortal(dialog, portalRoot) : dialog; +} diff --git a/web/src/components/__tests__/Dialog.test.jsx b/web/src/components/__tests__/Dialog.test.jsx new file mode 100644 index 000000000..646f5a46d --- /dev/null +++ b/web/src/components/__tests__/Dialog.test.jsx @@ -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(); + expect(screen.getByText('Tacos')).toBeInTheDocument(); + expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull(); + }); + + test('renders action buttons', async () => { + const handleClick = jest.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Okay' })); + expect(handleClick).toHaveBeenCalled(); + }); +}); diff --git a/web/src/icons/Delete.jsx b/web/src/icons/Delete.jsx new file mode 100644 index 000000000..fc865b732 --- /dev/null +++ b/web/src/icons/Delete.jsx @@ -0,0 +1,13 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function Delete({ className = '' }) { + return ( + + + + + ); +} + +export default memo(Delete); diff --git a/web/src/routes/Event.jsx b/web/src/routes/Event.jsx index cf45f9e62..dbf7aaa4f 100644 --- a/web/src/routes/Event.jsx +++ b/web/src/routes/Event.jsx @@ -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'; @@ -8,9 +13,39 @@ 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 ; + return } const startime = new Date(data.start_time * 1000); @@ -18,9 +53,27 @@ export default function Event({ eventId }) { return (
- - {data.camera} {data.label} {startime.toLocaleString()} - +
+ + {data.camera} {data.label} {startime.toLocaleString()} + + + {showDialog ? ( + + ) : null} +
diff --git a/web/src/routes/StyleGuide.jsx b/web/src/routes/StyleGuide.jsx index b0759fd63..3e79b36f1 100644 --- a/web/src/routes/StyleGuide.jsx +++ b/web/src/routes/StyleGuide.jsx @@ -2,6 +2,7 @@ import { h } from 'preact'; import ArrowDropdown from '../icons/ArrowDropdown'; import ArrowDropup from '../icons/ArrowDropup'; import Button from '../components/Button'; +import Dialog from '../components/Dialog'; import Heading from '../components/Heading'; import Select from '../components/Select'; import Switch from '../components/Switch'; @@ -10,6 +11,7 @@ import { useCallback, useState } from 'preact/hooks'; export default function StyleGuide() { const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false }); + const [showDialog, setShowDialog] = useState(false); const handleSwitch = useCallback( (id, checked) => { @@ -18,6 +20,10 @@ export default function StyleGuide() { [switches] ); + const handleDismissDialog = () => { + setShowDialog(false); + }; + return (
Button @@ -59,6 +65,26 @@ export default function StyleGuide() {
+ Dialog + + {showDialog ? ( + + ) : null} + Switch