import { h, Fragment } from 'preact'; import { route } from 'preact-router'; import ActivityIndicator from '../components/ActivityIndicator'; import Heading from '../components/Heading'; import { Tabs, TextTab } from '../components/Tabs'; import Link from '../components/Link'; import { useApiHost } from '../api'; import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite'; import axios from 'axios'; import { useState, useRef, useCallback, useMemo } from 'preact/hooks'; import VideoPlayer from '../components/VideoPlayer'; import { StarRecording } from '../icons/StarRecording'; import { Snapshot } from '../icons/Snapshot'; import { UploadPlus } from '../icons/UploadPlus'; import { Clip } from '../icons/Clip'; import { Zone } from '../icons/Zone'; import { Camera } from '../icons/Camera'; import { Clock } from '../icons/Clock'; import { Delete } from '../icons/Delete'; import { Download } from '../icons/Download'; import Menu, { MenuItem } from '../components/Menu'; import CalendarIcon from '../icons/Calendar'; import Calendar from '../components/Calendar'; import Button from '../components/Button'; import Dialog from '../components/Dialog'; import MultiSelect from '../components/MultiSelect'; import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil'; import TimeAgo from '../components/TimeAgo'; import Timepicker from '../components/TimePicker'; import TimelineSummary from '../components/TimelineSummary'; import TimelineEventOverlay from '../components/TimelineEventOverlay'; const API_LIMIT = 25; const daysAgo = (num) => { let date = new Date(); date.setDate(date.getDate() - num); return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000; }; const monthsAgo = (num) => { let date = new Date(); date.setMonth(date.getMonth() - num); return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000; }; export default function Events({ path, ...props }) { const apiHost = useApiHost(); const [searchParams, setSearchParams] = useState({ before: null, after: null, cameras: props.cameras ?? 'all', labels: props.labels ?? 'all', zones: props.zones ?? 'all', sub_labels: props.sub_labels ?? 'all', favorites: props.favorites ?? 0, }); const [state, setState] = useState({ showDownloadMenu: false, showDatePicker: false, showCalendar: false, showPlusSubmit: false, }); const [plusSubmitEvent, setPlusSubmitEvent] = useState({ id: null, label: null, validBox: null, }); const [uploading, setUploading] = useState([]); const [viewEvent, setViewEvent] = useState(); const [eventOverlay, setEventOverlay] = useState(); const [eventDetailType, setEventDetailType] = useState('clip'); const [downloadEvent, setDownloadEvent] = useState({ id: null, label: null, box: null, has_clip: false, has_snapshot: false, plus_id: undefined, end_time: null, }); const [deleteFavoriteState, setDeleteFavoriteState] = useState({ deletingFavoriteEventId: null, showDeleteFavorite: false, }); const eventsFetcher = useCallback((path, params) => { params = { ...params, include_thumbnails: 0, limit: API_LIMIT }; return axios.get(path, { params }).then((res) => res.data); }, []); const getKey = useCallback( (index, prevData) => { if (index > 0) { const lastDate = prevData[prevData.length - 1].start_time; const pagedParams = { ...searchParams, before: lastDate }; return ['events', pagedParams]; } return ['events', searchParams]; }, [searchParams] ); 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 }]); const filterValues = useMemo( () => ({ cameras: Object.keys(config?.cameras || {}), zones: [ ...Object.values(config?.cameras || {}) .reduce((memo, camera) => { memo = memo.concat(Object.keys(camera?.zones || {})); return memo; }, []) .filter((value, i, self) => self.indexOf(value) === i), 'None', ], labels: Object.values(allLabels || {}), sub_labels: (allSubLabels || []).length > 0 ? [...Object.values(allSubLabels), 'None'] : [], }), [config, allLabels, allSubLabels] ); const onSave = async (e, eventId, save) => { e.stopPropagation(); let response; if (save) { response = await axios.post(`events/${eventId}/retain`); } else { response = await axios.delete(`events/${eventId}/retain`); } if (response.status === 200) { mutate(); } }; const onDelete = async (e, eventId, saved) => { e.stopPropagation(); if (saved) { setDeleteFavoriteState({ deletingFavoriteEventId: eventId, showDeleteFavorite: true }); } else { const response = await axios.delete(`events/${eventId}`); if (response.status === 200) { mutate(); } } }; const onToggleNamedFilter = (name, item) => { let items; if (searchParams[name] == 'all') { const currentItems = Array.from(filterValues[name]); // don't remove all if only one option if (currentItems.length > 1) { currentItems.splice(currentItems.indexOf(item), 1); items = currentItems.join(','); } else { items = ['all']; } } else { let currentItems = searchParams[name].length > 0 ? searchParams[name].split(',') : []; if (currentItems.includes(item)) { // don't remove the last item in the filter list if (currentItems.length > 1) { currentItems.splice(currentItems.indexOf(item), 1); } items = currentItems.join(','); } else if (currentItems.length + 1 == filterValues[name].length) { items = ['all']; } else { currentItems.push(item); items = currentItems.join(','); } } onFilter(name, items); }; const onEventFrameSelected = (event, frame, seekSeconds) => { if (this.player) { this.player.pause(); this.player.currentTime(seekSeconds); setEventOverlay(frame); } }; const datePicker = useRef(); const downloadButton = useRef(); const onDownloadClick = (e, event) => { e.stopPropagation(); setDownloadEvent((_prev) => ({ id: event.id, box: event?.data?.box || event.box, label: event.label, has_clip: event.has_clip, has_snapshot: event.has_snapshot, plus_id: event.plus_id, end_time: event.end_time, })); downloadButton.current = e.target; setState({ ...state, showDownloadMenu: true }); }; const showSubmitToPlus = (event_id, label, box, e) => { if (e) { e.stopPropagation(); } // if any of the box coordinates are > 1, then the box data is from an older version // and not valid to submit to plus with the snapshot image setPlusSubmitEvent({ id: event_id, label, validBox: !box.some((d) => d > 1) }); setState({ ...state, showDownloadMenu: false, showPlusSubmit: true }); }; const handleSelectDateRange = useCallback( (dates) => { setSearchParams({ ...searchParams, before: dates.before, after: dates.after }); setState({ ...state, showDatePicker: false }); }, [searchParams, setSearchParams, state, setState] ); const onFilter = useCallback( (name, value) => { const updatedParams = { ...searchParams, [name]: value }; setSearchParams(updatedParams); const queryString = Object.keys(updatedParams) .map((key) => { if (updatedParams[key] && updatedParams[key] != 'all') { return `${key}=${updatedParams[key]}`; } return null; }) .filter((val) => val) .join('&'); route(`${path}?${queryString}`); }, [path, searchParams, setSearchParams] ); const isDone = (eventPages?.[eventPages.length - 1]?.length ?? 0) < API_LIMIT; // hooks for infinite scroll const observer = useRef(); const lastEventRef = useCallback( (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); }, [size, setSize, isValidating, isDone] ); const onSendToPlus = async (id, false_positive, validBox) => { if (uploading.includes(id)) { return; } setUploading((prev) => [...prev, id]); const response = false_positive ? await axios.put(`events/${id}/false_positive`) : await axios.post(`events/${id}/plus`, validBox ? { include_annotation: 1 } : {}); if (response.status === 200) { mutate( (pages) => pages.map((page) => page.map((event) => { if (event.id === id) { return { ...event, plus_id: response.data.plus_id }; } return event; }) ), false ); } setUploading((prev) => prev.filter((i) => i !== id)); if (state.showDownloadMenu && downloadEvent.id === id) { setState({ ...state, showDownloadMenu: false }); } setState({ ...state, showPlusSubmit: false }); }; const handleEventDetailTabChange = (index) => { setEventDetailType(index == 0 ? 'clip' : 'image'); }; if (!config) { return ; } return (
Events
onToggleNamedFilter('cameras', item)} onShowAll={() => onFilter('cameras', ['all'])} onSelectSingle={(item) => onFilter('cameras', item)} /> onToggleNamedFilter('labels', item)} onShowAll={() => onFilter('labels', ['all'])} onSelectSingle={(item) => onFilter('labels', item)} /> onToggleNamedFilter('zones', item)} onShowAll={() => onFilter('zones', ['all'])} onSelectSingle={(item) => onFilter('zones', item)} /> {filterValues.sub_labels.length > 0 && ( onToggleNamedFilter('sub_labels', item)} onShowAll={() => onFilter('sub_labels', ['all'])} onSelectSingle={(item) => onFilter('sub_labels', item)} /> )} onFilter('favorites', searchParams.favorites ? 0 : 1)} fill={searchParams.favorites == 1 ? 'currentColor' : 'none'} />
setState({ ...state, showDatePicker: true })} />
{state.showDownloadMenu && ( setState({ ...state, showDownloadMenu: false })} relativeTo={downloadButton}> {downloadEvent.has_snapshot && ( )} {downloadEvent.has_clip && ( )} {downloadEvent.end_time && downloadEvent.has_snapshot && !downloadEvent.plus_id && ( showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)} /> )} {downloadEvent.plus_id && ( setState({ ...state, showDownloadMenu: false })} /> )} )} {state.showDatePicker && ( setState({ ...state, setShowDatePicker: false })} relativeTo={datePicker} > { setState({ ...state, showCalendar: true, showDatePicker: false }); }} /> )} {state.showCalendar && ( setState({ ...state, showCalendar: false })} relativeTo={datePicker} > setState({ ...state, showCalendar: false })} > )} {state.showPlusSubmit && ( {config.plus.enabled ? ( <>
Submit to Frigate+ {`${plusSubmitEvent.label}`} {plusSubmitEvent.validBox ? (

Objects in locations you want to avoid are not false positives. Submitting them as false positives will confuse the model.

) : (

Events prior to version 0.13 can only be submitted to Frigate+ without annotations.

)}
{plusSubmitEvent.validBox ? (
) : (
)} ) : ( <>
Setup a Frigate+ Account

In order to submit images to Frigate+, you first need to setup an account.

https://plus.frigate.video
)}
)} {deleteFavoriteState.showDeleteFavorite && (
Delete Saved Event?

Confirm deletion of saved event.

)}
{eventPages ? ( eventPages.map((page, i) => { const lastPage = eventPages.length === i + 1; return page.map((event, j) => { const lastEvent = lastPage && page.length === j + 1; return (
(viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))} >
onSave(e, event.id, !event.retain_indefinitely)} fill={event.retain_indefinitely ? 'currentColor' : 'none'} /> {event.end_time ? null : (
In progress
)}
{event.sub_label ? `${event.label.replaceAll('_', ' ')}: ${event.sub_label.replaceAll('_', ' ')}` : event.label.replaceAll('_', ' ')} {(event?.data?.top_score || event.top_score || 0) == 0 ? null : ` (${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%)`}
{formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}
-
( {getDurationFromTimestamps(event.start_time, event.end_time)} )
{event.camera.replaceAll('_', ' ')}
{event.zones.join(', ').replaceAll('_', ' ')}
onDelete(e, event.id, event.retain_indefinitely)} /> onDownloadClick(e, event)} />
{viewEvent !== event.id ? null : (
{eventDetailType == 'clip' && event.has_clip ? (
onEventFrameSelected(event, frame, seekSeconds) } />
{ this.player = player; this.player.on('playing', () => { setEventOverlay(undefined); }); }} onDispose={() => { this.player = null; }} > {eventOverlay ? ( ) : null}
) : null} {eventDetailType == 'image' || !event.has_clip ? (
{`${event.label}
) : null}
)}
); }); }) ) : ( )}
{isDone ? null : }
); }