Merge branch 'release-0.10.0' into datepicker

This commit is contained in:
Bernt Christian Egeland
2021-12-09 09:16:13 +01:00
110 changed files with 6925 additions and 8507 deletions
-1
View File
@@ -28,7 +28,6 @@ export default function App() {
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
+3 -2
View File
@@ -10,6 +10,7 @@ import NavigationDrawer, { Destination, Separator } from './components/Navigatio
export default function Sidebar() {
const { data: config } = useConfig();
const cameras = useMemo(() => Object.entries(config.cameras), [config]);
const { birdseye } = config;
return (
<NavigationDrawer header={<Header />}>
@@ -49,7 +50,7 @@ export default function Sidebar() {
) : null
}
</Match>
<Destination href="/birdseye" text="Birdseye" />
{birdseye?.enabled ? <Destination href="/birdseye" text="Birdseye" /> : null}
<Destination href="/events" text="Events" />
<Destination href="/debug" text="Debug" />
<Separator />
@@ -60,7 +61,7 @@ export default function Sidebar() {
<Separator />
</Fragment>
) : null}
<Destination className="self-end" href="https://blakeblackshear.github.io/frigate" text="Documentation" />
<Destination className="self-end" href="https://docs.frigate.video" text="Documentation" />
<Destination className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
</NavigationDrawer>
);
+6 -6
View File
@@ -121,12 +121,12 @@ describe('MqttProvider', () => {
</MqttProvider>
);
await screen.findByTestId('data');
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
});
});
+5 -18
View File
@@ -18,7 +18,7 @@ const initialState = Object.freeze({
const Api = createContext(initialState);
function reducer(state, { type, payload, meta }) {
function reducer(state, { type, payload }) {
switch (type) {
case 'REQUEST': {
const { url, fetchId } = payload;
@@ -36,22 +36,9 @@ function reducer(state, { type, payload, meta }) {
}
case 'DELETE': {
const { eventId } = payload;
return produce(state, (draftState) => {
Object.keys(draftState.queries).map((url, index) => {
// If data has no array length then just return state.
if (!('data' in draftState.queries[url]) || !draftState.queries[url].data.length) return state;
//Find the index to remove
const removeIndex = draftState.queries[url].data.map((event) => event.id).indexOf(eventId);
if (removeIndex === -1) return state;
// We need to keep track of deleted items, This will be used to re-calculate "ReachEnd" for auto load new events. Events.jsx
const totDeleted = state.queries[url].deleted || 0;
// Splice the deleted index.
draftState.queries[url].data.splice(removeIndex, 1);
draftState.queries[url].deleted = totDeleted + 1;
Object.keys(draftState.queries).map((url) => {
draftState.queries[url].deletedId = eventId;
});
});
}
@@ -111,9 +98,9 @@ export function useFetch(url, fetchId) {
const data = state.queries[url].data || null;
const status = state.queries[url].status;
const deleted = state.queries[url].deleted || 0;
const deletedId = state.queries[url].deletedId || 0;
return { data, status, deleted };
return { data, status, deletedId };
}
export function useDelete() {
+3 -3
View File
@@ -42,9 +42,9 @@ export function MqttProvider({
useEffect(() => {
Object.keys(config.cameras).forEach((camera) => {
const { name, record, detect, snapshots } = config.cameras[camera];
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF' });
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' });
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' });
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF', retain: true });
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF', retain: true });
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF', retain: true });
});
}, [config]);
+2 -1
View File
@@ -37,7 +37,8 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
return (
<div
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
id="appbar"
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
!show ? '-translate-y-full' : 'translate-y-0'
} ${!atZero ? 'shadow-sm' : ''}`}
data-testid="appbar"
+1 -1
View File
@@ -19,7 +19,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
<div
data-testid="scrim"
key="scrim"
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
>
<div
role="modal"
+2 -2
View File
@@ -3,7 +3,7 @@ import { baseUrl } from '../api/baseUrl';
import { useRef, useEffect } from 'preact/hooks';
import JSMpeg from '@cycjimmy/jsmpeg-player';
export default function JSMpegPlayer({ camera }) {
export default function JSMpegPlayer({ camera, width, height }) {
const playerRef = useRef();
const url = `${baseUrl.replace(/^http/, 'ws')}/live/${camera}`
@@ -32,6 +32,6 @@ export default function JSMpegPlayer({ camera }) {
}, [url]);
return (
<div ref={playerRef} class="jsmpeg" />
<div ref={playerRef} class="jsmpeg" style={`max-height: ${height}px; max-width: ${width}px`} />
);
}
+2 -2
View File
@@ -21,7 +21,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
events={recording.events}
selected={recording.date === selectedDate}
>
{recording.recordings.map((item, i) => (
{recording.recordings.slice().reverse().map((item, i) => (
<div className="mb-2 w-full">
<div
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
@@ -35,7 +35,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
</div>
<div className="flex-1 text-right">{item.events.length} Events</div>
</div>
{item.events.map((event) => (
{item.events.slice().reverse().map((event) => (
<EventCard camera={camera} event={event} delay={item.delay} />
))}
</div>
+6 -5
View File
@@ -14,9 +14,9 @@ export function Thead({ children, className, ...attrs }) {
);
}
export function Tbody({ children, className, ...attrs }) {
export function Tbody({ children, className, reference, ...attrs }) {
return (
<tbody className={className} {...attrs}>
<tbody ref={reference} className={className} {...attrs}>
{children}
</tbody>
);
@@ -30,9 +30,10 @@ export function Tfoot({ children, className = '', ...attrs }) {
);
}
export function Tr({ children, className = '', ...attrs }) {
export function Tr({ children, className = '', reference, ...attrs }) {
return (
<tr
ref={reference}
className={`border-b border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 ${className}`}
{...attrs}
>
@@ -49,9 +50,9 @@ export function Th({ children, className = '', colspan, ...attrs }) {
);
}
export function Td({ children, className = '', colspan, ...attrs }) {
export function Td({ children, className = '', reference, colspan, ...attrs }) {
return (
<td className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
<td ref={reference} className={`p-2 px-1 lg:p-4 ${className}`} colSpan={colspan} {...attrs}>
{children}
</td>
);
+1 -1
View File
@@ -88,7 +88,7 @@ export default function VideoPlayer({ children, options, seekOptions = {}, onRea
return (
<div data-vjs-player>
<video ref={playerRef} className="video-js vjs-default-skin" controls playsinline />
<video ref={playerRef} className="small-player video-js vjs-default-skin" controls playsinline />
{children}
</div>
);
+22
View File
@@ -0,0 +1,22 @@
import { useEffect, useRef } from 'preact/hooks';
// https://stackoverflow.com/a/54292872/2693528
export const useClickOutside = (callback) => {
const callbackRef = useRef(); // initialize mutable ref, which stores callback
const innerRef = useRef(); // returned to client, who marks "border" element
// update cb on each render, so second useEffect has access to current value
useEffect(() => {
callbackRef.current = callback;
});
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
function handleClick(e) {
if (innerRef.current && callbackRef.current && !innerRef.current.contains(e.target)) callbackRef.current(e);
}
}, []);
return innerRef; // convenience for client (doesn't need to init ref himself)
};
+25
View File
@@ -0,0 +1,25 @@
import { useState, useCallback } from 'preact/hooks';
const defaultSearchString = (limit) => `include_thumbnails=0&limit=${limit}`;
export const useSearchString = (limit, searchParams) => {
const { searchParams: initialSearchParams } = new URL(window.location);
const _searchParams = searchParams || initialSearchParams.toString();
const [searchString, changeSearchString] = useState(`${defaultSearchString(limit)}&${_searchParams}`);
const setSearchString = useCallback(
(limit, searchString) => {
changeSearchString(`${defaultSearchString(limit)}&${searchString}`);
},
[changeSearchString]
);
const removeDefaultSearchKeys = useCallback((searchParams) => {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
searchParams.delete('before');
}, []);
return { searchString, setSearchString, removeDefaultSearchKeys };
};
+13
View File
@@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Close({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
);
}
export default memo(Close);
+24
View File
@@ -29,3 +29,27 @@
.jsmpeg canvas {
position: static !important;
}
/*
Event.js
Maintain aspect ratio and scale down the video container
Could not find a proper tailwind css.
*/
.outer-max-width {
max-width: 70%;
}
/*
Hide some videoplayer controls on mobile devices to
align the video player and bottom control bar properly.
*/
@media only screen and (max-width: 700px) {
.small-player .vjs-time-control,
.small-player .vjs-time-divider {
display: none;
}
div.vjs-control-bar > .skip-back.skip-5,
div.vjs-control-bar > .skip-forward.skip-10 {
display: none;
}
}
+2 -1
View File
@@ -21,6 +21,7 @@ export default function Camera({ camera }) {
const [viewMode, setViewMode] = useState('live');
const cameraConfig = config?.cameras[camera];
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(
@@ -87,7 +88,7 @@ export default function Camera({ camera }) {
player = (
<Fragment>
<div>
<JSMpegPlayer camera={camera} />
<JSMpegPlayer camera={camera} width={liveWidth} height={cameraConfig.live.height} />
</div>
</Fragment>
);
+148 -94
View File
@@ -1,25 +1,76 @@
import { h, Fragment } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { route } from 'preact-router';
import { useCallback, useState, useEffect } from 'preact/hooks';
import Link from '../components/Link';
import ActivityIndicator from '../components/ActivityIndicator';
import Button from '../components/Button';
import ArrowDown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup';
import Clip from '../icons/Clip';
import Close from '../icons/Close';
import Delete from '../icons/Delete';
import Snapshot from '../icons/Snapshot';
import Dialog from '../components/Dialog';
import Heading from '../components/Heading';
import Link from '../components/Link';
import VideoPlayer from '../components/VideoPlayer';
import { FetchStatus, useApiHost, useEvent, useDelete } from '../api';
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
import { FetchStatus, useApiHost, useEvent, useDelete } from '../api';
export default function Event({ eventId }) {
const ActionButtonGroup = ({ className, handleClickDelete, close }) => (
<div className={`space-y-2 space-x-2 sm:space-y-0 xs:space-x-4 ${className}`}>
<Button className="xs:w-auto" color="red" onClick={handleClickDelete}>
<Delete className="w-6" /> Delete event
</Button>
<Button color="gray" className="xs:w-auto" onClick={() => close()}>
<Close className="w-6" /> Close
</Button>
</div>
);
const DownloadButtonGroup = ({ className, apiHost, eventId }) => (
<span className={`space-y-2 sm:space-y-0 space-x-0 sm:space-x-4 ${className}`}>
<Button
className="w-full sm:w-auto"
color="blue"
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
download
>
<Clip className="w-6" /> Download Clip
</Button>
<Button
className="w-full sm:w-auto"
color="blue"
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
download
>
<Snapshot className="w-6" /> Download Snapshot
</Button>
</span>
);
export default function Event({ eventId, close, scrollRef }) {
const apiHost = useApiHost();
const { data, status } = useEvent(eventId);
const [showDialog, setShowDialog] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [shouldScroll, setShouldScroll] = useState(true);
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
const setDeleteEvent = useDelete();
useEffect(() => {
// Scroll event into view when component has been mounted.
if (shouldScroll && scrollRef && scrollRef[eventId]) {
scrollRef[eventId].scrollIntoView();
setShouldScroll(false);
}
return () => {
// When opening new event window, the previous one will sometimes cause the
// navbar to be visible, hence the "hide nav" code bellow.
// Navbar will be hided if we add the - translate - y - full class.appBar.js
const element = document.getElementById('appbar');
if (element) element.classList.add('-translate-y-full');
};
}, [data, scrollRef, eventId, shouldScroll]);
const handleClickDelete = () => {
setShowDialog(true);
};
@@ -40,7 +91,6 @@ export default function Event({ eventId }) {
if (success) {
setDeleteStatus(FetchStatus.LOADED);
setShowDialog(false);
route('/events', true);
}
}, [eventId, setShowDialog, setDeleteEvent]);
@@ -49,17 +99,27 @@ export default function Event({ eventId }) {
}
const startime = new Date(data.start_time * 1000);
const endtime = new Date(data.end_time * 1000);
const endtime = data.end_time ? new Date(data.end_time * 1000) : null;
return (
<div className="space-y-4">
<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>
<div className="flex md:flex-row justify-between flex-wrap flex-col">
<div className="space-y-2 xs:space-y-0 sm:space-x-4">
<DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="hidden sm:inline" />
<Button className="w-full sm:w-auto" onClick={() => setShowDetails(!showDetails)}>
{showDetails ? (
<Fragment>
<ArrowDropup className="w-6" />
Hide event Details
</Fragment>
) : (
<Fragment>
<ArrowDown className="w-6" />
Show event Details
</Fragment>
)}
</Button>
</div>
<ActionButtonGroup handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
{showDialog ? (
<Dialog
onDismiss={handleDismissDeleteDialog}
@@ -78,86 +138,80 @@ export default function Event({ eventId }) {
/>
) : null}
</div>
<div>
{showDetails ? (
<Table class="w-full">
<Thead>
<Th>Key</Th>
<Th>Value</Th>
</Thead>
<Tbody>
<Tr>
<Td>Camera</Td>
<Td>
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
</Td>
</Tr>
<Tr index={1}>
<Td>Timeframe</Td>
<Td>
{startime.toLocaleString()}{endtime === null ? ` ${endtime.toLocaleString()}`:''}
</Td>
</Tr>
<Tr>
<Td>Score</Td>
<Td>{(data.top_score * 100).toFixed(2)}%</Td>
</Tr>
<Tr index={1}>
<Td>Zones</Td>
<Td>{data.zones.join(', ')}</Td>
</Tr>
</Tbody>
</Table>
) : null}
</div>
<Table class="w-full">
<Thead>
<Th>Key</Th>
<Th>Value</Th>
</Thead>
<Tbody>
<Tr>
<Td>Camera</Td>
<Td>
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link>
</Td>
</Tr>
<Tr index={1}>
<Td>Timeframe</Td>
<Td>
{startime.toLocaleString()} {endtime.toLocaleString()}
</Td>
</Tr>
<Tr>
<Td>Score</Td>
<Td>{(data.top_score * 100).toFixed(2)}%</Td>
</Tr>
<Tr index={1}>
<Td>Zones</Td>
<Td>{data.zones.join(', ')}</Td>
</Tr>
</Tbody>
</Table>
{data.has_clip ? (
<Fragment>
<Heading size="lg">Clip</Heading>
<VideoPlayer
options={{
sources: [
{
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
poster: data.has_snapshot
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
: `data:image/jpeg;base64,${data.thumbnail}`,
}}
seekOptions={{ forward: 10, back: 5 }}
onReady={(player) => {}}
/>
<div className="text-center">
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
download
>
<Clip className="w-6" /> Download Clip
</Button>
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
download
>
<Snapshot className="w-6" /> Download Snapshot
</Button>
</div>
</Fragment>
) : (
<Fragment>
<Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
<img
src={
data.has_snapshot
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
: `data:image/jpeg;base64,${data.thumbnail}`
}
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
/>
</Fragment>
)}
<div className="outer-max-width xs:m-auto">
<div className="pt-5 relative pb-20 w-screen xs:w-full">
{data.has_clip ? (
<Fragment>
<Heading size="lg">Clip</Heading>
<VideoPlayer
options={{
preload: 'none',
sources: [
{
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
poster: data.has_snapshot
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
: `data:image/jpeg;base64,${data.thumbnail}`,
}}
seekOptions={{ forward: 10, back: 5 }}
onReady={() => {}}
/>
</Fragment>
) : (
<Fragment>
<Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
<img
src={
data.has_snapshot
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
: `data:image/jpeg;base64,${data.thumbnail}`
}
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
/>
</Fragment>
)}
</div>
</div>
<div className="space-y-2 xs:space-y-0">
<DownloadButtonGroup apiHost={apiHost} eventId={eventId} className="block sm:hidden" />
<ActionButtonGroup handleClickDelete={handleClickDelete} close={close} className="block sm:hidden" />
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import { h } from 'preact';
import Select from '../../../components/Select';
import { useCallback, useMemo } from 'preact/hooks';
const Filter = ({ onChange, searchParams, paramName, options }) => {
const handleSelect = useCallback(
(key) => {
const newParams = new URLSearchParams(searchParams.toString());
if (key !== 'all') {
newParams.set(paramName, key);
} else {
newParams.delete(paramName);
}
onChange(newParams);
},
[searchParams, paramName, onChange]
);
const selectOptions = useMemo(() => ['all', ...options], [options]);
return (
<Select
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
onChange={handleSelect}
options={selectOptions}
selected={searchParams.get(paramName) || 'all'}
/>
);
};
export default Filter;
@@ -0,0 +1,32 @@
import { h } from 'preact';
import { useCallback, useMemo } from 'preact/hooks';
import Link from '../../../components/Link';
import { route } from 'preact-router';
const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => {
const href = useMemo(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
removeDefaultSearchKeys(params);
return `${pathname}?${params.toString()}`;
}, [searchParams, paramName, pathname, name, removeDefaultSearchKeys]);
const handleClick = useCallback(
(event) => {
event.preventDefault();
route(href, true);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
onFilter(params);
},
[href, searchParams, onFilter, paramName, name]
);
return (
<Link href={href} onclick={handleClick}>
{name}
</Link>
);
};
export default Filterable;
@@ -0,0 +1,39 @@
import { h } from 'preact';
import Filter from './filter';
import { useConfig } from '../../../api';
import { useMemo } from 'preact/hooks';
const Filters = ({ onChange, searchParams }) => {
const { data } = useConfig();
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
const zones = useMemo(
() =>
Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
[data]
);
const labels = useMemo(() => {
return Object.values(data.cameras)
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
return memo;
}, data.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [data]);
return (
<div className="flex space-x-4">
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
</div>
);
};
export default Filters;
@@ -0,0 +1,3 @@
export { default as TableHead } from './tableHead';
export { default as TableRow } from './tableRow';
export { default as Filters } from './filters';
@@ -0,0 +1,18 @@
import { h } from 'preact';
import { Thead, Th, Tr } from '../../../components/Table';
const TableHead = () => (
<Thead>
<Tr>
<Th />
<Th>Camera</Th>
<Th>Label</Th>
<Th>Score</Th>
<Th>Zones</Th>
<Th>Date</Th>
<Th>Start</Th>
<Th>End</Th>
</Tr>
</Thead>
);
export default TableHead;
@@ -0,0 +1,119 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useState, useMemo } from 'preact/hooks';
import { Tr, Td, Tbody } from '../../../components/Table';
import Filterable from './filterable';
import Event from '../../Event';
import { useSearchString } from '../../../hooks/useSearchString';
import { useClickOutside } from '../../../hooks/useClickOutside';
const EventsRow = memo(
({
id,
apiHost,
start_time: startTime,
end_time: endTime,
scrollToRef,
lastRowRef,
handleFilter,
pathname,
limit,
camera,
label,
top_score: score,
zones,
}) => {
const [viewEvent, setViewEvent] = useState(null);
const { searchString, removeDefaultSearchKeys } = useSearchString(limit);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
const innerRef = useClickOutside(() => {
setViewEvent(null);
});
const viewEventHandler = useCallback(
(id) => {
//Toggle event view
if (viewEvent === id) return setViewEvent(null);
//Set event id to be rendered.
setViewEvent(id);
},
[viewEvent]
);
const start = new Date(parseInt(startTime * 1000, 10));
const end = endTime ? new Date(parseInt(endTime * 1000, 10)) : null;
return (
<Tbody reference={innerRef}>
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
<Td className="w-40">
<a
onClick={() => viewEventHandler(id)}
ref={lastRowRef}
data-start-time={startTime}
// data-reached-end={reachedEnd} <-- Enable this will cause all events to re-render when reaching end.
>
<img
width="150"
height="150"
className="cursor-pointer"
style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/>
</a>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="camera"
name={camera}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="label"
name={label}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</Td>
<Td>{(score * 100).toFixed(2)}%</Td>
<Td>
<ul>
{zones.map((zone) => (
<li>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchString}
paramName="zone"
name={zone}
removeDefaultSearchKeys={removeDefaultSearchKeys}
/>
</li>
))}
</ul>
</Td>
<Td>{start.toLocaleDateString()}</Td>
<Td>{start.toLocaleTimeString()}</Td>
<Td>{end === null ? 'In progress' : end.toLocaleTimeString()}</Td>
</Tr>
{viewEvent === id ? (
<Tr className="border-b-1">
<Td colSpan="8" reference={(el) => (scrollToRef[id] = el)}>
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
</Td>
</Tr>
) : null}
</Tbody>
);
}
);
export default EventsRow;
+107
View File
@@ -0,0 +1,107 @@
import { h } from 'preact';
import ActivityIndicator from '../../components/ActivityIndicator';
import Heading from '../../components/Heading';
import { TableHead, Filters, TableRow } from './components';
import { route } from 'preact-router';
import { FetchStatus, useApiHost, useEvents } from '../../api';
import { Table, Tfoot, Tr, Td } from '../../components/Table';
import { useCallback, useEffect, useMemo, useReducer } from 'preact/hooks';
import { reducer, initialState } from './reducer';
import { useSearchString } from '../../hooks/useSearchString';
import { useIntersectionObserver } from '../../hooks';
const API_LIMIT = 25;
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
const apiHost = useApiHost();
const { searchString, setSearchString, removeDefaultSearchKeys } = useSearchString(limit);
const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
const { data, status, deletedId } = useEvents(searchString);
const scrollToRef = useMemo(() => Object, []);
useEffect(() => {
if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
}
if (data && Array.isArray(data) && data.length + deleted < limit) {
dispatch({ type: 'REACHED_END', meta: { searchString } });
}
if (deletedId) {
dispatch({ type: 'DELETE_EVENT', deletedId });
}
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
const [entry, setIntersectNode] = useIntersectionObserver();
useEffect(() => {
if (entry && entry.isIntersecting) {
const { startTime } = entry.target.dataset;
const { searchParams } = new URL(window.location);
searchParams.set('before', parseFloat(startTime) - 0.0001);
setSearchString(limit, searchParams.toString());
}
}, [entry, limit, setSearchString]);
const lastCellRef = useCallback(
(node) => {
if (node !== null && !reachedEnd) {
setIntersectNode(node);
}
},
[setIntersectNode, reachedEnd]
);
const handleFilter = useCallback(
(searchParams) => {
dispatch({ type: 'RESET' });
removeDefaultSearchKeys(searchParams);
setSearchString(limit, searchParams.toString());
route(`${pathname}?${searchParams.toString()}`);
},
[limit, pathname, setSearchString, removeDefaultSearchKeys]
);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
const RenderTableRow = useCallback(
(props) => (
<TableRow
key={props.id}
apiHost={apiHost}
scrollToRef={scrollToRef}
pathname={pathname}
limit={API_LIMIT}
handleFilter={handleFilter}
{...props}
/>
),
[apiHost, handleFilter, pathname, scrollToRef]
);
return (
<div className="space-y-4 w-full">
<Heading>Events</Heading>
<Filters onChange={handleFilter} searchParams={searchParams} />
<div className="min-w-0 overflow-auto">
<Table className="min-w-full table-fixed">
<TableHead />
{events.map((props, idx) => {
const lastRowRef = idx === events.length - 1 ? lastCellRef : undefined;
return <RenderTableRow {...props} lastRowRef={lastRowRef} idx={idx} />;
})}
<Tfoot>
<Tr>
<Td className="text-center p-4" colSpan="8">
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
</Td>
</Tr>
</Tfoot>
</Table>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import produce from 'immer';
export const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
export const reducer = (state = initialState, action) => {
switch (action.type) {
case 'DELETE_EVENT': {
const { deletedId } = action;
return produce(state, (draftState) => {
const idx = draftState.events.findIndex((e) => e.id === deletedId);
if (idx === -1) return state;
draftState.events.splice(idx, 1);
draftState.deleted++;
});
}
case 'APPEND_EVENTS': {
const {
meta: { searchString },
payload,
} = action;
return produce(state, (draftState) => {
draftState.searchStrings[searchString] = true;
draftState.events.push(...payload);
draftState.deleted = 0;
});
}
case 'REACHED_END': {
const {
meta: { searchString },
} = action;
return produce(state, (draftState) => {
draftState.reachedEnd = true;
draftState.searchStrings[searchString] = true;
});
}
case 'RESET':
return initialState;
default:
return state;
}
};
+1 -1
View File
@@ -13,7 +13,7 @@ describe('Camera Route', () => {
mockSetOptions = jest.fn();
mockUsePersistence = jest.spyOn(Context, 'usePersistence').mockImplementation(() => [{}, mockSetOptions]);
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
data: { cameras: { front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } } } },
data: { cameras: { front: { name: 'front', detect: {width: 1280, height: 720}, live: {height: 720}, objects: { track: ['taco', 'cat', 'dog'] } } } },
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
jest.spyOn(AutoUpdatingCameraImage, 'default').mockImplementation(({ searchParams }) => {
-1
View File
@@ -57,7 +57,6 @@ describe('Event Route', () => {
const mockEvent = {
camera: 'front',
end_time: 1613257337.841237,
false_positive: false,
has_clip: true,
has_snapshot: true,
id: '1613257326.237365-83cgl2',
-1
View File
@@ -71,7 +71,6 @@ describe('Events Route', () => {
const mockEvents = new Array(12).fill(null).map((v, i) => ({
end_time: 1613257337 + i,
false_positive: false,
has_clip: true,
has_snapshot: true,
id: i,
+1 -1
View File
@@ -19,7 +19,7 @@ export async function getBirdseye(url, cb, props) {
}
export async function getEvents(url, cb, props) {
const module = await import('./Events.jsx');
const module = await import('./Events');
return module.default;
}