feat!: web user interface

This commit is contained in:
Paul Armstrong 2021-01-09 09:26:46 -08:00 committed by Blake Blackshear
parent 5ad4017510
commit c618867941
29 changed files with 9112 additions and 123 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
.DS_Store
*.pyc *.pyc
debug debug
.vscode .vscode
@ -6,3 +7,5 @@ models
*.mp4 *.mp4
*.db *.db
frigate/version.py frigate/version.py
web/build
web/node_modules

View File

@ -5,13 +5,16 @@ COMMIT_HASH := $(shell git log -1 --pretty=format:"%h")
version: version:
echo "VERSION='0.8.0-$(COMMIT_HASH)'" > frigate/version.py echo "VERSION='0.8.0-$(COMMIT_HASH)'" > frigate/version.py
web:
cd web && npm install && npm run build
amd64_wheels: amd64_wheels:
docker build --tag blakeblackshear/frigate-wheels:amd64 --file docker/Dockerfile.wheels . docker build --tag blakeblackshear/frigate-wheels:amd64 --file docker/Dockerfile.wheels .
amd64_ffmpeg: amd64_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 . docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 .
amd64_frigate: version amd64_frigate: version web
docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --file docker/Dockerfile.base . docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.amd64 . docker build --tag frigate --file docker/Dockerfile.amd64 .
@ -23,7 +26,7 @@ amd64nvidia_wheels:
amd64nvidia_ffmpeg: amd64nvidia_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia . docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia .
amd64nvidia_frigate: version amd64nvidia_frigate: version web
docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base . docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.amd64nvidia . docker build --tag frigate --file docker/Dockerfile.amd64nvidia .
@ -35,7 +38,7 @@ aarch64_wheels:
aarch64_ffmpeg: aarch64_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 . docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 .
aarch64_frigate: version aarch64_frigate: version web
docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base . docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.aarch64 . docker build --tag frigate --file docker/Dockerfile.aarch64 .
@ -47,8 +50,10 @@ armv7_wheels:
armv7_ffmpeg: armv7_ffmpeg:
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 . docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 .
armv7_frigate: version armv7_frigate: version web
docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base . docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
docker build --tag frigate --file docker/Dockerfile.armv7 . docker build --tag frigate --file docker/Dockerfile.armv7 .
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
.PHONY: web

View File

@ -44,6 +44,7 @@ RUN wget -q https://github.com/google-coral/test_data/raw/master/ssdlite_mobiled
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
ADD frigate frigate/ ADD frigate frigate/
ADD web/build web/
COPY run.sh /run.sh COPY run.sh /run.sh
RUN chmod +x /run.sh RUN chmod +x /run.sh

View File

@ -750,7 +750,8 @@ class ZoneConfig():
def to_dict(self): def to_dict(self):
return { return {
'filters': {k: f.to_dict() for k, f in self.filters.items()} 'filters': {k: f.to_dict() for k, f in self.filters.items()},
'coordinates': self._coordinates
} }
class CameraConfig(): class CameraConfig():
@ -763,6 +764,7 @@ class CameraConfig():
self._frame_shape_yuv = (self._frame_shape[0]*3//2, self._frame_shape[1]) self._frame_shape_yuv = (self._frame_shape[0]*3//2, self._frame_shape[1])
self._fps = config.get('fps') self._fps = config.get('fps')
self._mask = self._create_mask(config.get('mask')) self._mask = self._create_mask(config.get('mask'))
self._raw_mask = config.get('mask')
self._best_image_timeout = config['best_image_timeout'] self._best_image_timeout = config['best_image_timeout']
self._zones = { name: ZoneConfig(name, z) for name, z in config['zones'].items() } self._zones = { name: ZoneConfig(name, z) for name, z in config['zones'].items() }
self._clips = CameraClipsConfig(global_config, config['clips']) self._clips = CameraClipsConfig(global_config, config['clips'])
@ -948,6 +950,7 @@ class CameraConfig():
'objects': self.objects.to_dict(), 'objects': self.objects.to_dict(),
'motion': self.motion.to_dict(), 'motion': self.motion.to_dict(),
'detect': self.detect.to_dict(), 'detect': self.detect.to_dict(),
'mask': self._raw_mask,
'frame_shape': self.frame_shape, 'frame_shape': self.frame_shape,
'ffmpeg_cmds': [{'roles': c['roles'], 'cmd': ' '.join(c['cmd'])} for c in self.ffmpeg_cmds], 'ffmpeg_cmds': [{'roles': c['roles'], 'cmd': ' '.join(c['cmd'])} for c in self.ffmpeg_cmds],
} }

View File

@ -96,13 +96,19 @@ http {
root /media/frigate; root /media/frigate;
} }
location / { location /api/ {
add_header 'Access-Control-Allow-Origin' '*';
proxy_pass http://frigate_api/; proxy_pass http://frigate_api/;
proxy_pass_request_headers on; proxy_pass_request_headers on;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location / {
root /opt/frigate/web;
try_files $uri $uri/ /index.html;
}
} }
} }

8
web/README.md Normal file
View File

@ -0,0 +1,8 @@
# Frigate Web UI
## Development
1. Build the docker images in the root of the repository `make amd64_all` (or appropriate for your system)
2. Create a config file in `config/`
3. Run the container: `docker run --rm --name frigate --privileged -v $PWD/config:/config:ro -v /etc/localtime:/etc/localtime:ro -p 5000:5000 frigate`
4. Run the dev ui: `cd web && npm run start`

8083
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
web/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "frigate",
"private": true,
"scripts": {
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
"build": "snowpack build"
},
"dependencies": {
"@prefresh/snowpack": "^3.0.1",
"@snowpack/plugin-optimize": "^0.2.10",
"@snowpack/plugin-postcss": "^1.0.11",
"autoprefixer": "^10.2.1",
"cross-env": "^7.0.3",
"preact": "^10.5.9",
"preact-router": "^3.2.1",
"snowpack": "^2.18.5",
"tailwindcss": "^2.0.2"
}
}

8
web/postcss.config.js Normal file
View File

@ -0,0 +1,8 @@
'use strict';
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
};

24
web/public/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Frigate</title>
</head>
<body>
<div id="root"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<script type="module" src="/dist/index.js"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

24
web/snowpack.config.js Normal file
View File

@ -0,0 +1,24 @@
'use strict';
module.exports = {
mount: {
public: { url: '/', static: true },
src: { url: '/dist' },
},
plugins: [
'@snowpack/plugin-postcss',
'@prefresh/snowpack',
[
'@snowpack/plugin-optimize',
{
preloadModules: true,
},
],
],
installOptions: {
sourceMaps: false,
},
buildOptions: {
sourceMaps: true,
},
};

43
web/src/App.jsx Normal file
View File

@ -0,0 +1,43 @@
import { h } from 'preact';
import Camera from './Camera';
import CameraMap from './CameraMap';
import Cameras from './Cameras';
import Debug from './Debug';
import Event from './Event';
import Events from './Events';
import { Router } from 'preact-router';
import Sidebar from './Sidebar';
import { ApiHost, Config } from './context';
import { useContext, useEffect, useState } from 'preact/hooks';
export default function App() {
const apiHost = useContext(ApiHost);
const [config, setConfig] = useState(null);
useEffect(async () => {
const response = await fetch(`${apiHost}/api/config`);
const data = response.ok ? await response.json() : {};
setConfig(data);
}, []);
return !config ? (
<div />
) : (
<Config.Provider value={config}>
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 ">
<Sidebar />
<div className="p-4">
<Router>
<CameraMap path="/cameras/:camera/map-editor" />
<Camera path="/cameras/:camera" />
<Event path="/events/:eventId" />
<Events path="/events" />
<Debug path="/debug" />
<Cameras path="/" />
</Router>
</div>
</div>
</Config.Provider>
);
return;
}

77
web/src/Camera.jsx Normal file
View File

@ -0,0 +1,77 @@
import { h } from 'preact';
import Heading from './components/Heading';
import Link from './components/Link';
import Switch from './components/Switch';
import { route } from 'preact-router';
import { useCallback, useContext } from 'preact/hooks';
import { ApiHost, Config } from './context';
export default function Camera({ camera, url }) {
const config = useContext(Config);
const apiHost = useContext(ApiHost);
if (!(camera in config.cameras)) {
return <div>{`No camera named ${camera}`}</div>;
}
const cameraConfig = config.cameras[camera];
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url}`);
const searchParamsString = searchParams.toString();
const handleSetOption = useCallback(
(id, value) => {
searchParams.set(id, value ? 1 : 0);
route(`${pathname}?${searchParams.toString()}`, true);
},
[searchParams]
);
function getBoolean(id) {
return Boolean(parseInt(searchParams.get(id), 10));
}
return (
<div>
<Heading size="2xl">{camera}</Heading>
<img
width={cameraConfig.width}
height={cameraConfig.height}
key={searchParamsString}
src={`${apiHost}/api/${camera}?${searchParamsString}`}
/>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
<Switch checked={getBoolean('bbox')} id="bbox" label="Bounding box" onChange={handleSetOption} />
<Switch checked={getBoolean('timestamp')} id="timestamp" label="Timestamp" onChange={handleSetOption} />
<Switch checked={getBoolean('zones')} id="zones" label="Zones" onChange={handleSetOption} />
<Switch checked={getBoolean('mask')} id="mask" label="Masks" onChange={handleSetOption} />
<Switch checked={getBoolean('motion')} id="motion" label="Motion boxes" onChange={handleSetOption} />
<Switch checked={getBoolean('regions')} id="regions" label="Regions" onChange={handleSetOption} />
</div>
<div>
<Heading size="sm">Tracked objects</Heading>
<ul className="flex flex-row flex-wrap space-x-4">
{cameraConfig.objects.track.map((objectType) => {
return (
<li key={objectType}>
<Link href={`/events?camera=${camera}&label=${objectType}`}>
<span className="capitalize">{objectType}</span>
<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />
</Link>
</li>
);
})}
</ul>
</div>
<div>
<Heading size="sm">Options</Heading>
<ul>
<li>
<Link href={`/cameras/${camera}/map-editor`}>Mask & Zone editor</Link>
</li>
</ul>
</div>
</div>
);
}

279
web/src/CameraMap.jsx Normal file
View File

@ -0,0 +1,279 @@
import { h } from 'preact';
import Button from './components/Button';
import Heading from './components/Heading';
import Switch from './components/Switch';
import { route } from 'preact-router';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { ApiHost, Config } from './context';
export default function Camera({ camera, url }) {
const config = useContext(Config);
const apiHost = useContext(ApiHost);
const imageRef = useRef(null);
const [imageScale, setImageScale] = useState(1);
if (!(camera in config.cameras)) {
return <div>{`No camera named ${camera}`}</div>;
}
const cameraConfig = config.cameras[camera];
const { width, height, mask, zones } = cameraConfig;
const [editing, setEditing] = useState('mask');
useEffect(() => {
if (!imageRef.current) {
return;
}
const scaledWidth = imageRef.current.width;
const scale = scaledWidth / width;
setImageScale(scale);
}, [imageRef.current, setImageScale]);
const initialZonePoints = {};
if (mask) {
initialZonePoints.mask = getPolylinePoints(mask);
}
const [zonePoints, setZonePoints] = useState(
Object.keys(zones).reduce(
(memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }),
initialZonePoints
)
);
const handleUpdateEditable = useCallback(
(newPoints) => {
setZonePoints({ ...zonePoints, [editing]: newPoints });
},
[editing, setZonePoints, zonePoints]
);
const handleSelectEditable = useCallback(
(name) => {
setEditing(name);
},
[setEditing]
);
const handleAddMask = useCallback(() => {
setZonePoints({ mask: [], ...zonePoints });
}, [zonePoints, setZonePoints]);
const handleAddZone = useCallback(() => {
const n = Object.keys(zonePoints).length;
const zoneName = `zone-${n}`;
setZonePoints({ ...zonePoints, [zoneName]: [] });
setEditing(zoneName);
}, [zonePoints, setZonePoints]);
return (
<div>
<Heading size="2xl">{camera}</Heading>
<div className="relative">
<img ref={imageRef} width={width} height={height} src={`${apiHost}/api/${camera}/latest.jpg`} />
<EditableMask
onChange={handleUpdateEditable}
points={zonePoints[editing]}
scale={imageScale}
width={width}
height={height}
/>
</div>
<div class="flex-column space-y-4 overflow-hidden">
{Object.keys(zonePoints).map((zone) => (
<MaskValues
editing={editing === zone}
onSelect={handleSelectEditable}
points={zonePoints[zone]}
name={zone}
/>
))}
</div>
<div class="flex flex-grow-0 space-x-4">
{!mask ? <Button onClick={handleAddMask}>Add Mask</Button> : null}
<Button onClick={handleAddZone}>Add Zone</Button>
</div>
</div>
);
}
function EditableMask({ onChange, points: initialPoints, scale, width, height }) {
const [points, setPoints] = useState(initialPoints);
useEffect(() => {
setPoints(initialPoints);
}, [setPoints, initialPoints]);
function boundedSize(value, maxValue) {
return Math.min(Math.max(0, Math.round(value)), maxValue);
}
const handleDragEnd = useCallback(
(index, newX, newY) => {
if (newX < 0 && newY < 0) {
return;
}
const x = boundedSize(newX / scale, width);
const y = boundedSize(newY / scale, height);
const newPoints = [...points];
newPoints[index] = [x, y];
setPoints(newPoints);
onChange(newPoints);
},
[scale, points, setPoints]
);
// Add a new point between the closest two other points
const handleAddPoint = useCallback(
(event) => {
const { offsetX, offsetY } = event;
const scaledX = boundedSize(offsetX / scale, width);
const scaledY = boundedSize(offsetY / scale, height);
const newPoint = [scaledX, scaledY];
const closest = points.reduce((a, b, i) => {
if (!a) {
return b;
}
return distance(a, newPoint) < distance(b, newPoint) ? a : b;
}, null);
const index = points.indexOf(closest);
const newPoints = [...points];
newPoints.splice(index, 0, newPoint);
setPoints(newPoints);
onChange(newPoints);
},
[scale, points, setPoints, onChange]
);
const handleRemovePoint = useCallback(
(index) => {
const newPoints = [...points];
newPoints.splice(index, 1);
setPoints(newPoints);
onChange(newPoints);
},
[points, setPoints, onChange]
);
const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
return (
<div onclick={handleAddPoint}>
{!scaledPoints
? null
: scaledPoints.map(([x, y], i) => (
<PolyPoint index={i} onDragend={handleDragEnd} onRemove={handleRemovePoint} x={x} y={y} />
))}
<svg width="100%" height="100%" className="absolute" style="top: 0; left: 0; right: 0; bottom: 0;">
{!scaledPoints ? null : (
<g>
<polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />
</g>
)}
</svg>
</div>
);
}
function MaskValues({ editing, name, onSelect, points }) {
const handleClick = useCallback(() => {
onSelect(name);
}, [name, onSelect]);
return (
<div
className={`rounded border-gray-500 border-solid border p-2 hover:bg-gray-400 cursor-pointer ${
editing ? 'bg-gray-300' : ''
}`}
onclick={handleClick}
>
<Heading className="mb-4" size="sm">
{name}
</Heading>
<textarea className="select-all font-mono border-gray-300 text-gray-900 dark:text-gray-100 w-full" readonly>
{name === 'mask' ? 'poly,' : null}
{polylinePointsToPolyline(points)}
</textarea>
</div>
);
}
function distance([x0, y0], [x1, y1]) {
return Math.sqrt(Math.pow(x0 - x1, 2) + Math.pow(y0 - y1, 2));
}
function getPolylinePoints(polyline) {
if (!polyline) {
return;
}
return polyline
.replace('poly,', '')
.split(',')
.reduce((memo, point, i) => {
if (i % 2) {
memo[memo.length - 1].push(parseInt(point, 10));
} else {
memo.push([parseInt(point, 10)]);
}
return memo;
}, []);
}
function scalePolylinePoints(polylinePoints, scale) {
if (!polylinePoints) {
return;
}
return polylinePoints.map(([x, y]) => [Math.round(x * scale), Math.round(y * scale)]);
}
function polylinePointsToPolyline(polylinePoints) {
if (!polylinePoints) {
return;
}
return polylinePoints.reduce((memo, [x, y]) => `${memo}${x},${y},`, '').replace(/,$/, '');
}
const PolyPointRadius = 10;
function PolyPoint({ index, x, y, onDragend, onRemove }) {
const [hidden, setHidden] = useState(false);
const handleDragStart = useCallback(() => {
setHidden(true);
}, [setHidden]);
const handleDrag = useCallback(
(event) => {
const { offsetX, offsetY } = event;
onDragend(index, event.offsetX + x - PolyPointRadius, event.offsetY + y - PolyPointRadius);
},
[onDragend, index]
);
const handleDragEnd = useCallback(() => {
setHidden(false);
}, [setHidden]);
const handleRightClick = useCallback(
(event) => {
event.preventDefault();
onRemove(index);
},
[onRemove, index]
);
const handleClick = useCallback((event) => {
event.stopPropagation();
event.preventDefault();
}, []);
return (
<div
className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
draggable
onclick={handleClick}
oncontextmenu={handleRightClick}
ondragstart={handleDragStart}
ondrag={handleDrag}
ondragend={handleDragEnd}
/>
);
}

36
web/src/Cameras.jsx Normal file
View File

@ -0,0 +1,36 @@
import { h } from 'preact';
import Events from './Events';
import Heading from './components/Heading';
import { route } from 'preact-router';
import { useContext } from 'preact/hooks';
import { ApiHost, Config } from './context';
export default function Cameras() {
const config = useContext(Config);
if (!config.cameras) {
return <p>loading</p>;
}
return (
<div className="grid lg:grid-cols-2 md:grid-cols-1 gap-4">
{Object.keys(config.cameras).map((camera) => (
<Camera name={camera} />
))}
</div>
);
}
function Camera({ name }) {
const apiHost = useContext(ApiHost);
const href = `/cameras/${name}`;
return (
<div className="bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900">
<a className="dark:hover:text-gray-900" href={href}>
<Heading size="base">{name}</Heading>
<img className="w-full" src={`${apiHost}/api/${name}/latest.jpg`} />
</a>
</div>
);
}

16
web/src/Debug.jsx Normal file
View File

@ -0,0 +1,16 @@
import { h } from 'preact';
import { ApiHost } from './context';
import { useContext, useEffect, useState } from 'preact/hooks';
export default function Debug() {
const apiHost = useContext(ApiHost);
const [config, setConfig] = useState({});
useEffect(async () => {
const response = await fetch(`${apiHost}/api/stats`);
const data = response.ok ? await response.json() : {};
setConfig(data);
}, []);
return <pre>{JSON.stringify(config, null, 2)}</pre>;
}

40
web/src/Event.jsx Normal file
View File

@ -0,0 +1,40 @@
import { h } from 'preact';
import { ApiHost } from './context';
import Heading from './components/Heading';
import { useContext, useEffect, useState } from 'preact/hooks';
export default function Event({ eventId }) {
const apiHost = useContext(ApiHost);
const [data, setData] = useState(null);
useEffect(async () => {
const response = await fetch(`${apiHost}/api/events/${eventId}`);
const data = response.ok ? await response.json() : null;
setData(data);
}, [apiHost, eventId]);
if (!data) {
return (
<div>
<Heading>{eventId}</Heading>
<p>loading</p>
</div>
);
}
const datetime = new Date(data.start_time * 1000);
return (
<div>
<Heading>
{data.camera} {data.label} <span className="text-sm">{datetime.toLocaleString()}</span>
</Heading>
<img
src={`data:image/jpeg;base64,${data.thumbnail}`}
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
/>
<video className="w-96" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}

108
web/src/Events.jsx Normal file
View File

@ -0,0 +1,108 @@
import { h } from 'preact';
import { ApiHost } from './context';
import Heading from './components/Heading';
import Link from './components/Link';
import { route } from 'preact-router';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
export default function Events({ url } = {}) {
const apiHost = useContext(ApiHost);
const [events, setEvents] = useState([]);
const searchParams = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`).searchParams;
const searchParamsString = searchParams.toString();
useEffect(async () => {
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`);
const data = response.ok ? await response.json() : {};
setEvents(data);
}, [searchParamsString]);
return (
<div>
<Heading>Events</Heading>
<div className="flex flex-wrap space-x-2">
{Array.from(searchParams.keys()).map((filterKey) => (
<UnFilterable
paramName={filterKey}
searchParams={searchParamsString}
name={`${filterKey}: ${searchParams.get(filterKey)}`}
/>
))}
</div>
<Table>
<Thead>
<Tr>
<Th></Th>
<Th>Camera</Th>
<Th>Label</Th>
<Th>Score</Th>
<Th>Zones</Th>
<Th>Date</Th>
<Th>Start</Th>
<Th>End</Th>
</Tr>
</Thead>
<Tbody>
{events.map(
(
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
i
) => {
const start = new Date(parseInt(startTime * 1000, 10));
const end = new Date(parseInt(endTime * 1000, 10));
return (
<Tr key={id} index={i}>
<Td>
<a href={`/events/${id}`}>
<img className="w-32" src={`data:image/jpeg;base64,${thumbnail}`} />
</a>
</Td>
<Td>
<Filterable searchParams={searchParamsString} paramName="camera" name={camera} />
</Td>
<Td>
<Filterable searchParams={searchParamsString} paramName="label" name={label} />
</Td>
<Td>{(score * 100).toFixed(2)}%</Td>
<Td>
<ul>
{zones.map((zone) => (
<li>
<Filterable searchParams={searchParamsString} paramName="zone" name={zone} />
</li>
))}
</ul>
</Td>
<Td>{start.toLocaleDateString()}</Td>
<Td>{start.toLocaleTimeString()}</Td>
<Td>{end.toLocaleTimeString()}</Td>
</Tr>
);
}
)}
</Tbody>
</Table>
</div>
);
}
function Filterable({ searchParams, paramName, name }) {
const params = new URLSearchParams(searchParams);
params.set(paramName, name);
return <Link href={`?${params.toString()}`}>{name}</Link>;
}
function UnFilterable({ searchParams, paramName, name }) {
const params = new URLSearchParams(searchParams);
params.delete(paramName);
return (
<a
className="bg-gray-700 text-white px-3 py-1 rounded-md hover:bg-gray-300 hover:text-gray-900 dark:bg-gray-300 dark:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white"
href={`?${params.toString()}`}
>
{name}
</a>
);
}

87
web/src/Sidebar.jsx Normal file
View File

@ -0,0 +1,87 @@
import { h } from 'preact';
import Link from './components/Link';
import { Link as RouterLink } from 'preact-router/match';
import { useCallback, useState } from 'preact/hooks';
function HamburgerIcon() {
return (
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
<path
fill-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM9 15a1 1 0 011-1h6a1 1 0 110 2h-6a1 1 0 01-1-1z"
clip-rule="evenodd"
></path>
</svg>
);
}
function CloseIcon() {
return (
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
);
}
function NavLink({ className = '', href, text }) {
const external = href.startsWith('http');
const El = external ? Link : RouterLink;
const props = external ? { rel: 'noopener nofollow', target: '_blank' } : {};
return (
<El
activeClassName="bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200"
className={`block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark:bg-transparent dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline self-end ${className}`}
href={href}
{...props}
>
{text}
</El>
);
}
export default function Sidebar() {
const [open, setOpen] = useState(false);
const handleToggle = useCallback(() => {
setOpen(!open);
}, [open, setOpen]);
return (
<div className="flex flex-col w-full md:w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-700 flex-shrink-0">
<div className="flex-shrink-0 px-8 py-4 flex flex-row items-center justify-between">
<a
href="#"
className="text-lg font-semibold tracking-widest text-gray-900 uppercase rounded-lg dark:text-white focus:outline-none focus:shadow-outline"
>
Frigate
</a>
<button
className="rounded-lg md:hidden rounded-lg focus:outline-none focus:shadow-outline"
onClick={handleToggle}
>
{open ? <CloseIcon /> : <HamburgerIcon />}
</button>
</div>
<nav
className={`flex-col flex-grow md:block overflow-hidden px-4 pb-4 md:pb-0 md:overflow-y-auto ${
!open ? 'md:h-0 hidden' : ''
}`}
>
<NavLink href="/" text="Cameras" />
<NavLink href="/events" text="Events" />
<NavLink href="/debug" text="Debug" />
<hr className="border-solid border-gray-500 mt-2" />
<NavLink
className="self-end"
href="https://github.com/blakeblackshear/frigate/blob/master/README.md"
text="Documentation"
/>
<NavLink className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
</nav>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { h } from 'preact';
const noop = () => {};
export default function Button({ children, color, onClick, size }) {
return (
<div
role="button"
tabindex="0"
className="rounded bg-blue-500 text-white pl-4 pr-4 pt-2 pb-2 font-bold shadow hover:bg-blue-400 hover:shadow-lg cursor-pointer"
onClick={onClick || noop}
>
{children}
</div>
);
}

View File

@ -0,0 +1,9 @@
import { h } from 'preact';
export default function Heading({ children, className = '', size = '2xl' }) {
return (
<h1 className={`font-semibold tracking-widest text-gray-900 uppercase dark:text-white text-${size} ${className}`}>
{children}
</h1>
);
}

View File

@ -0,0 +1,9 @@
import { h } from 'preact';
export default function Link({ className, children, href, ...props }) {
return (
<a className={`text-blue-500 hover:underline ${className}`} href={href} {...props}>
{children}
</a>
);
}

View File

@ -0,0 +1,26 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
export default function Switch({ checked, label, id, onChange }) {
const handleChange = useCallback(
(event) => {
console.log(event.target.checked, !checked);
onChange(id, !checked);
},
[id, onChange, checked]
);
return (
<label for={id} className="flex items-center cursor-pointer">
<div className="relative">
<input id={id} type="checkbox" className="hidden" onChange={handleChange} checked={checked} />
<div className="toggle__line w-12 h-6 bg-gray-400 rounded-full shadow-inner" />
<div
className="transition-transform absolute w-6 h-6 bg-white rounded-full shadow-md inset-y-0 left-0"
style={checked ? 'transform: translateX(100%);' : 'transform: translateX(0%);'}
/>
</div>
<div className="ml-3 text-gray-700 font-medium dark:text-gray-200">{label}</div>
</label>
);
}

View File

@ -0,0 +1,29 @@
import { h } from 'preact';
export function Table({ children }) {
return <table className="table-auto border-collapse text-gray-900 dark:text-gray-200">{children}</table>;
}
export function Thead({ children }) {
return <thead className="">{children}</thead>;
}
export function Tbody({ children }) {
return <tbody className="">{children}</tbody>;
}
export function Tfoot({ children }) {
return <tfoot className="">{children}</tfoot>;
}
export function Tr({ children, index }) {
return <tr className={`${index % 2 ? 'bg-gray-200 ' : ''}`}>{children}</tr>;
}
export function Th({ children }) {
return <th className="border-b-2 border-gray-400 p-4 text-left">{children}</th>;
}
export function Td({ children }) {
return <td className="p-4">{children}</td>;
}

5
web/src/context/index.js Normal file
View File

@ -0,0 +1,5 @@
import { createContext } from 'preact';
export const Config = createContext({});
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || '');

3
web/src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

9
web/src/index.jsx Normal file
View File

@ -0,0 +1,9 @@
import App from './App';
import { h, render } from 'preact';
import 'preact/devtools';
import './index.css';
render(
<App />,
document.getElementById('root')
);

13
web/tailwind.config.js Normal file
View File

@ -0,0 +1,13 @@
'use strict';
module.exports = {
purge: ['./public/**/*.html', './src/**/*.jsx'],
darkMode: 'media',
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
};