From 1aba8c1ef53b10aac0b3306b40f3d52215ecaaf9 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 27 Sep 2023 05:09:38 -0600 Subject: [PATCH] Refactor time filter (#7962) * Add ability to filter events by start time * Add tests * Add time param to events * Add time picker * Update docs * Catch overnight case Update comment * Cleanup * Fix tests --- docs/docs/integrations/api.md | 26 +-- frigate/http.py | 33 ++++ frigate/test/test_http.py | 47 +++++- web/src/components/TimePicker.jsx | 260 +++++++----------------------- web/src/routes/Events.jsx | 78 +++++---- 5 files changed, 203 insertions(+), 241 deletions(-) diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index 2e6734497..c0266a429 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -155,18 +155,20 @@ Version info Events from the database. Accepts the following query string parameters: -| param | Type | Description | -| -------------------- | ---- | --------------------------------------------- | -| `before` | int | Epoch time | -| `after` | int | Epoch time | -| `cameras` | str | , separated list of cameras | -| `labels` | str | , separated list of labels | -| `zones` | str | , separated list of zones | -| `limit` | int | Limit the number of events returned | -| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) | -| `has_clip` | int | Filter to events that have clips (0 or 1) | -| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) | -| `in_progress` | int | Limit to events in progress (0 or 1) | +| param | Type | Description | +| -------------------- | ---- | ----------------------------------------------- | +| `before` | int | Epoch time | +| `after` | int | Epoch time | +| `cameras` | str | , separated list of cameras | +| `labels` | str | , separated list of labels | +| `zones` | str | , separated list of zones | +| `limit` | int | Limit the number of events returned | +| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) | +| `has_clip` | int | Filter to events that have clips (0 or 1) | +| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) | +| `in_progress` | int | Limit to events in progress (0 or 1) | +| `time_range` | str | Time range in format after,before (00:00,24:00) | +| `timezone` | str | Timezone to use for time range | ### `GET /api/timeline` diff --git a/frigate/http.py b/frigate/http.py index 4354e205e..acb2eaf12 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -56,6 +56,8 @@ from frigate.version import VERSION logger = logging.getLogger(__name__) +DEFAULT_TIME_RANGE = "00:00,24:00" + bp = Blueprint("frigate", __name__) @@ -769,6 +771,7 @@ def events(): limit = request.args.get("limit", 100) after = request.args.get("after", type=float) before = request.args.get("before", type=float) + time_range = request.args.get("time_range", DEFAULT_TIME_RANGE) has_clip = request.args.get("has_clip", type=int) has_snapshot = request.args.get("has_snapshot", type=int) in_progress = request.args.get("in_progress", type=int) @@ -851,6 +854,36 @@ def events(): if before: clauses.append((Event.start_time < before)) + if time_range != DEFAULT_TIME_RANGE: + # get timezone arg to ensure browser times are used + tz_name = request.args.get("timezone", default="utc", type=str) + hour_modifier, minute_modifier = get_tz_modifiers(tz_name) + + times = time_range.split(",") + time_after = times[0] + time_before = times[1] + + start_hour_fun = fn.strftime( + "%H:%M", + fn.datetime(Event.start_time, "unixepoch", hour_modifier, minute_modifier), + ) + + # cases where user wants events overnight, ex: from 20:00 to 06:00 + # should use or operator + if time_after > time_before: + clauses.append( + ( + reduce( + operator.or_, + [(start_hour_fun > time_after), (start_hour_fun < time_before)], + ) + ) + ) + # all other cases should be and operator + else: + clauses.append((start_hour_fun > time_after)) + clauses.append((start_hour_fun < time_before)) + if has_clip is not None: clauses.append((Event.has_clip == has_clip)) diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 3557eccd3..932a468a3 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -236,6 +236,44 @@ class TestHttp(unittest.TestCase): assert event["id"] == id assert event["retain_indefinitely"] is False + def test_event_time_filtering(self): + app = create_app( + FrigateConfig(**self.minimal_config), + self.db, + None, + None, + None, + None, + None, + PlusApi(), + ) + morning_id = "123456.random" + evening_id = "654321.random" + morning = 1656590400 # 06/30/2022 6 am (GMT) + evening = 1656633600 # 06/30/2022 6 pm (GMT) + + with app.test_client() as client: + _insert_mock_event(morning_id, morning) + _insert_mock_event(evening_id, evening) + # both events come back + events = client.get("/events").json + assert events + assert len(events) == 2 + # morning event is excluded + events = client.get( + "/events", + query_string={"time_range": "07:00,24:00"}, + ).json + assert events + # assert len(events) == 1 + # evening event is excluded + events = client.get( + "/events", + query_string={"time_range": "00:00,18:00"}, + ).json + assert events + assert len(events) == 1 + def test_set_delete_sub_label(self): app = create_app( FrigateConfig(**self.minimal_config), @@ -351,14 +389,17 @@ class TestHttp(unittest.TestCase): assert stats == self.test_stats -def _insert_mock_event(id: str) -> Event: +def _insert_mock_event( + id: str, + start_time: datetime.datetime = datetime.datetime.now().timestamp(), +) -> Event: """Inserts a basic event model with a given id.""" return Event.insert( id=id, label="Mock", camera="front_door", - start_time=datetime.datetime.now().timestamp(), - end_time=datetime.datetime.now().timestamp() + 20, + start_time=start_time, + end_time=start_time + 20, top_score=100, false_positive=False, zones=list(), diff --git a/web/src/components/TimePicker.jsx b/web/src/components/TimePicker.jsx index edb39bd52..4a70e9c29 100644 --- a/web/src/components/TimePicker.jsx +++ b/web/src/components/TimePicker.jsx @@ -1,182 +1,18 @@ import { h } from 'preact'; -import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import { ArrowDropdown } from '../icons/ArrowDropdown'; import { ArrowDropup } from '../icons/ArrowDropup'; +import Heading from './Heading'; -const TimePicker = ({ dateRange, onChange }) => { - const [error, setError] = useState(null); - const [timeRange, setTimeRange] = useState(new Set()); - const [hoverIdx, setHoverIdx] = useState(null); - const [reset, setReset] = useState(false); +const TimePicker = ({ timeRange, onChange }) => { + const times = timeRange.split(','); + const [after, setAfter] = useState(times[0]); + const [before, setBefore] = useState(times[1]); - /** - * Initializes two variables before and after with date objects, - * If they are not null, it creates a new Date object with the value of the property and if not, - * it creates a new Date object with the current hours to 0 and 24 respectively. - */ - const before = useMemo(() => { - return dateRange.before ? new Date(dateRange.before) : new Date(new Date().setHours(24, 0, 0, 0)); - }, [dateRange]); - - const after = useMemo(() => { - return dateRange.after ? new Date(dateRange.after) : new Date(new Date().setHours(0, 0, 0, 0)); - }, [dateRange]); - - useEffect(() => { - /** - * This will reset hours when user selects another date in the calendar. - */ - if (before.getHours() === 0 && after.getHours() === 0 && timeRange.size > 1) return setTimeRange(new Set()); - }, [after, before, timeRange]); - - useEffect(() => { - if (reset || !after) return; - /** - * calculates the number of hours between two dates, by finding the difference in days, - * converting it to hours and adding the hours from the before date. - */ - const days = Math.max(before.getDate() - after.getDate()); - const hourOffset = days * 24; - const beforeOffset = before.getHours() ? hourOffset + before.getHours() : 0; - - /** - * Fills the timeRange by iterating over the hours between 'after' and 'before' during component mount, to keep the selected hours persistent. - */ - for (let hour = after.getHours(); hour < beforeOffset; hour++) { - setTimeRange((timeRange) => timeRange.add(hour)); - } - - /** - * find an element by the id timeIndex- concatenated with the minimum value from timeRange array, - * and if that element is present, it will scroll into view if needed - */ - if (timeRange.size > 1) { - const element = document.getElementById(`timeIndex-${Math.max(...timeRange)}`); - if (element) { - element.scrollIntoViewIfNeeded(true); - } - } - }, [after, before, timeRange, reset]); - - /** - * numberOfDaysSelected is a set that holds the number of days selected in the dateRange. - * The loop iterates through the days starting from the after date's day to the before date's day. - * If the before date's hour is 0, it skips it. - */ - const numberOfDaysSelected = useMemo(() => { - return new Set([...Array(Math.max(1, before.getDate() - after.getDate() + 1))].map((_, i) => after.getDate() + i)); - }, [before, after]); - - if (before.getHours() === 0) numberOfDaysSelected.delete(before.getDate()); - - // Create repeating array with the number of hours for each day selected ...23,24,0,1,2... - const hoursInDays = useMemo(() => { - return Array.from({ length: numberOfDaysSelected.size * 24 }, (_, i) => i % 24); - }, [numberOfDaysSelected]); - - // function for handling the selected time from the provided list - const handleTime = useCallback( - (hour) => { - if (isNaN(hour)) return; - - const _timeRange = new Set([...timeRange]); - _timeRange.add(hour); - - // reset error messages - setError(null); - - /** - * Check if the variable "hour" exists in the "timeRange" set. - * If it does, reset the timepicker - */ - if (timeRange.has(hour)) { - setTimeRange(new Set()); - setReset(true); - const resetBefore = before.setDate(after.getDate() + numberOfDaysSelected.size - 1); - return onChange({ - after: after.setHours(0, 0, 0, 0) / 1000, - before: new Date(resetBefore).setHours(24, 0, 0, 0) / 1000, - }); - } - - //update after - if (_timeRange.size === 1) { - // check if the first selected value is within first day - const firstSelectedHour = Math.ceil(Math.max(..._timeRange)); - if (firstSelectedHour > 23) { - return setError('Select a time on the initial day!'); - } - - // calculate days offset - const dayOffsetAfter = new Date(after).setHours(Math.min(..._timeRange)); - - let dayOffsetBefore = before; - if (numberOfDaysSelected.size === 1) { - dayOffsetBefore = new Date(after).setHours(Math.min(..._timeRange) + 1); - } - - onChange({ - after: dayOffsetAfter / 1000, - before: dayOffsetBefore / 1000, - }); - } - - //update before - if (_timeRange.size > 1) { - let selectedDay = Math.ceil(Math.max(..._timeRange) / 24); - - // if user selects time 00:00 for the next day, add one day - if (hour === 24 && selectedDay === numberOfDaysSelected.size - 1) { - selectedDay += 1; - } - - // Check if end time is on the last day - if (selectedDay !== numberOfDaysSelected.size) { - return setError('Ending must occur on final day!'); - } - - // Check if end time is later than start time - const startHour = Math.min(..._timeRange); - if (hour <= startHour) { - return setError('Ending hour must be greater than start time!'); - } - - // Add all hours between start and end times to the set - for (let x = startHour; x <= hour; x++) { - _timeRange.add(x); - } - - // calculate days offset - const dayOffsetBefore = new Date(dateRange.after); - onChange({ - after: dateRange.after / 1000, - // we add one hour to get full 60min of last selected hour - before: dayOffsetBefore.setHours(Math.max(..._timeRange) + 1) / 1000, - }); - } - - for (let i = 0; i < _timeRange.size; i++) { - setTimeRange((timeRange) => timeRange.add(Array.from(_timeRange)[i])); - } - }, - [after, before, timeRange, dateRange.after, numberOfDaysSelected.size, onChange] - ); - const isSelected = useCallback( - (idx) => { - return !!timeRange.has(idx); - }, - [timeRange] - ); - - const isHovered = useCallback( - (idx) => { - return timeRange.size === 1 && idx > Math.max(...timeRange) && idx <= hoverIdx; - }, - [timeRange, hoverIdx] - ); + // Create repeating array with the number of hours for 1 day ...23,24,0,1,2... + const hoursInDays = Array.from({ length: 24 }, (_, i) => String(i % 24).padStart(2, '0')); // background colors for each day - const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none'; function randomGrayTone(shade) { const grayTones = [ 'bg-[#212529]/50', @@ -193,44 +29,72 @@ const TimePicker = ({ dateRange, onChange }) => { return grayTones[shade % grayTones.length]; } + const isSelected = (idx, current) => { + return current == `${idx}:00`; + }; + + const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none'; + const handleTime = (after, before) => { + setAfter(after); + setBefore(before); + onChange(`${after},${before}`); + }; + return ( <> - {error ? {error} : null}
-
-
- {hoursInDays.map((_, idx) => ( -
1 && Math.max(...timeRange) === idx ? 'rounded-b-lg' : ''}`} - onMouseEnter={() => setHoverIdx(idx)} - onMouseLeave={() => setHoverIdx(null)} - > -
+
+ + After + +
+ {hoursInDays.map((time, idx) => ( +
+
handleTime(idx)} - > - {hoursInDays[idx]}:00 + onClick={() => handleTime(`${time}:00`, before)} + > + {hoursInDays[idx]}:00 +
-
- ))} + ))} +
-
- +
+ + Before + +
+ {hoursInDays.map((time, idx) => ( +
+
handleTime(after, `${time}:00`)} + > + {hoursInDays[idx]}:00 +
+
+ ))} +
+
+ +
); diff --git a/web/src/routes/Events.jsx b/web/src/routes/Events.jsx index 1a692195b..9ba328a25 100644 --- a/web/src/routes/Events.jsx +++ b/web/src/routes/Events.jsx @@ -48,6 +48,8 @@ const monthsAgo = (num) => { export default function Events({ path, ...props }) { const apiHost = useApiHost(); + const { data: config } = useSWR('config'); + const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]); const [searchParams, setSearchParams] = useState({ before: null, after: null, @@ -55,6 +57,8 @@ export default function Events({ path, ...props }) { labels: props.labels ?? 'all', zones: props.zones ?? 'all', sub_labels: props.sub_labels ?? 'all', + time_range: '00:00,24:00', + timezone, favorites: props.favorites ?? 0, event: props.event, }); @@ -87,14 +91,17 @@ export default function Events({ path, ...props }) { showDeleteFavorite: false, }); - const eventsFetcher = useCallback((path, params) => { - if (searchParams.event) { - path = `${path}/${searchParams.event}`; - return axios.get(path).then((res) => [res.data]); - } - params = { ...params, include_thumbnails: 0, limit: API_LIMIT }; - return axios.get(path, { params }).then((res) => res.data); - }, [searchParams]); + const eventsFetcher = useCallback( + (path, params) => { + if (searchParams.event) { + path = `${path}/${searchParams.event}`; + return axios.get(path).then((res) => [res.data]); + } + params = { ...params, include_thumbnails: 0, limit: API_LIMIT }; + return axios.get(path, { params }).then((res) => res.data); + }, + [searchParams] + ); const getKey = useCallback( (index, prevData) => { @@ -111,8 +118,6 @@ export default function Events({ path, ...props }) { const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher); - const { data: config } = useSWR('config'); - const { data: allLabels } = useSWR(['labels']); const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]); @@ -239,6 +244,13 @@ export default function Events({ path, ...props }) { [searchParams, setSearchParams, state, setState] ); + const handleSelectTimeRange = useCallback( + (timeRange) => { + setSearchParams({ ...searchParams, time_range: timeRange }); + }, + [searchParams] + ); + const onFilter = useCallback( (name, value) => { const updatedParams = { ...searchParams, [name]: value }; @@ -265,12 +277,16 @@ export default function Events({ path, ...props }) { (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); + try { + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isDone) { + setSize(size + 1); + } + }); + if (node) observer.current.observe(node); + } catch (e) { + // no op + } }, [size, setSize, isValidating, isDone] ); @@ -361,7 +377,7 @@ export default function Events({ path, ...props }) { /> )} {searchParams.event && ( - )} @@ -399,7 +415,10 @@ export default function Events({ path, ...props }) { download /> )} - {(event?.data?.type || "object") == "object" && downloadEvent.end_time && downloadEvent.has_snapshot && !downloadEvent.plus_id && ( + {(event?.data?.type || 'object') == 'object' && + downloadEvent.end_time && + downloadEvent.has_snapshot && + !downloadEvent.plus_id && ( setState({ ...state, showCalendar: false })} > - + @@ -566,7 +582,11 @@ export default function Events({ path, ...props }) {

Confirm deletion of saved event.

-
- {event.zones.length ?
- - {event.zones.join(', ').replaceAll('_', ' ')} -
: null} + {event.zones.length ? ( +
+ + {event.zones.join(', ').replaceAll('_', ' ')} +
+ ) : null}
{(event?.data?.top_score || event.top_score || 0) == 0 @@ -650,7 +672,7 @@ export default function Events({ path, ...props }) {