forked from Github/frigate
Events performance (#1645)
* rearrange event route and splitted into several components * useIntersectionObserver * re-arrange * searchstring improvement * added xs tailwind breakpoint * useOuterClick hook * cleaned up * removed some video controls for mobile devices * lint * moved hooks to global folder * moved buttons for small devices * added button groups Co-authored-by: Bernt Christian Egeland <cbegelan@gmail.com>
This commit is contained in:
committed by
GitHub
parent
b8df419bad
commit
00ff76a0b9
31
web/src/routes/Events/components/filter.jsx
Normal file
31
web/src/routes/Events/components/filter.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { h } from 'preact';
|
||||
import Select from '../../../components/Select';
|
||||
import { useCallback, useMemo } from 'preact/hooks';
|
||||
|
||||
const Filter = ({ onChange, searchParams, paramName, options }) => {
|
||||
const handleSelect = useCallback(
|
||||
(key) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
if (key !== 'all') {
|
||||
newParams.set(paramName, key);
|
||||
} else {
|
||||
newParams.delete(paramName);
|
||||
}
|
||||
|
||||
onChange(newParams);
|
||||
},
|
||||
[searchParams, paramName, onChange]
|
||||
);
|
||||
|
||||
const selectOptions = useMemo(() => ['all', ...options], [options]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
||||
onChange={handleSelect}
|
||||
options={selectOptions}
|
||||
selected={searchParams.get(paramName) || 'all'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default Filter;
|
||||
32
web/src/routes/Events/components/filterable.jsx
Normal file
32
web/src/routes/Events/components/filterable.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useMemo } from 'preact/hooks';
|
||||
import Link from '../../../components/Link';
|
||||
import { route } from 'preact-router';
|
||||
|
||||
const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => {
|
||||
const href = useMemo(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(paramName, name);
|
||||
removeDefaultSearchKeys(params);
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}, [searchParams, paramName, pathname, name, removeDefaultSearchKeys]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
route(href, true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(paramName, name);
|
||||
onFilter(params);
|
||||
},
|
||||
[href, searchParams, onFilter, paramName, name]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link href={href} onclick={handleClick}>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filterable;
|
||||
39
web/src/routes/Events/components/filters.jsx
Normal file
39
web/src/routes/Events/components/filters.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { h } from 'preact';
|
||||
import Filter from './filter';
|
||||
import { useConfig } from '../../../api';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
|
||||
const Filters = ({ onChange, searchParams }) => {
|
||||
const { data } = useConfig();
|
||||
|
||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
||||
|
||||
const zones = useMemo(
|
||||
() =>
|
||||
Object.values(data.cameras)
|
||||
.reduce((memo, camera) => {
|
||||
memo = memo.concat(Object.keys(camera.zones));
|
||||
return memo;
|
||||
}, [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i),
|
||||
[data]
|
||||
);
|
||||
|
||||
const labels = useMemo(() => {
|
||||
return Object.values(data.cameras)
|
||||
.reduce((memo, camera) => {
|
||||
memo = memo.concat(camera.objects?.track || []);
|
||||
return memo;
|
||||
}, data.objects?.track || [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4">
|
||||
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Filters;
|
||||
3
web/src/routes/Events/components/index.jsx
Normal file
3
web/src/routes/Events/components/index.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as TableHead } from './tableHead';
|
||||
export { default as TableRow } from './tableRow';
|
||||
export { default as Filters } from './filters';
|
||||
18
web/src/routes/Events/components/tableHead.jsx
Normal file
18
web/src/routes/Events/components/tableHead.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { h } from 'preact';
|
||||
import { Thead, Th, Tr } from '../../../components/Table';
|
||||
|
||||
const TableHead = () => (
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th />
|
||||
<Th>Camera</Th>
|
||||
<Th>Label</Th>
|
||||
<Th>Score</Th>
|
||||
<Th>Zones</Th>
|
||||
<Th>Date</Th>
|
||||
<Th>Start</Th>
|
||||
<Th>End</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
);
|
||||
export default TableHead;
|
||||
119
web/src/routes/Events/components/tableRow.jsx
Normal file
119
web/src/routes/Events/components/tableRow.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useCallback, useState, useMemo } from 'preact/hooks';
|
||||
import { Tr, Td, Tbody } from '../../../components/Table';
|
||||
import Filterable from './filterable';
|
||||
import Event from '../../Event';
|
||||
import { useSearchString } from '../../../hooks/useSearchString';
|
||||
import { useClickOutside } from '../../../hooks/useClickOutside';
|
||||
|
||||
const EventsRow = memo(
|
||||
({
|
||||
id,
|
||||
apiHost,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
scrollToRef,
|
||||
lastRowRef,
|
||||
handleFilter,
|
||||
pathname,
|
||||
limit,
|
||||
camera,
|
||||
label,
|
||||
top_score: score,
|
||||
zones,
|
||||
}) => {
|
||||
const [viewEvent, setViewEvent] = useState(null);
|
||||
const { searchString, removeDefaultSearchKeys } = useSearchString(limit);
|
||||
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
||||
|
||||
const innerRef = useClickOutside(() => {
|
||||
setViewEvent(null);
|
||||
});
|
||||
|
||||
const viewEventHandler = useCallback(
|
||||
(id) => {
|
||||
//Toggle event view
|
||||
if (viewEvent === id) return setViewEvent(null);
|
||||
//Set event id to be rendered.
|
||||
setViewEvent(id);
|
||||
},
|
||||
[viewEvent]
|
||||
);
|
||||
|
||||
const start = new Date(parseInt(startTime * 1000, 10));
|
||||
const end = new Date(parseInt(endTime * 1000, 10));
|
||||
|
||||
return (
|
||||
<Tbody reference={innerRef}>
|
||||
<Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
|
||||
<Td className="w-40">
|
||||
<a
|
||||
onClick={() => viewEventHandler(id)}
|
||||
ref={lastRowRef}
|
||||
data-start-time={startTime}
|
||||
// data-reached-end={reachedEnd} <-- Enable this will cause all events to re-render when reaching end.
|
||||
>
|
||||
<img
|
||||
width="150"
|
||||
height="150"
|
||||
className="cursor-pointer"
|
||||
style="min-height: 48px; min-width: 48px;"
|
||||
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
|
||||
/>
|
||||
</a>
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchParams}
|
||||
paramName="camera"
|
||||
name={camera}
|
||||
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchParams}
|
||||
paramName="label"
|
||||
name={label}
|
||||
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
||||
/>
|
||||
</Td>
|
||||
<Td>{(score * 100).toFixed(2)}%</Td>
|
||||
<Td>
|
||||
<ul>
|
||||
{zones.map((zone) => (
|
||||
<li>
|
||||
<Filterable
|
||||
onFilter={handleFilter}
|
||||
pathname={pathname}
|
||||
searchParams={searchString}
|
||||
paramName="zone"
|
||||
name={zone}
|
||||
removeDefaultSearchKeys={removeDefaultSearchKeys}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Td>
|
||||
<Td>{start.toLocaleDateString()}</Td>
|
||||
<Td>{start.toLocaleTimeString()}</Td>
|
||||
<Td>{end.toLocaleTimeString()}</Td>
|
||||
</Tr>
|
||||
{viewEvent === id ? (
|
||||
<Tr className="border-b-1">
|
||||
<Td colSpan="8" reference={(el) => (scrollToRef[id] = el)}>
|
||||
<Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
|
||||
</Td>
|
||||
</Tr>
|
||||
) : null}
|
||||
</Tbody>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default EventsRow;
|
||||
107
web/src/routes/Events/index.jsx
Normal file
107
web/src/routes/Events/index.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { h } from 'preact';
|
||||
import ActivityIndicator from '../../components/ActivityIndicator';
|
||||
import Heading from '../../components/Heading';
|
||||
import { TableHead, Filters, TableRow } from './components';
|
||||
import { route } from 'preact-router';
|
||||
import { FetchStatus, useApiHost, useEvents } from '../../api';
|
||||
import { Table, Tfoot, Tr, Td } from '../../components/Table';
|
||||
import { useCallback, useEffect, useMemo, useReducer } from 'preact/hooks';
|
||||
import { reducer, initialState } from './reducer';
|
||||
import { useSearchString } from '../../hooks/useSearchString';
|
||||
import { useIntersectionObserver } from '../../hooks';
|
||||
|
||||
const API_LIMIT = 25;
|
||||
|
||||
export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
|
||||
const apiHost = useApiHost();
|
||||
const { searchString, setSearchString, removeDefaultSearchKeys } = useSearchString(limit);
|
||||
const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
|
||||
const { data, status, deletedId } = useEvents(searchString);
|
||||
|
||||
const scrollToRef = useMemo(() => Object, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !(searchString in searchStrings)) {
|
||||
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
|
||||
}
|
||||
|
||||
if (data && Array.isArray(data) && data.length + deleted < limit) {
|
||||
dispatch({ type: 'REACHED_END', meta: { searchString } });
|
||||
}
|
||||
|
||||
if (deletedId) {
|
||||
dispatch({ type: 'DELETE_EVENT', deletedId });
|
||||
}
|
||||
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
|
||||
|
||||
const [entry, setIntersectNode] = useIntersectionObserver();
|
||||
|
||||
useEffect(() => {
|
||||
if (entry && entry.isIntersecting) {
|
||||
const { startTime } = entry.target.dataset;
|
||||
const { searchParams } = new URL(window.location);
|
||||
searchParams.set('before', parseFloat(startTime) - 0.0001);
|
||||
setSearchString(limit, searchParams.toString());
|
||||
}
|
||||
}, [entry, limit, setSearchString]);
|
||||
|
||||
const lastCellRef = useCallback(
|
||||
(node) => {
|
||||
if (node !== null && !reachedEnd) {
|
||||
setIntersectNode(node);
|
||||
}
|
||||
},
|
||||
[setIntersectNode, reachedEnd]
|
||||
);
|
||||
|
||||
const handleFilter = useCallback(
|
||||
(searchParams) => {
|
||||
dispatch({ type: 'RESET' });
|
||||
removeDefaultSearchKeys(searchParams);
|
||||
setSearchString(limit, searchParams.toString());
|
||||
route(`${pathname}?${searchParams.toString()}`);
|
||||
},
|
||||
[limit, pathname, setSearchString, removeDefaultSearchKeys]
|
||||
);
|
||||
|
||||
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
|
||||
|
||||
const RenderTableRow = useCallback(
|
||||
(props) => (
|
||||
<TableRow
|
||||
key={props.id}
|
||||
apiHost={apiHost}
|
||||
scrollToRef={scrollToRef}
|
||||
pathname={pathname}
|
||||
limit={API_LIMIT}
|
||||
handleFilter={handleFilter}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
[apiHost, handleFilter, pathname, scrollToRef]
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4 w-full">
|
||||
<Heading>Events</Heading>
|
||||
<Filters onChange={handleFilter} searchParams={searchParams} />
|
||||
<div className="min-w-0 overflow-auto">
|
||||
<Table className="min-w-full table-fixed">
|
||||
<TableHead />
|
||||
|
||||
{events.map((props, idx) => {
|
||||
const lastRowRef = idx === events.length - 1 ? lastCellRef : undefined;
|
||||
return <RenderTableRow {...props} lastRowRef={lastRowRef} idx={idx} />;
|
||||
})}
|
||||
|
||||
<Tfoot>
|
||||
<Tr>
|
||||
<Td className="text-center p-4" colSpan="8">
|
||||
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tfoot>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
web/src/routes/Events/reducer.jsx
Normal file
47
web/src/routes/Events/reducer.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import produce from 'immer';
|
||||
|
||||
export const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
|
||||
|
||||
export const reducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case 'DELETE_EVENT': {
|
||||
const { deletedId } = action;
|
||||
|
||||
return produce(state, (draftState) => {
|
||||
const idx = draftState.events.findIndex((e) => e.id === deletedId);
|
||||
if (idx === -1) return state;
|
||||
|
||||
draftState.events.splice(idx, 1);
|
||||
draftState.deleted++;
|
||||
});
|
||||
}
|
||||
case 'APPEND_EVENTS': {
|
||||
const {
|
||||
meta: { searchString },
|
||||
payload,
|
||||
} = action;
|
||||
|
||||
return produce(state, (draftState) => {
|
||||
draftState.searchStrings[searchString] = true;
|
||||
draftState.events.push(...payload);
|
||||
draftState.deleted = 0;
|
||||
});
|
||||
}
|
||||
|
||||
case 'REACHED_END': {
|
||||
const {
|
||||
meta: { searchString },
|
||||
} = action;
|
||||
return produce(state, (draftState) => {
|
||||
draftState.reachedEnd = true;
|
||||
draftState.searchStrings[searchString] = true;
|
||||
});
|
||||
}
|
||||
|
||||
case 'RESET':
|
||||
return initialState;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user