mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +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]
|
||||||
);
|
);
|
||||||
@ -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