feat(web): auto-paginate events page

This commit is contained in:
Paul Armstrong 2021-01-26 07:04:03 -08:00 committed by Blake Blackshear
parent 40d5a9f890
commit e6516235fa
17 changed files with 424 additions and 161 deletions

View File

@ -160,8 +160,8 @@ def events():
camera = request.args.get('camera')
label = request.args.get('label')
zone = request.args.get('zone')
after = request.args.get('after', type=int)
before = request.args.get('before', type=int)
after = request.args.get('after', type=float)
before = request.args.get('before', type=float)
has_clip = request.args.get('has_clip', type=int)
has_snapshot = request.args.get('has_snapshot', type=int)
include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)

5
web/package-lock.json generated
View File

@ -3574,6 +3574,11 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
},
"immer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz",
"integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA=="
},
"import-cwd": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz",

View File

@ -12,6 +12,7 @@
"@snowpack/plugin-webpack": "^2.3.0",
"autoprefixer": "^10.2.1",
"cross-env": "^7.0.3",
"immer": "^8.0.1",
"postcss": "^8.2.2",
"postcss-cli": "^8.3.1",
"preact": "^10.5.9",

View File

@ -7,26 +7,16 @@ 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';
import Api, { useConfig } from './api';
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 ? (
const { data, status } = useConfig();
return !data ? (
<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 text-gray-900 dark:text-white">
<Sidebar />
<div className="flex-auto p-4 lg:pl-8 lg:pr-8 min-w-0">
<div className="flex-auto p-2 md:p-4 lg:pl-8 lg:pr-8 min-w-0">
<Router>
<CameraMap path="/cameras/:camera/editor" />
<Camera path="/cameras/:camera" />
@ -37,6 +27,5 @@ export default function App() {
</Router>
</div>
</div>
</Config.Provider>
);
}

View File

@ -6,13 +6,13 @@ 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';
import { useApiHost, useConfig } from './api';
export default function Camera({ camera, url }) {
const config = useContext(Config);
const apiHost = useContext(ApiHost);
const { data: config } = useConfig();
const apiHost = useApiHost();
if (!(camera in config.cameras)) {
if (!config) {
return <div>{`No camera named ${camera}`}</div>;
}

View File

@ -5,11 +5,11 @@ 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';
import { useApiHost, useConfig } from './api';
export default function CameraMasks({ camera, url }) {
const config = useContext(Config);
const apiHost = useContext(ApiHost);
const { data: config } = useConfig();
const apiHost = useApiHost();
const imageRef = useRef(null);
const [imageScale, setImageScale] = useState(1);
const [snap, setSnap] = useState(true);

View File

@ -4,13 +4,12 @@ import CameraImage from './components/CameraImage';
import Events from './Events';
import Heading from './components/Heading';
import { route } from 'preact-router';
import { useContext } from 'preact/hooks';
import { ApiHost, Config } from './context';
import { useConfig } from './api';
export default function Cameras() {
const config = useContext(Config);
const { data: config, status } = useConfig();
if (!config.cameras) {
if (!config) {
return <p>loading</p>;
}

View File

@ -3,25 +3,21 @@ import Box from './components/Box';
import Button from './components/Button';
import Heading from './components/Heading';
import Link from './components/Link';
import { ApiHost, Config } from './context';
import { useConfig, useStats } from './api';
import { Table, Tbody, Thead, Tr, Th, Td } from './components/Table';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
import { useCallback, useEffect, useState } from 'preact/hooks';
export default function Debug() {
const apiHost = useContext(ApiHost);
const config = useContext(Config);
const [stats, setStats] = useState({});
const config = useConfig();
const [timeoutId, setTimeoutId] = useState(null);
const fetchStats = useCallback(async () => {
const statsResponse = await fetch(`${apiHost}/api/stats`);
const stats = statsResponse.ok ? await statsResponse.json() : {};
setStats(stats);
setTimeoutId(setTimeout(fetchStats, 1000));
}, [setStats]);
const forceUpdate = useCallback(async () => {
setTimeoutId(setTimeout(forceUpdate, 1000));
}, []);
useEffect(() => {
fetchStats();
forceUpdate();
}, []);
useEffect(() => {
@ -29,12 +25,14 @@ export default function Debug() {
clearTimeout(timeoutId);
};
}, [timeoutId]);
const { data: stats, status } = useStats(null, timeoutId);
const { detectors, detection_fps, service, ...cameras } = stats;
if (!service) {
if (!stats) {
return 'loading…';
}
const { detectors, detection_fps, service, ...cameras } = stats;
const detectorNames = Object.keys(detectors);
const detectorDataKeys = Object.keys(detectors[detectorNames[0]]);

View File

@ -1,20 +1,13 @@
import { h, Fragment } from 'preact';
import { ApiHost } from './context';
import Box from './components/Box';
import Heading from './components/Heading';
import Link from './components/Link';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
import { useContext, useEffect, useState } from 'preact/hooks';
import { useApiHost, useEvent } from './api';
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]);
const apiHost = useApiHost();
const { data } = useEvent(eventId);
if (!data) {
return (
@ -57,6 +50,7 @@ export default function Event({ eventId }) {
/>
</Box>
<Box>
<Table>
<Thead>
<Th>Key</Th>
@ -85,6 +79,7 @@ export default function Event({ eventId }) {
</Tr>
</Tbody>
</Table>
</Box>
</div>
);
}

View File

@ -1,49 +1,122 @@
import { h } from 'preact';
import { ApiHost } from './context';
import Box from './components/Box';
import Heading from './components/Heading';
import Link from './components/Link';
import produce from 'immer';
import { route } from 'preact-router';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
import { useApiHost, useConfig, useEvents } from './api';
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
export default function Events({ url } = {}) {
const apiHost = useContext(ApiHost);
const [events, setEvents] = useState([]);
const API_LIMIT = 25;
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`);
const searchParamsString = searchParams.toString();
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} });
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'APPEND_EVENTS': {
const {
meta: { searchString },
payload,
} = action;
return produce(state, (draftState) => {
draftState.searchStrings[searchString] = true;
draftState.events.push(...payload);
});
}
useEffect(async () => {
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`);
const data = response.ok ? await response.json() : {};
setEvents(data);
}, [searchParamsString]);
case 'REACHED_END': {
const {
meta: { searchString },
} = action;
return produce(state, (draftState) => {
draftState.reachedEnd = true;
draftState.searchStrings[searchString] = true;
});
}
const searchKeys = Array.from(searchParams.keys());
case 'RESET':
return initialState;
default:
return state;
}
};
const defaultSearchString = `include_thumbnails=0&limit=${API_LIMIT}`;
function removeDefaultSearchKeys(searchParams) {
searchParams.delete('limit');
searchParams.delete('include_thumbnails');
searchParams.delete('before');
}
export default function Events({ path: pathname } = {}) {
const apiHost = useApiHost();
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState);
const { searchParams: initialSearchParams } = new URL(window.location);
const [searchString, setSearchString] = useState(`${defaultSearchString}&${initialSearchParams.toString()}`);
const { data, status } = useEvents(searchString);
useEffect(() => {
if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
}
if (Array.isArray(data) && data.length < API_LIMIT) {
dispatch({ type: 'REACHED_END', meta: { searchString } });
}
}, [data]);
const observer = useRef(
new IntersectionObserver((entries, observer) => {
window.requestAnimationFrame(() => {
if (entries.length === 0) {
return;
}
// under certain edge cases, a ref may be applied / in memory twice
// avoid fetching twice by grabbing the last observed entry only
const entry = entries[entries.length - 1];
if (entry.isIntersecting) {
const { startTime } = entry.target.dataset;
const { searchParams } = new URL(window.location);
searchParams.set('before', parseFloat(startTime) - 0.0001);
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
}
});
})
);
const lastCellRef = useCallback(
(node) => {
if (node !== null) {
observer.current.disconnect();
if (!reachedEnd) {
observer.current.observe(node);
}
}
},
[observer.current, reachedEnd]
);
const handleFilter = useCallback(
(searchParams) => {
dispatch({ type: 'RESET' });
removeDefaultSearchKeys(searchParams);
setSearchString(`${defaultSearchString}&${searchParams.toString()}`);
route(`${pathname}?${searchParams.toString()}`);
},
[pathname, setSearchString]
);
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
return (
<div className="space-y-4 w-full">
<Heading>Events</Heading>
{searchKeys.length ? (
<Box>
<Heading size="sm">Filters</Heading>
<div className="flex flex-wrap space-x-2">
{searchKeys.map((filterKey) => (
<UnFilterable
name={`${filterKey}: ${searchParams.get(filterKey)}`}
paramName={filterKey}
pathname={pathname}
searchParams={searchParamsString}
/>
))}
</div>
</Box>
) : null}
<Filters onChange={handleFilter} searchParams={searchParams} />
<Box className="min-w-0 overflow-auto">
<Table className="w-full">
<Table className="min-w-full table-fixed">
<Thead>
<Tr>
<Th></Th>
@ -64,25 +137,33 @@ export default function Events({ url } = {}) {
) => {
const start = new Date(parseInt(startTime * 1000, 10));
const end = new Date(parseInt(endTime * 1000, 10));
const ref = i === events.length - 1 ? lastCellRef : undefined;
return (
<Tr key={id} index={i}>
<Td>
<a href={`/events/${id}`}>
<img className="w-32 max-w-none" src={`data:image/jpeg;base64,${thumbnail}`} />
<Td className="w-40">
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}>
<img
width="150"
height="150"
style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/>
</a>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParamsString}
searchParams={searchParams}
paramName="camera"
name={camera}
/>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParamsString}
searchParams={searchParams}
paramName="label"
name={label}
/>
@ -93,8 +174,9 @@ export default function Events({ url } = {}) {
{zones.map((zone) => (
<li>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParamsString}
searchParams={searchString}
paramName="zone"
name={zone}
/>
@ -110,27 +192,108 @@ export default function Events({ url } = {}) {
}
)}
</Tbody>
<Tfoot>
<Tr>
<Td className="text-center" colspan="8">
{status === 'loading' ? 'Loading…' : reachedEnd ? 'No more events' : null}
</Td>
</Tr>
</Tfoot>
</Table>
</Box>
</div>
);
}
function Filterable({ pathname, searchParams, paramName, name }) {
const params = new URLSearchParams(searchParams);
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
const href = useMemo(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
return <Link href={`${pathname}?${params.toString()}`}>{name}</Link>;
}
removeDefaultSearchKeys(params);
return `${pathname}?${params.toString()}`;
}, [searchParams]);
const handleClick = useCallback(
(event) => {
event.preventDefault();
route(href, true);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, name);
onFilter(params);
},
[href, searchParams]
);
function UnFilterable({ pathname, 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={`${pathname}?${params.toString()}`}
>
<Link href={href} onclick={handleClick}>
{name}
</a>
</Link>
);
}
function 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 (
<Box className="flex space-y-0 space-x-8 flex-wrap">
<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} />
</Box>
);
}
function Filter({ onChange, searchParams, paramName, options }) {
const handleSelect = useCallback(
(event) => {
const newParams = new URLSearchParams(searchParams.toString());
const value = event.target.value;
if (value) {
newParams.set(paramName, event.target.value);
} else {
newParams.delete(paramName);
}
onChange(newParams);
},
[searchParams, paramName, onChange]
);
return (
<label>
<span className="block uppercase text-sm">{paramName}</span>
<select className="border-solid border border-gray-500 rounded dark:text-gray-900" onChange={handleSelect}>
<option>All</option>
{options.map((opt) => {
return (
<option value={opt} selected={searchParams.get(paramName) === opt}>
{opt}
</option>
);
})}
</select>
</label>
);
}

108
web/src/api/index.jsx Normal file
View File

@ -0,0 +1,108 @@
import { h, createContext } from 'preact';
import produce from 'immer';
import { useCallback, useContext, useEffect, useMemo, useRef, useReducer, useState } from 'preact/hooks';
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '');
export const FetchStatus = {
NONE: 'none',
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'error',
};
const initialState = Object.freeze({
host: import.meta.env.SNOWPACK_PUBLIC_API_HOST || window.baseUrl || '',
queries: {},
});
export const Api = createContext(initialState);
export default Api;
function reducer(state, { type, payload, meta }) {
switch (type) {
case 'REQUEST': {
const { url, request } = payload;
const data = state.queries[url]?.data || null;
return produce(state, (draftState) => {
draftState.queries[url] = { status: FetchStatus.LOADING, data };
});
}
case 'RESPONSE': {
const { url, ok, data } = payload;
return produce(state, (draftState) => {
draftState.queries[url] = { status: ok ? FetchStatus.LOADED : FetchStatus.ERROR, data };
});
}
default:
return state;
}
}
export const ApiProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return <Api.Provider value={{ state, dispatch }}>{children}</Api.Provider>;
};
function shouldFetch(state, url, forceRefetch = false) {
if (forceRefetch || !(url in state.queries)) {
return true;
}
const { status } = state.queries[url];
return status !== FetchStatus.LOADING && status !== FetchStatus.LOADED;
}
export function useFetch(url, forceRefetch) {
const { state, dispatch } = useContext(Api);
useEffect(() => {
if (!shouldFetch(state, url, forceRefetch)) {
return;
}
async function fetchConfig() {
await dispatch({ type: 'REQUEST', payload: { url } });
const response = await fetch(`${state.host}${url}`);
const data = await response.json();
await dispatch({ type: 'RESPONSE', payload: { url, ok: response.ok, data } });
}
fetchConfig();
}, [url, forceRefetch]);
if (!(url in state.queries)) {
return { data: null, status: FetchStatus.NONE };
}
const data = state.queries[url].data || null;
const status = state.queries[url].status;
return { data, status };
}
export function useApiHost() {
const { state, dispatch } = useContext(Api);
return state.host;
}
export function useEvents(searchParams, forceRefetch) {
const url = `/api/events${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, forceRefetch);
}
export function useEvent(eventId, forceRefetch) {
const url = `/api/events/${eventId}`;
return useFetch(url, forceRefetch);
}
export function useConfig(searchParams, forceRefetch) {
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, forceRefetch);
}
export function useStats(searchParams, forceRefetch) {
const url = `/api/stats${searchParams ? `?${searchParams.toString()}` : ''}`;
return useFetch(url, forceRefetch);
}

View File

@ -1,6 +1,5 @@
import { h } from 'preact';
import CameraImage from './CameraImage';
import { ApiHost, Config } from '../context';
import { useCallback, useState } from 'preact/hooks';
const MIN_LOAD_TIMEOUT_MS = 200;

View File

@ -4,7 +4,7 @@ export default function Box({ children, className = '', hover = false, href, ...
const Element = href ? 'a' : 'div';
return (
<Element
className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 ${
className={`bg-white dark:bg-gray-700 shadow-lg rounded-lg p-2 lg:p-4 ${
hover ? 'hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900 dark:hover:text-gray-900' : ''
} ${className}`}
href={href}

View File

@ -1,10 +1,10 @@
import { h } from 'preact';
import { ApiHost, Config } from '../context';
import { useApiHost, useConfig } from '../api';
import { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'preact/hooks';
export default function CameraImage({ camera, onload, searchParams = '' }) {
const config = useContext(Config);
const apiHost = useContext(ApiHost);
const { data: config } = useConfig();
const apiHost = useApiHost();
const [availableWidth, setAvailableWidth] = useState(0);
const [loadedSrc, setLoadedSrc] = useState(null);
const containerRef = useRef(null);

View File

@ -6,12 +6,12 @@ export function Table({ children, className = '' }) {
);
}
export function Thead({ children, className = '' }) {
return <thead className={`${className}`}>{children}</thead>;
export function Thead({ children, className }) {
return <thead className={className}>{children}</thead>;
}
export function Tbody({ children, className = '' }) {
return <tbody className={`${className}`}>{children}</tbody>;
export function Tbody({ children, className }) {
return <tbody className={className}>{children}</tbody>;
}
export function Tfoot({ children, className = '' }) {
@ -19,13 +19,21 @@ export function Tfoot({ children, className = '' }) {
}
export function Tr({ children, className = '', index }) {
return <tr className={`${index % 2 ? 'bg-gray-200 dark:bg-gray-700' : ''} ${className}`}>{children}</tr>;
return <tr className={`${index % 2 ? 'bg-gray-200 dark:bg-gray-600' : ''} ${className}`}>{children}</tr>;
}
export function Th({ children, className = '' }) {
return <th className={`border-b-2 border-gray-400 p-4 text-left ${className}`}>{children}</th>;
export function Th({ children, className = '', colspan }) {
return (
<th className={`border-b-2 border-gray-400 p-1 md:p-2 text-left ${className}`} colspan={colspan}>
{children}
</th>
);
}
export function Td({ children, className = '' }) {
return <td className={`p-4 ${className}`}>{children}</td>;
export function Td({ children, className = '', colspan }) {
return (
<td className={`p-1 md:p-2 ${className}`} colspan={colspan}>
{children}
</td>
);
}

View File

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

View File

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