swr events refactor

This commit is contained in:
Blake Blackshear
2022-02-26 13:11:00 -06:00
parent 4bae3993da
commit 1c9ba11e07
76 changed files with 29753 additions and 9109 deletions

View File

@@ -10,18 +10,19 @@ import Switch from '../components/Switch';
import ButtonsTabbed from '../components/ButtonsTabbed';
import { usePersistence } from '../context';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useApiHost, useConfig } from '../api';
import { useApiHost } from '../api';
import useSWR from 'swr';
const emptyObject = Object.freeze({});
export default function Camera({ camera }) {
const { data: config } = useConfig();
const { data: config } = useSWR('config');
const apiHost = useApiHost();
const [showSettings, setShowSettings] = useState(false);
const [viewMode, setViewMode] = useState('live');
const cameraConfig = config?.cameras[camera];
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(

View File

@@ -5,10 +5,11 @@ import Heading from '../components/Heading.jsx';
import Switch from '../components/Switch.jsx';
import { useResizeObserver } from '../hooks';
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
import { useApiHost, useConfig } from '../api';
import { useApiHost } from '../api';
import useSWR from 'swr';
export default function CameraMasks({ camera, url }) {
const { data: config } = useConfig();
export default function CameraMasks({ camera }) {
const { data: config } = useSWR('config');
const apiHost = useApiHost();
const imageRef = useRef(null);
const [snap, setSnap] = useState(true);
@@ -20,10 +21,7 @@ export default function CameraMasks({ camera, url }) {
zones,
} = cameraConfig;
const {
width,
height,
} = cameraConfig.detect;
const { width, height } = cameraConfig.detect;
const [{ width: scaledWidth }] = useResizeObserver(imageRef);
const imageScale = scaledWidth / width;
@@ -100,7 +98,7 @@ export default function CameraMasks({ camera, url }) {
const handleCopyMotionMasks = useCallback(async () => {
await window.navigator.clipboard.writeText(` motion:
mask:
${motionMaskPoints.map((mask, i) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
}, [motionMaskPoints]);
// Zone methods
@@ -273,16 +271,16 @@ ${Object.keys(objectMaskPoints)
);
}
function maskYamlKeyPrefix(points) {
function maskYamlKeyPrefix() {
return ' - ';
}
function zoneYamlKeyPrefix(points, key) {
function zoneYamlKeyPrefix(_points, key) {
return ` ${key}:
coordinates: `;
}
function objectYamlKeyPrefix(points, key, subkey) {
function objectYamlKeyPrefix() {
return ' - ';
}
@@ -364,6 +362,7 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
? null
: scaledPoints.map(([x, y], i) => (
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
@@ -466,6 +465,7 @@ function MaskValues({
) : null}
{points[mainkey].map((item, subkey) => (
<Item
key={subkey}
mainkey={mainkey}
subkey={subkey}
editing={editing}
@@ -481,6 +481,7 @@ function MaskValues({
}
return (
<Item
key={mainkey}
mainkey={mainkey}
editing={editing}
handleAdd={onAdd ? handleAdd : undefined}
@@ -497,7 +498,7 @@ function MaskValues({
);
}
function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, handleAdd, handleRemove, yamlKeyPrefix }) {
function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, _handleAdd, handleRemove, yamlKeyPrefix }) {
return (
<span
data-key={mainkey}

View File

@@ -2,14 +2,14 @@ import { h, Fragment } from 'preact';
import JSMpegPlayer from '../components/JSMpegPlayer';
import Heading from '../components/Heading';
import { useState } from 'preact/hooks';
import { useConfig } from '../api';
import useSWR from 'swr';
import { Tabs, TextTab } from '../components/Tabs';
import { LiveChip } from '../components/LiveChip';
import { DebugCamera } from '../components/DebugCamera';
import HistoryViewer from '../components/HistoryViewer/HistoryViewer.tsx';
export default function Camera({ camera }) {
const { data: config } = useConfig();
const { data: config } = useSWR('config');
const [playerType, setPlayerType] = useState('live');

View File

@@ -6,30 +6,33 @@ import ClipIcon from '../icons/Clip';
import MotionIcon from '../icons/Motion';
import SnapshotIcon from '../icons/Snapshot';
import { useDetectState, useRecordingsState, useSnapshotsState } from '../api/mqtt';
import { useConfig, FetchStatus } from '../api';
import { useMemo } from 'preact/hooks';
import useSWR from 'swr';
export default function Cameras() {
const { data: config, status } = useConfig();
const { data: config } = useSWR('config');
return status !== FetchStatus.LOADED ? (
return !config ? (
<ActivityIndicator />
) : (
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4">
{Object.entries(config.cameras).map(([camera, conf]) => (
<Camera name={camera} conf={conf} />
<Camera key={camera} name={camera} conf={conf} />
))}
</div>
);
}
function Camera({ name, conf }) {
function Camera({ name }) {
const { payload: detectValue, send: sendDetect } = useDetectState(name);
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const href = `/cameras/${name}`;
const buttons = useMemo(() => {
return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }];
return [
{ name: 'Events', href: `/events?camera=${name}` },
{ name: 'Recordings', href: `/recording/${name}` },
];
}, [name]);
const icons = useMemo(
() => [

View File

@@ -4,21 +4,21 @@ import Button from '../components/Button';
import Heading from '../components/Heading';
import Link from '../components/Link';
import { useMqtt } from '../api/mqtt';
import { useConfig, useStats } from '../api';
import useSWR from 'swr';
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
import { useCallback } from 'preact/hooks';
const emptyObject = Object.freeze({});
export default function Debug() {
const { data: config } = useConfig();
const { data: config } = useSWR('config');
const {
value: { payload: stats },
} = useMqtt('stats');
const { data: initialStats } = useStats();
const { data: initialStats } = useSWR('stats');
const { detectors, service = {}, detection_fps, ...cameras } = stats || initialStats || emptyObject;
const { detectors, service = {}, ...cameras } = stats || initialStats || emptyObject;
const detectorNames = Object.keys(detectors || emptyObject);
const detectorDataKeys = Object.keys(detectors ? detectors[detectorNames[0]] : emptyObject);
@@ -50,13 +50,13 @@ export default function Debug() {
<Tr>
<Th>detector</Th>
{detectorDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
<Th key={name}>{name.replace('_', ' ')}</Th>
))}
</Tr>
</Thead>
<Tbody>
{detectorNames.map((detector, i) => (
<Tr index={i}>
<Tr key={i} index={i}>
<Td>{detector}</Td>
{detectorDataKeys.map((name) => (
<Td key={`${name}-${detector}`}>{detectors[detector][name]}</Td>
@@ -73,13 +73,13 @@ export default function Debug() {
<Tr>
<Th>camera</Th>
{cameraDataKeys.map((name) => (
<Th>{name.replace('_', ' ')}</Th>
<Th key={name}>{name.replace('_', ' ')}</Th>
))}
</Tr>
</Thead>
<Tbody>
{cameraNames.map((camera, i) => (
<Tr index={i}>
<Tr key={i} index={i}>
<Td>
<Link href={`/cameras/${camera}`}>{camera}</Link>
</Td>

View File

@@ -1,241 +0,0 @@
import { h, Fragment } from 'preact';
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 StarRecording from '../icons/StarRecording';
import Delete from '../icons/Delete';
import Snapshot from '../icons/Snapshot';
import Heading from '../components/Heading';
import VideoPlayer from '../components/VideoPlayer';
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api';
import Prompt from '../components/Prompt';
const ActionButtonGroup = ({ className, isRetained, handleClickRetain, 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={isRetained ? 'red' : 'yellow'} onClick={handleClickRetain}>
<StarRecording className="w-6" />
{isRetained ? ('Un-retain event') : ('Retain event')}
</Button>
<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 [isRetained, setIsRetained] = useState(false);
const setRetainEvent = useRetain();
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 handleClickRetain = useCallback(async () => {
let success;
try {
success = await setRetainEvent(eventId, !isRetained);
if (success) {
setIsRetained(!isRetained);
// Need to reload page otherwise retain button state won't stick if event is collapsed and re-opened.
window.location.reload();
}
} catch (e) {
}
}, [eventId, isRetained, setRetainEvent]);
const handleClickDelete = () => {
setShowDialog(true);
};
const handleDismissDeleteDialog = () => {
setShowDialog(false);
};
const handleClickDeleteDialog = useCallback(async () => {
let success;
try {
success = await setDeleteEvent(eventId);
setDeleteStatus(success ? FetchStatus.LOADED : FetchStatus.ERROR);
} catch (e) {
setDeleteStatus(FetchStatus.ERROR);
}
if (success) {
setDeleteStatus(FetchStatus.LOADED);
setShowDialog(false);
}
}, [eventId, setShowDialog, setDeleteEvent]);
if (status !== FetchStatus.LOADED) {
return <ActivityIndicator />;
}
setIsRetained(data.retain_indefinitely);
const startime = new Date(data.start_time * 1000);
const endtime = data.end_time ? new Date(data.end_time * 1000) : null;
return (
<div className="space-y-4">
<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 isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
{showDialog ? (
<Prompt
onDismiss={handleDismissDeleteDialog}
title="Delete Event?"
text={
deleteStatus === FetchStatus.ERROR
? 'An error occurred, please try again.'
: '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>
<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>
<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 isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="block sm:hidden" />
</div>
</div>
);
}

375
web/src/routes/Events.jsx Normal file
View File

@@ -0,0 +1,375 @@
import { h, Fragment } from 'preact';
import { route } from 'preact-router';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import { useApiHost } from '../api';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import axios from 'axios';
import { useState, useRef, useCallback, useMemo } from 'preact/hooks';
import VideoPlayer from '../components/VideoPlayer';
import { StarRecording } from '../icons/StarRecording';
import { Snapshot } from '../icons/Snapshot';
import { Clip } from '../icons/Clip';
import { Zone } from '../icons/Zone';
import { Camera } from '../icons/Camera';
import { Delete } from '../icons/Delete';
import { Download } from '../icons/Download';
import Menu, { MenuItem } from '../components/Menu';
import CalendarIcon from '../icons/Calendar';
import Calendar from '../components/Calendar';
const API_LIMIT = 25;
const daysAgo = (num) => {
let date = new Date();
date.setDate(date.getDate() - num);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
};
const monthsAgo = (num) => {
let date = new Date();
date.setMonth(date.getMonth() - num);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
};
export default function Events({ path, ...props }) {
const apiHost = useApiHost();
const [searchParams, setSearchParams] = useState({
before: null,
after: null,
camera: props.camera ?? 'all',
label: props.label ?? 'all',
zone: props.zone ?? 'all',
});
const [viewEvent, setViewEvent] = useState();
const [downloadEvent, setDownloadEvent] = useState({ id: null, has_clip: false, has_snapshot: false });
const [showDownloadMenu, setShowDownloadMenu] = useState();
const [showDatePicker, setShowDatePicker] = useState();
const [showCalendar, setShowCalendar] = useState();
const eventsFetcher = (path, params) => {
params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
return axios.get(path, { params }).then((res) => res.data);
};
const getKey = (index, prevData) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = { ...searchParams, before: lastDate };
return ['events', pagedParams];
}
return ['events', searchParams];
};
const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
const { data: config } = useSWR('config');
const cameras = useMemo(() => Object.keys(config.cameras), [config]);
const zones = useMemo(
() =>
Object.values(config.cameras)
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera.zones));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
[config]
);
const labels = useMemo(() => {
return Object.values(config.cameras)
.reduce((memo, camera) => {
memo = memo.concat(camera.objects?.track || []);
return memo;
}, config.objects?.track || [])
.filter((value, i, self) => self.indexOf(value) === i);
}, [config]);
const onSave = async (e, eventId, save) => {
e.stopPropagation();
let response;
if (save) {
response = await axios.post(`events/${eventId}/retain`);
} else {
response = await axios.delete(`events/${eventId}/retain`);
}
if (response.status === 200) {
mutate();
}
};
const onDelete = async (e, eventId) => {
e.stopPropagation();
const response = await axios.delete(`events/${eventId}`);
if (response.status === 200) {
mutate();
}
};
const datePicker = useRef();
const downloadButton = useRef();
const onDownloadClick = (e, event) => {
e.stopPropagation();
setDownloadEvent((_prev) => ({ id: event.id, has_clip: event.has_clip, has_snapshot: event.has_snapshot }));
downloadButton.current = e.target;
setShowDownloadMenu(true);
};
const handleSelectDateRange = useCallback(
(dates) => {
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
setShowDatePicker(false);
},
[searchParams, setSearchParams, setShowDatePicker]
);
const onFilter = useCallback(
(name, value) => {
const updatedParams = { ...searchParams, [name]: value };
setSearchParams(updatedParams);
const queryString = Object.keys(updatedParams)
.map((key) => {
if (updatedParams[key] && updatedParams[key] != 'all') {
return `${key}=${updatedParams[key]}`;
}
return null;
})
.filter((val) => val)
.join('&');
route(`${path}?${queryString}`);
},
[path, searchParams, setSearchParams]
);
const isDone = (eventPages?.[eventPages.length - 1]?.length ?? 0) < API_LIMIT;
// hooks for infinite scroll
const observer = useRef();
const lastEventRef = useCallback(
(node) => {
if (isValidating) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isDone) {
setSize(size + 1);
}
});
if (node) observer.current.observe(node);
},
[size, setSize, isValidating, isDone]
);
if (!eventPages || !config) {
return <ActivityIndicator />;
}
return (
<div className="space-y-4 p-2 px-4 w-full">
<Heading>Events</Heading>
<div className="flex flex-wrap gap-2 items-center">
<select
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
value={searchParams.camera}
onChange={(e) => onFilter('camera', e.target.value)}
>
<option value="all">all</option>
{cameras.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
value={searchParams.label}
onChange={(e) => onFilter('label', e.target.value)}
>
<option value="all">all</option>
{labels.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select
className="basis-1/4 cursor-pointer rounded dark:bg-slate-800"
value={searchParams.zone}
onChange={(e) => onFilter('zone', e.target.value)}
>
<option value="all">all</option>
{zones.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<div ref={datePicker} className="ml-auto">
<CalendarIcon className="h-8 w-8 cursor-pointer" onClick={() => setShowDatePicker(true)} />
</div>
</div>
{showDownloadMenu && (
<Menu onDismiss={() => setShowDownloadMenu(false)} relativeTo={downloadButton}>
{downloadEvent.has_snapshot && (
<MenuItem
icon={Snapshot}
label="Download Snapshot"
value="snapshot"
href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
download
/>
)}
{downloadEvent.has_clip && (
<MenuItem
icon={Clip}
label="Download Clip"
value="clip"
href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`}
download
/>
)}
</Menu>
)}
{showDatePicker && (
<Menu className="rounded-t-none" onDismiss={() => setShowDatePicker(false)} relativeTo={datePicker}>
<MenuItem label="All" value={{ before: null, after: null }} onSelect={handleSelectDateRange} />
<MenuItem label="Today" value={{ before: null, after: daysAgo(0) }} onSelect={handleSelectDateRange} />
<MenuItem
label="Yesterday"
value={{ before: daysAgo(0), after: daysAgo(1) }}
onSelect={handleSelectDateRange}
/>
<MenuItem label="Last 7 Days" value={{ before: null, after: daysAgo(7) }} onSelect={handleSelectDateRange} />
<MenuItem label="This Month" value={{ before: null, after: monthsAgo(0) }} onSelect={handleSelectDateRange} />
<MenuItem
label="Last Month"
value={{ before: monthsAgo(0), after: monthsAgo(1) }}
onSelect={handleSelectDateRange}
/>
<MenuItem
label="Custom Range"
value="custom"
onSelect={() => {
setShowCalendar(true);
setShowDatePicker(false);
}}
/>
</Menu>
)}
{showCalendar && (
<Menu className="rounded-t-none" onDismiss={() => setShowCalendar(false)} relativeTo={datePicker}>
<Calendar onChange={handleSelectDateRange} close={() => setShowCalendar(false)} />
</Menu>
)}
<div className="space-y-2">
{eventPages.map((page, i) => {
const lastPage = eventPages.length === i + 1;
return page.map((event, j) => {
const lastEvent = lastPage && page.length === j + 1;
return (
<Fragment key={event.id}>
<div
ref={lastEvent ? lastEventRef : false}
className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
>
<div
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain"
style={{
'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`,
}}
>
<StarRecording
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
fill={event.retain_indefinitely ? 'currentColor' : 'none'}
/>
{event.end_time ? null : (
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
In progress
</div>
)}
</div>
<div className="m-2 flex grow">
<div className="flex flex-col grow">
<div className="capitalize text-lg font-bold">
{event.label} ({(event.top_score * 100).toFixed(0)}%)
</div>
<div className="text-sm">
{new Date(event.start_time * 1000).toLocaleDateString()}{' '}
{new Date(event.start_time * 1000).toLocaleTimeString()}
</div>
<div className="capitalize text-sm flex align-center mt-1">
<Camera className="h-5 w-5 mr-2 inline" />
{event.camera}
</div>
<div className="capitalize text-sm flex align-center">
<Zone className="w-5 h-5 mr-2 inline" />
{event.zones.join(',')}
</div>
</div>
<div class="flex flex-col">
<Delete className="cursor-pointer" stroke="#f87171" onClick={(e) => onDelete(e, event.id)} />
<Download
className="h-6 w-6 mt-auto"
stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
onClick={(e) => onDownloadClick(e, event)}
/>
</div>
</div>
</div>
{viewEvent !== event.id ? null : (
<div className="space-y-4">
<div className="mx-auto">
{event.has_clip ? (
<>
<Heading size="lg">Clip</Heading>
<VideoPlayer
options={{
preload: 'auto',
autoplay: true,
sources: [
{
src: `${apiHost}/vod/event/${event.id}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
}}
seekOptions={{ forward: 10, back: 5 }}
onReady={() => {}}
/>
</>
) : (
<div className="flex justify-center">
<div>
<Heading size="sm">{event.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
<img
className="flex-grow-0"
src={
event.has_snapshot
? `${apiHost}/api/events/${event.id}/snapshot.jpg`
: `data:image/jpeg;base64,${event.thumbnail}`
}
alt={`${event.label} at ${(event.top_score * 100).toFixed(0)}% confidence`}
/>
</div>
</div>
)}
</div>
</div>
)}
</Fragment>
);
});
})}
</div>
<div>{isDone ? null : <ActivityIndicator />}</div>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import { h } from 'preact';
import Select from '../../../components/Select';
import { useCallback } from 'preact/hooks';
function Filter({ onChange, searchParams, paramName, options, ...rest }) {
const handleSelect = useCallback(
(key) => {
const newParams = new URLSearchParams(searchParams.toString());
Object.keys(key).map((entries) => {
if (key[entries] !== 'all') {
newParams.set(entries, key[entries]);
} else {
paramName.map((p) => newParams.delete(p));
}
});
onChange(newParams);
},
[searchParams, paramName, onChange]
);
const obj = {};
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
}
export default Filter;

View File

@@ -1,38 +0,0 @@
import { h } from 'preact';
import { useCallback, useMemo } from 'preact/hooks';
import Link from '../../../components/Link';
import { route } from 'preact-router';
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
const removeDefaultSearchKeys = useCallback((searchParams) => {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
// searchParams.delete('before');
}, []);
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;

View File

@@ -1,81 +0,0 @@
import { h } from 'preact';
import Filter from './filter';
import { useConfig } from '../../../api';
import { useMemo, useState } from 'preact/hooks';
import { DateFilterOptions } from '../../../components/DatePicker';
import Button from '../../../components/Button';
const Filters = ({ onChange, searchParams }) => {
const [viewFilters, setViewFilters] = useState(false);
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>
<Button
onClick={() => setViewFilters(!viewFilters)}
className="block xs:hidden w-full mb-4 text-center"
type="text"
>
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
</Button>
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...cameras]}
paramName={['camera']}
label="Camera"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...zones]}
paramName={['zone']}
label="Zone"
searchParams={searchParams}
/>
<Filter
type="dropdown"
onChange={onChange}
options={['all', ...labels]}
paramName={['label']}
label="Label"
searchParams={searchParams}
/>
<Filter
type="datepicker"
onChange={onChange}
options={DateFilterOptions}
paramName={['before', 'after']}
label="DatePicker"
searchParams={searchParams}
/>
</div>
</div>
);
};
export default Filters;

View File

@@ -1,3 +0,0 @@
export { default as TableHead } from './tableHead';
export { default as TableRow } from './tableRow';
export { default as Filters } from './filters';

View File

@@ -1,19 +0,0 @@
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>Retain</Th>
<Th>Date</Th>
<Th>Start</Th>
<Th>End</Th>
</Tr>
</Thead>
);
export default TableHead;

View File

@@ -1,121 +0,0 @@
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,
retain_indefinitely
}) => {
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>{retain_indefinitely ? 'True' : 'False'}</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;

View File

@@ -1,107 +0,0 @@
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 p-2 px-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>
);
}

View File

@@ -1,47 +0,0 @@
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;
}
};

View File

@@ -4,13 +4,14 @@ import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import RecordingPlaylist from '../components/RecordingPlaylist';
import VideoPlayer from '../components/VideoPlayer';
import { FetchStatus, useApiHost, useRecording } from '../api';
import { useApiHost } from '../api';
import useSWR from 'swr';
export default function Recording({ camera, date, hour, seconds }) {
const apiHost = useApiHost();
const { data, status } = useRecording(camera);
const { data } = useSWR(`${camera}/recordings`);
if (status !== FetchStatus.LOADED) {
if (!data) {
return <ActivityIndicator />;
}

View File

@@ -1,68 +0,0 @@
import { h } from 'preact';
import * as Api from '../../api';
import Event from '../Event';
import { render, screen } from '@testing-library/preact';
describe('Event Route', () => {
let useEventMock;
beforeEach(() => {
useEventMock = jest.spyOn(Api, 'useEvent').mockImplementation(() => ({
data: mockEvent,
status: 'loaded',
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://localhost:5000');
});
test('shows an ActivityIndicator if not yet loaded', async () => {
useEventMock.mockReturnValueOnce(() => ({ status: 'loading' }));
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test('shows cameras', async () => {
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
expect(screen.queryByText('Clip')).toBeInTheDocument();
expect(screen.queryByLabelText('Video Player')).toBeInTheDocument();
expect(screen.queryByText('Best Image')).not.toBeInTheDocument();
expect(screen.queryByText('Thumbnail')).not.toBeInTheDocument();
});
test('does not render a video if there is no clip', async () => {
useEventMock.mockReturnValue({ data: { ...mockEvent, has_clip: false }, status: 'loaded' });
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByText('Clip')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Video Player')).not.toBeInTheDocument();
expect(screen.queryByText('Best Image')).toBeInTheDocument();
expect(screen.queryByText('Thumbnail')).not.toBeInTheDocument();
});
test('shows the thumbnail if no snapshot available', async () => {
useEventMock.mockReturnValue({ data: { ...mockEvent, has_clip: false, has_snapshot: false }, status: 'loaded' });
render(<Event eventId={mockEvent.id} />);
expect(screen.queryByText('Best Image')).not.toBeInTheDocument();
expect(screen.queryByText('Thumbnail')).toBeInTheDocument();
expect(screen.queryByAltText('person at 82.0% confidence')).toHaveAttribute(
'src',
'data:image/jpeg;base64,/9j/4aa...'
);
});
});
const mockEvent = {
camera: 'front',
end_time: 1613257337.841237,
has_clip: true,
has_snapshot: true,
id: '1613257326.237365-83cgl2',
label: 'person',
start_time: 1613257326.237365,
top_score: 0.8203125,
zones: ['front_patio'],
thumbnail: '/9j/4aa...',
};

View File

@@ -1,44 +1,39 @@
export async function getCameraMap(url, cb, props) {
export async function getCameraMap(_url, _cb, _props) {
const module = await import('./CameraMap.jsx');
return module.default;
}
export async function getCamera(url, cb, props) {
export async function getCamera(_url, _cb, _props) {
const module = await import('./Camera.jsx');
return module.default;
}
export async function getCameraV2(url, cb, props) {
export async function getCameraV2(_url, _cb, _props) {
const module = await import('./Camera_V2.jsx');
return module.default;
}
export async function getEvent(url, cb, props) {
const module = await import('./Event.jsx');
return module.default;
}
export async function getBirdseye(url, cb, props) {
export async function getBirdseye(_url, _cb, _props) {
const module = await import('./Birdseye.jsx');
return module.default;
}
export async function getEvents(url, cb, props) {
const module = await import('./Events');
export async function getEvents(_url, _cb, _props) {
const module = await import('./Events.jsx');
return module.default;
}
export async function getRecording(url, cb, props) {
export async function getRecording(_url, _cb, _props) {
const module = await import('./Recording.jsx');
return module.default;
}
export async function getDebug(url, cb, props) {
export async function getDebug(_url, _cb, _props) {
const module = await import('./Debug.jsx');
return module.default;
}
export async function getStyleGuide(url, cb, props) {
export async function getStyleGuide(_url, _cb, _props) {
const module = await import('./StyleGuide.jsx');
return module.default;
}