From e6516235fa9c22d1e31a2ea1d1a633b97d21a4fb Mon Sep 17 00:00:00 2001 From: Paul Armstrong Date: Tue, 26 Jan 2021 07:04:03 -0800 Subject: [PATCH] feat(web): auto-paginate events page --- frigate/http.py | 8 +- web/package-lock.json | 5 + web/package.json | 1 + web/src/App.jsx | 41 +-- web/src/Camera.jsx | 8 +- web/src/CameraMap.jsx | 6 +- web/src/Cameras.jsx | 7 +- web/src/Debug.jsx | 26 +- web/src/Event.jsx | 71 +++-- web/src/Events.jsx | 259 ++++++++++++++---- web/src/api/index.jsx | 108 ++++++++ .../components/AutoUpdatingCameraImage.jsx | 1 - web/src/components/Box.jsx | 2 +- web/src/components/CameraImage.jsx | 6 +- web/src/components/Table.jsx | 26 +- web/src/context/index.js | 5 - web/src/index.jsx | 5 +- 17 files changed, 424 insertions(+), 161 deletions(-) create mode 100644 web/src/api/index.jsx delete mode 100644 web/src/context/index.js diff --git a/frigate/http.py b/frigate/http.py index f89791d75..60c4ce539 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -55,7 +55,7 @@ def events_summary(): if not has_clip is None: clauses.append((Event.has_clip == has_clip)) - + if not has_snapshot is None: clauses.append((Event.has_snapshot == has_snapshot)) @@ -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) @@ -186,7 +186,7 @@ def events(): if not has_clip is None: clauses.append((Event.has_clip == has_clip)) - + if not has_snapshot is None: clauses.append((Event.has_snapshot == has_snapshot)) diff --git a/web/package-lock.json b/web/package-lock.json index 32eded6de..3725fd518 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 03ce52161..ce24a0078 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/App.jsx b/web/src/App.jsx index 96a08758c..7222d4f7b 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -7,36 +7,25 @@ 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 ? (
) : ( - -
- -
- - - - - - - - -
+
+ +
+ + + + + + + +
- +
); } diff --git a/web/src/Camera.jsx b/web/src/Camera.jsx index 9136dbd37..79815e450 100644 --- a/web/src/Camera.jsx +++ b/web/src/Camera.jsx @@ -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
{`No camera named ${camera}`}
; } diff --git a/web/src/CameraMap.jsx b/web/src/CameraMap.jsx index 5f0e7705c..8a227b08b 100644 --- a/web/src/CameraMap.jsx +++ b/web/src/CameraMap.jsx @@ -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); diff --git a/web/src/Cameras.jsx b/web/src/Cameras.jsx index 48bfa1887..ab01eb9ff 100644 --- a/web/src/Cameras.jsx +++ b/web/src/Cameras.jsx @@ -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

loading…

; } diff --git a/web/src/Debug.jsx b/web/src/Debug.jsx index 09d73a27f..40c77f37b 100644 --- a/web/src/Debug.jsx +++ b/web/src/Debug.jsx @@ -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]]); diff --git a/web/src/Event.jsx b/web/src/Event.jsx index 5256f8957..9243f679c 100644 --- a/web/src/Event.jsx +++ b/web/src/Event.jsx @@ -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,34 +50,36 @@ export default function Event({ eventId }) { /> - - - - - - - - - - - - - - - - - - - - - - - -
KeyValue
Camera - {data.camera} -
Timeframe - {startime.toLocaleString()} – {endtime.toLocaleString()} -
Score{(data.top_score * 100).toFixed(2)}%
Zones{data.zones.join(', ')}
+ + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Camera + {data.camera} +
Timeframe + {startime.toLocaleString()} – {endtime.toLocaleString()} +
Score{(data.top_score * 100).toFixed(2)}%
Zones{data.zones.join(', ')}
+
); } diff --git a/web/src/Events.jsx b/web/src/Events.jsx index 2976d7fc0..4b7be88cf 100644 --- a/web/src/Events.jsx +++ b/web/src/Events.jsx @@ -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 (
Events - {searchKeys.length ? ( - - Filters -
- {searchKeys.map((filterKey) => ( - - ))} -
-
- ) : null} + - +
@@ -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 ( - + + + +
- - + + + @@ -93,8 +174,9 @@ export default function Events({ url } = {}) { {zones.map((zone) => (
  • @@ -110,27 +192,108 @@ export default function Events({ url } = {}) { } )} +
  • + {status === 'loading' ? 'Loading…' : reachedEnd ? 'No more events' : null} +
    ); } -function Filterable({ pathname, searchParams, paramName, name }) { - const params = new URLSearchParams(searchParams); - params.set(paramName, name); - return {name}; -} +function Filterable({ onFilter, pathname, searchParams, paramName, name }) { + const href = useMemo(() => { + const params = new URLSearchParams(searchParams.toString()); + params.set(paramName, name); + 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 ( - + {name} - + + ); +} + +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 ( + + + + + + ); +} + +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 ( + ); } diff --git a/web/src/api/index.jsx b/web/src/api/index.jsx new file mode 100644 index 000000000..c5e1aedc8 --- /dev/null +++ b/web/src/api/index.jsx @@ -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 {children}; +}; + +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); +} diff --git a/web/src/components/AutoUpdatingCameraImage.jsx b/web/src/components/AutoUpdatingCameraImage.jsx index bf606a1b8..e0c39ae22 100644 --- a/web/src/components/AutoUpdatingCameraImage.jsx +++ b/web/src/components/AutoUpdatingCameraImage.jsx @@ -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; diff --git a/web/src/components/Box.jsx b/web/src/components/Box.jsx index 73304c12a..41dde70e7 100644 --- a/web/src/components/Box.jsx +++ b/web/src/components/Box.jsx @@ -4,7 +4,7 @@ export default function Box({ children, className = '', hover = false, href, ... const Element = href ? 'a' : 'div'; return ( {children}; +export function Thead({ children, className }) { + return {children}; } -export function Tbody({ children, className = '' }) { - return {children}; +export function Tbody({ children, className }) { + return {children}; } export function Tfoot({ children, className = '' }) { @@ -19,13 +19,21 @@ export function Tfoot({ children, className = '' }) { } export function Tr({ children, className = '', index }) { - return {children}; + return {children}; } -export function Th({ children, className = '' }) { - return {children}; +export function Th({ children, className = '', colspan }) { + return ( + + {children} + + ); } -export function Td({ children, className = '' }) { - return {children}; +export function Td({ children, className = '', colspan }) { + return ( + + {children} + + ); } diff --git a/web/src/context/index.js b/web/src/context/index.js deleted file mode 100644 index 7899c81e0..000000000 --- a/web/src/context/index.js +++ /dev/null @@ -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 || ''); diff --git a/web/src/index.jsx b/web/src/index.jsx index 966b59d06..599b5f836 100644 --- a/web/src/index.jsx +++ b/web/src/index.jsx @@ -1,9 +1,12 @@ import App from './App'; +import { ApiProvider } from './api'; import { h, render } from 'preact'; import 'preact/devtools'; import './index.css'; render( - , + + + , document.getElementById('root') );