Browse Source

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>
Mitch Ross 4 years ago
parent
commit
ebb6d348a3

+ 2 - 2
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)
 

+ 1 - 1
docker/Dockerfile.wheels

@@ -35,7 +35,7 @@ RUN pip3 wheel --wheel-dir=/wheels \
     click \
     setproctitle \
     peewee \
-    gevent
+    gevent 
 
 FROM scratch
 

+ 15 - 11
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/<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.
 
@@ -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/<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.
 
@@ -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.
 
@@ -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/<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.
 

+ 24 - 2
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/<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

+ 1 - 0
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;

+ 1 - 0
web/public/index.html

@@ -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>

+ 47 - 0
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 = (
+    <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 - 0
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(<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 - 0
web/src/icons/Delete.jsx

@@ -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);

+ 57 - 4
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 <ActivityIndicator />;
+    return <ActivityIndicator />
   }
 
   const startime = new Date(data.start_time * 1000);
@@ -18,9 +53,27 @@ export default function Event({ eventId }) {
 
   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>

+ 26 - 0
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 (
     <div>
       <Heading size="md">Button</Heading>
@@ -59,6 +65,26 @@ export default function StyleGuide() {
         </Button>
       </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>
       <div className="flex-col space-y-4 max-w-4xl">
         <Switch label="Disabled, off" labelPosition="after" />