mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									f92237c9c1
								
							
						
					
					
						commit
						1aba8c1ef5
					
				@ -156,7 +156,7 @@ Version info
 | 
				
			|||||||
Events from the database. Accepts the following query string parameters:
 | 
					Events from the database. Accepts the following query string parameters:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| param                | Type | Description                                     |
 | 
					| param                | Type | Description                                     |
 | 
				
			||||||
| -------------------- | ---- | --------------------------------------------- |
 | 
					| -------------------- | ---- | ----------------------------------------------- |
 | 
				
			||||||
| `before`             | int  | Epoch time                                      |
 | 
					| `before`             | int  | Epoch time                                      |
 | 
				
			||||||
| `after`              | int  | Epoch time                                      |
 | 
					| `after`              | int  | Epoch time                                      |
 | 
				
			||||||
| `cameras`            | str  | , separated list of cameras                     |
 | 
					| `cameras`            | str  | , separated list of cameras                     |
 | 
				
			||||||
@ -167,6 +167,8 @@ Events from the database. Accepts the following query string parameters:
 | 
				
			|||||||
| `has_clip`           | int  | Filter to events that have clips (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)     |
 | 
					| `include_thumbnails` | int  | Include thumbnails in the response (0 or 1)     |
 | 
				
			||||||
| `in_progress`        | int  | Limit to events in progress (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`
 | 
					### `GET /api/timeline`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -56,6 +56,8 @@ from frigate.version import VERSION
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DEFAULT_TIME_RANGE = "00:00,24:00"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
bp = Blueprint("frigate", __name__)
 | 
					bp = Blueprint("frigate", __name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -769,6 +771,7 @@ def events():
 | 
				
			|||||||
    limit = request.args.get("limit", 100)
 | 
					    limit = request.args.get("limit", 100)
 | 
				
			||||||
    after = request.args.get("after", type=float)
 | 
					    after = request.args.get("after", type=float)
 | 
				
			||||||
    before = request.args.get("before", 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_clip = request.args.get("has_clip", type=int)
 | 
				
			||||||
    has_snapshot = request.args.get("has_snapshot", type=int)
 | 
					    has_snapshot = request.args.get("has_snapshot", type=int)
 | 
				
			||||||
    in_progress = request.args.get("in_progress", type=int)
 | 
					    in_progress = request.args.get("in_progress", type=int)
 | 
				
			||||||
@ -851,6 +854,36 @@ def events():
 | 
				
			|||||||
    if before:
 | 
					    if before:
 | 
				
			||||||
        clauses.append((Event.start_time < 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:
 | 
					    if has_clip is not None:
 | 
				
			||||||
        clauses.append((Event.has_clip == has_clip))
 | 
					        clauses.append((Event.has_clip == has_clip))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -236,6 +236,44 @@ class TestHttp(unittest.TestCase):
 | 
				
			|||||||
            assert event["id"] == id
 | 
					            assert event["id"] == id
 | 
				
			||||||
            assert event["retain_indefinitely"] is False
 | 
					            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):
 | 
					    def test_set_delete_sub_label(self):
 | 
				
			||||||
        app = create_app(
 | 
					        app = create_app(
 | 
				
			||||||
            FrigateConfig(**self.minimal_config),
 | 
					            FrigateConfig(**self.minimal_config),
 | 
				
			||||||
@ -351,14 +389,17 @@ class TestHttp(unittest.TestCase):
 | 
				
			|||||||
            assert stats == self.test_stats
 | 
					            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."""
 | 
					    """Inserts a basic event model with a given id."""
 | 
				
			||||||
    return Event.insert(
 | 
					    return Event.insert(
 | 
				
			||||||
        id=id,
 | 
					        id=id,
 | 
				
			||||||
        label="Mock",
 | 
					        label="Mock",
 | 
				
			||||||
        camera="front_door",
 | 
					        camera="front_door",
 | 
				
			||||||
        start_time=datetime.datetime.now().timestamp(),
 | 
					        start_time=start_time,
 | 
				
			||||||
        end_time=datetime.datetime.now().timestamp() + 20,
 | 
					        end_time=start_time + 20,
 | 
				
			||||||
        top_score=100,
 | 
					        top_score=100,
 | 
				
			||||||
        false_positive=False,
 | 
					        false_positive=False,
 | 
				
			||||||
        zones=list(),
 | 
					        zones=list(),
 | 
				
			||||||
 | 
				
			|||||||
@ -1,182 +1,18 @@
 | 
				
			|||||||
import { h } from 'preact';
 | 
					import { h } from 'preact';
 | 
				
			||||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
 | 
					import { useState } from 'preact/hooks';
 | 
				
			||||||
import { ArrowDropdown } from '../icons/ArrowDropdown';
 | 
					import { ArrowDropdown } from '../icons/ArrowDropdown';
 | 
				
			||||||
import { ArrowDropup } from '../icons/ArrowDropup';
 | 
					import { ArrowDropup } from '../icons/ArrowDropup';
 | 
				
			||||||
 | 
					import Heading from './Heading';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const TimePicker = ({ dateRange, onChange }) => {
 | 
					const TimePicker = ({ timeRange, onChange }) => {
 | 
				
			||||||
  const [error, setError] = useState(null);
 | 
					  const times = timeRange.split(',');
 | 
				
			||||||
  const [timeRange, setTimeRange] = useState(new Set());
 | 
					  const [after, setAfter] = useState(times[0]);
 | 
				
			||||||
  const [hoverIdx, setHoverIdx] = useState(null);
 | 
					  const [before, setBefore] = useState(times[1]);
 | 
				
			||||||
  const [reset, setReset] = useState(false);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  // Create repeating array with the number of hours for 1 day ...23,24,0,1,2...
 | 
				
			||||||
   * Initializes two variables before and after with date objects,
 | 
					  const hoursInDays = Array.from({ length: 24 }, (_, i) => String(i % 24).padStart(2, '0'));
 | 
				
			||||||
   * 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]
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // background colors for each day
 | 
					  // background colors for each day
 | 
				
			||||||
  const isSelectedCss = 'bg-blue-600 transition duration-300 ease-in-out hover:rounded-none';
 | 
					 | 
				
			||||||
  function randomGrayTone(shade) {
 | 
					  function randomGrayTone(shade) {
 | 
				
			||||||
    const grayTones = [
 | 
					    const grayTones = [
 | 
				
			||||||
      'bg-[#212529]/50',
 | 
					      'bg-[#212529]/50',
 | 
				
			||||||
@ -193,45 +29,73 @@ const TimePicker = ({ dateRange, onChange }) => {
 | 
				
			|||||||
    return grayTones[shade % grayTones.length];
 | 
					    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 (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      {error ? <span className="text-red-400 text-center text-xs absolute top-1 right-0 pr-2">{error}</span> : null}
 | 
					 | 
				
			||||||
      <div className="mt-2 pr-3 hidden xs:block" aria-label="Calendar timepicker, select a time range">
 | 
					      <div className="mt-2 pr-3 hidden xs:block" aria-label="Calendar timepicker, select a time range">
 | 
				
			||||||
        <div className="flex items-center justify-center">
 | 
					        <div className="flex items-center justify-center">
 | 
				
			||||||
          <ArrowDropup className="w-10 text-center" />
 | 
					          <ArrowDropup className="w-10 text-center" />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div className="w-20 px-1">
 | 
					        <div className="px-1 flex justify-between">
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <Heading className="text-center" size="sm">
 | 
				
			||||||
 | 
					              After
 | 
				
			||||||
 | 
					            </Heading>
 | 
				
			||||||
            <div
 | 
					            <div
 | 
				
			||||||
            className="border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
 | 
					              className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
 | 
				
			||||||
              style={{ maxHeight: '17rem', overflowY: 'scroll' }}
 | 
					              style={{ maxHeight: '17rem', overflowY: 'scroll' }}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
            {hoursInDays.map((_, idx) => (
 | 
					              {hoursInDays.map((time, idx) => (
 | 
				
			||||||
              <div
 | 
					                <div className={`${isSelected(time, after) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
 | 
				
			||||||
                key={idx}
 | 
					 | 
				
			||||||
                id={`timeIndex-${idx}`}
 | 
					 | 
				
			||||||
                className={`${isSelected(idx) ? isSelectedCss : ''}
 | 
					 | 
				
			||||||
                ${isHovered(idx) ? 'opacity-30 bg-slate-900 transition duration-150 ease-in-out' : ''}
 | 
					 | 
				
			||||||
                ${Math.min(...timeRange) === idx ? 'rounded-t-lg' : ''}
 | 
					 | 
				
			||||||
                ${timeRange.size > 1 && Math.max(...timeRange) === idx ? 'rounded-b-lg' : ''}`}
 | 
					 | 
				
			||||||
                onMouseEnter={() => setHoverIdx(idx)}
 | 
					 | 
				
			||||||
                onMouseLeave={() => setHoverIdx(null)}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                  <div
 | 
					                  <div
 | 
				
			||||||
                    className={`
 | 
					                    className={`
 | 
				
			||||||
            text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
 | 
					            text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
 | 
				
			||||||
            ${randomGrayTone([Math.floor(idx / 24)])}`}
 | 
					            ${randomGrayTone([Math.floor(idx / 24)])}`}
 | 
				
			||||||
                  onClick={() => handleTime(idx)}
 | 
					                    onClick={() => handleTime(`${time}:00`, before)}
 | 
				
			||||||
                  >
 | 
					                  >
 | 
				
			||||||
                    <span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
 | 
					                    <span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
              ))}
 | 
					              ))}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <Heading className="text-center" size="sm">
 | 
				
			||||||
 | 
					              Before
 | 
				
			||||||
 | 
					            </Heading>
 | 
				
			||||||
 | 
					            <div
 | 
				
			||||||
 | 
					              className="w-20 border border-gray-400/50 cursor-pointer hide-scroll shadow-md rounded-md"
 | 
				
			||||||
 | 
					              style={{ maxHeight: '17rem', overflowY: 'scroll' }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {hoursInDays.map((time, idx) => (
 | 
				
			||||||
 | 
					                <div className={`${isSelected(time, before) ? isSelectedCss : ''}`} key={idx} id={`timeIndex-${idx}`}>
 | 
				
			||||||
 | 
					                  <div
 | 
				
			||||||
 | 
					                    className={`
 | 
				
			||||||
 | 
					            text-gray-300 w-full font-light border border-transparent hover:border hover:rounded-md hover:border-gray-600 text-center text-sm
 | 
				
			||||||
 | 
					            ${randomGrayTone([Math.floor(idx / 24)])}`}
 | 
				
			||||||
 | 
					                    onClick={() => handleTime(after, `${time}:00`)}
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                    <span aria-label={`${idx}:00`}>{hoursInDays[idx]}:00</span>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              ))}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
        <div className="flex items-center justify-center">
 | 
					        <div className="flex items-center justify-center">
 | 
				
			||||||
          <ArrowDropdown className="w-10 text-center" />
 | 
					          <ArrowDropdown className="w-10 text-center" />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -48,6 +48,8 @@ const monthsAgo = (num) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export default function Events({ path, ...props }) {
 | 
					export default function Events({ path, ...props }) {
 | 
				
			||||||
  const apiHost = useApiHost();
 | 
					  const apiHost = useApiHost();
 | 
				
			||||||
 | 
					  const { data: config } = useSWR('config');
 | 
				
			||||||
 | 
					  const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
 | 
				
			||||||
  const [searchParams, setSearchParams] = useState({
 | 
					  const [searchParams, setSearchParams] = useState({
 | 
				
			||||||
    before: null,
 | 
					    before: null,
 | 
				
			||||||
    after: null,
 | 
					    after: null,
 | 
				
			||||||
@ -55,6 +57,8 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
    labels: props.labels ?? 'all',
 | 
					    labels: props.labels ?? 'all',
 | 
				
			||||||
    zones: props.zones ?? 'all',
 | 
					    zones: props.zones ?? 'all',
 | 
				
			||||||
    sub_labels: props.sub_labels ?? 'all',
 | 
					    sub_labels: props.sub_labels ?? 'all',
 | 
				
			||||||
 | 
					    time_range: '00:00,24:00',
 | 
				
			||||||
 | 
					    timezone,
 | 
				
			||||||
    favorites: props.favorites ?? 0,
 | 
					    favorites: props.favorites ?? 0,
 | 
				
			||||||
    event: props.event,
 | 
					    event: props.event,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
@ -87,14 +91,17 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
    showDeleteFavorite: false,
 | 
					    showDeleteFavorite: false,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const eventsFetcher = useCallback((path, params) => {
 | 
					  const eventsFetcher = useCallback(
 | 
				
			||||||
 | 
					    (path, params) => {
 | 
				
			||||||
      if (searchParams.event) {
 | 
					      if (searchParams.event) {
 | 
				
			||||||
        path = `${path}/${searchParams.event}`;
 | 
					        path = `${path}/${searchParams.event}`;
 | 
				
			||||||
        return axios.get(path).then((res) => [res.data]);
 | 
					        return axios.get(path).then((res) => [res.data]);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
 | 
					      params = { ...params, include_thumbnails: 0, limit: API_LIMIT };
 | 
				
			||||||
      return axios.get(path, { params }).then((res) => res.data);
 | 
					      return axios.get(path, { params }).then((res) => res.data);
 | 
				
			||||||
  }, [searchParams]);
 | 
					    },
 | 
				
			||||||
 | 
					    [searchParams]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const getKey = useCallback(
 | 
					  const getKey = useCallback(
 | 
				
			||||||
    (index, prevData) => {
 | 
					    (index, prevData) => {
 | 
				
			||||||
@ -111,8 +118,6 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
 | 
					  const { data: eventPages, mutate, size, setSize, isValidating } = useSWRInfinite(getKey, eventsFetcher);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { data: config } = useSWR('config');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const { data: allLabels } = useSWR(['labels']);
 | 
					  const { data: allLabels } = useSWR(['labels']);
 | 
				
			||||||
  const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
 | 
					  const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -239,6 +244,13 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
    [searchParams, setSearchParams, state, setState]
 | 
					    [searchParams, setSearchParams, state, setState]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSelectTimeRange = useCallback(
 | 
				
			||||||
 | 
					    (timeRange) => {
 | 
				
			||||||
 | 
					      setSearchParams({ ...searchParams, time_range: timeRange });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    [searchParams]
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onFilter = useCallback(
 | 
					  const onFilter = useCallback(
 | 
				
			||||||
    (name, value) => {
 | 
					    (name, value) => {
 | 
				
			||||||
      const updatedParams = { ...searchParams, [name]: value };
 | 
					      const updatedParams = { ...searchParams, [name]: value };
 | 
				
			||||||
@ -265,12 +277,16 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
    (node) => {
 | 
					    (node) => {
 | 
				
			||||||
      if (isValidating) return;
 | 
					      if (isValidating) return;
 | 
				
			||||||
      if (observer.current) observer.current.disconnect();
 | 
					      if (observer.current) observer.current.disconnect();
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
        observer.current = new IntersectionObserver((entries) => {
 | 
					        observer.current = new IntersectionObserver((entries) => {
 | 
				
			||||||
          if (entries[0].isIntersecting && !isDone) {
 | 
					          if (entries[0].isIntersecting && !isDone) {
 | 
				
			||||||
            setSize(size + 1);
 | 
					            setSize(size + 1);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
        if (node) observer.current.observe(node);
 | 
					        if (node) observer.current.observe(node);
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        // no op
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [size, setSize, isValidating, isDone]
 | 
					    [size, setSize, isValidating, isDone]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
@ -361,7 +377,7 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
          />
 | 
					          />
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {searchParams.event && (
 | 
					        {searchParams.event && (
 | 
				
			||||||
          <Button className="ml-2" onClick={() => onFilter('event',null)} type="text">
 | 
					          <Button className="ml-2" onClick={() => onFilter('event', null)} type="text">
 | 
				
			||||||
            View All
 | 
					            View All
 | 
				
			||||||
          </Button>
 | 
					          </Button>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
@ -399,7 +415,10 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
              download
 | 
					              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 && (
 | 
				
			||||||
            <MenuItem
 | 
					            <MenuItem
 | 
				
			||||||
              icon={UploadPlus}
 | 
					              icon={UploadPlus}
 | 
				
			||||||
              label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
 | 
					              label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
 | 
				
			||||||
@ -459,10 +478,7 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
              dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
 | 
					              dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
 | 
				
			||||||
              close={() => setState({ ...state, showCalendar: false })}
 | 
					              close={() => setState({ ...state, showCalendar: false })}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <Timepicker
 | 
					              <Timepicker timeRange={searchParams.time_range} onChange={handleSelectTimeRange} />
 | 
				
			||||||
                dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
 | 
					 | 
				
			||||||
                onChange={handleSelectDateRange}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
            </Calendar>
 | 
					            </Calendar>
 | 
				
			||||||
          </Menu>
 | 
					          </Menu>
 | 
				
			||||||
        </span>
 | 
					        </span>
 | 
				
			||||||
@ -566,7 +582,11 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
            <p className="mb-2">Confirm deletion of saved event.</p>
 | 
					            <p className="mb-2">Confirm deletion of saved event.</p>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div className="p-2 flex justify-start flex-row-reverse space-x-2">
 | 
					          <div className="p-2 flex justify-start flex-row-reverse space-x-2">
 | 
				
			||||||
            <Button className="ml-2" onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })} type="text">
 | 
					            <Button
 | 
				
			||||||
 | 
					              className="ml-2"
 | 
				
			||||||
 | 
					              onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })}
 | 
				
			||||||
 | 
					              type="text"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
              Cancel
 | 
					              Cancel
 | 
				
			||||||
            </Button>
 | 
					            </Button>
 | 
				
			||||||
            <Button
 | 
					            <Button
 | 
				
			||||||
@ -635,10 +655,12 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
                          <Camera className="h-5 w-5 mr-2 inline" />
 | 
					                          <Camera className="h-5 w-5 mr-2 inline" />
 | 
				
			||||||
                          {event.camera.replaceAll('_', ' ')}
 | 
					                          {event.camera.replaceAll('_', ' ')}
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                        {event.zones.length ? <div className="capitalize  text-sm flex align-center">
 | 
					                        {event.zones.length ? (
 | 
				
			||||||
 | 
					                          <div className="capitalize  text-sm flex align-center">
 | 
				
			||||||
                            <Zone className="w-5 h-5 mr-2 inline" />
 | 
					                            <Zone className="w-5 h-5 mr-2 inline" />
 | 
				
			||||||
                            {event.zones.join(', ').replaceAll('_', ' ')}
 | 
					                            {event.zones.join(', ').replaceAll('_', ' ')}
 | 
				
			||||||
                        </div> : null}
 | 
					                          </div>
 | 
				
			||||||
 | 
					                        ) : null}
 | 
				
			||||||
                        <div className="capitalize  text-sm flex align-center">
 | 
					                        <div className="capitalize  text-sm flex align-center">
 | 
				
			||||||
                          <Score className="w-5 h-5 mr-2 inline" />
 | 
					                          <Score className="w-5 h-5 mr-2 inline" />
 | 
				
			||||||
                          {(event?.data?.top_score || event.top_score || 0) == 0
 | 
					                          {(event?.data?.top_score || event.top_score || 0) == 0
 | 
				
			||||||
@ -650,7 +672,7 @@ export default function Events({ path, ...props }) {
 | 
				
			|||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                      </div>
 | 
					                      </div>
 | 
				
			||||||
                      <div class="hidden sm:flex flex-col justify-end mr-2">
 | 
					                      <div class="hidden sm:flex flex-col justify-end mr-2">
 | 
				
			||||||
                        {event.end_time && event.has_snapshot && (event?.data?.type || "object") == "object" && (
 | 
					                        {event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
 | 
				
			||||||
                          <Fragment>
 | 
					                          <Fragment>
 | 
				
			||||||
                            {event.plus_id ? (
 | 
					                            {event.plus_id ? (
 | 
				
			||||||
                              <div className="uppercase text-xs underline">
 | 
					                              <div className="uppercase text-xs underline">
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user