-
-
-
-
-
-
-
-
-
-
-
+
);
}
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 }) {
/>
-
-
- Key |
- Value |
-
-
-
- Camera |
-
- {data.camera}
- |
-
-
- Timeframe |
-
- {startime.toLocaleString()} – {endtime.toLocaleString()}
- |
-
-
- Score |
- {(data.top_score * 100).toFixed(2)}% |
-
-
- Zones |
- {data.zones.join(', ')} |
-
-
-
+
+
+
+ Key |
+ Value |
+
+
+
+ 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 (