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

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