diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index d79b4b3cc..f02756bce 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -159,9 +159,9 @@ Events from the database. Accepts the following query string parameters: | -------------------- | ---- | --------------------------------------------- | | `before` | int | Epoch time | | `after` | int | Epoch time | -| `camera` | str | Camera name | -| `label` | str | Label name | -| `zone` | str | Zone name | +| `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) | diff --git a/frigate/http.py b/frigate/http.py index 29e5449cc..a4a7c22cd 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -278,6 +278,8 @@ def set_sub_label(id): @bp.route("/sub_labels") def get_sub_labels(): + split_joined = request.args.get("split_joined", type=int) + try: events = Event.select(Event.sub_label).distinct() except Exception as e: @@ -290,6 +292,16 @@ def get_sub_labels(): if None in sub_labels: sub_labels.remove(None) + if split_joined: + for label in sub_labels: + if "," in label: + sub_labels.remove(label) + parts = label.split(",") + + for part in parts: + if not (part.strip()) in sub_labels: + sub_labels.append(part.strip()) + return jsonify(sub_labels) @@ -519,11 +531,35 @@ def event_clip(id): @bp.route("/events") def events(): - limit = request.args.get("limit", 100) camera = request.args.get("camera", "all") + cameras = request.args.get("cameras", "all") + + # handle old camera arg + if cameras == "all" and camera != "all": + cameras = camera + label = unquote(request.args.get("label", "all")) + labels = request.args.get("labels", "all") + + # handle old label arg + if labels == "all" and label != "all": + labels = label + sub_label = request.args.get("sub_label", "all") + sub_labels = request.args.get("sub_labels", "all") + + # handle old sub_label arg + if sub_labels == "all" and sub_label != "all": + sub_labels = sub_label + zone = request.args.get("zone", "all") + zones = request.args.get("zones", "all") + + # handle old label arg + if zones == "all" and zone != "all": + zones = zone + + limit = request.args.get("limit", 100) after = request.args.get("after", type=float) before = request.args.get("before", type=float) has_clip = request.args.get("has_clip", type=int) @@ -551,14 +587,36 @@ def events(): if camera != "all": clauses.append((Event.camera == camera)) - if label != "all": - clauses.append((Event.label == label)) + if cameras != "all": + camera_list = cameras.split(",") + clauses.append((Event.camera << camera_list)) - if sub_label != "all": - clauses.append((Event.sub_label == sub_label)) + if labels != "all": + label_list = labels.split(",") + clauses.append((Event.label << label_list)) - if zone != "all": - clauses.append((Event.zones.cast("text") % f'*"{zone}"*')) + if sub_labels != "all": + # use matching so joined sub labels are included + # for example a sub label 'bob' would get events + # with sub labels 'bob' and 'bob, john' + sub_label_clauses = [] + + for label in sub_labels.split(","): + sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label}*")) + + sub_label_clause = reduce(operator.or_, sub_label_clauses) + clauses.append((sub_label_clause)) + + if zones != "all": + # use matching so events with multiple zones + # still match on a search where any zone matches + zone_clauses = [] + + for zone in zones.split(","): + zone_clauses.append((Event.zones.cast("text") % f'*"{zone}"*')) + + zone_clause = reduce(operator.or_, zone_clauses) + clauses.append((zone_clause)) if after: clauses.append((Event.start_time > after)) diff --git a/web/src/components/MultiSelect.jsx b/web/src/components/MultiSelect.jsx new file mode 100644 index 000000000..0e41a0503 --- /dev/null +++ b/web/src/components/MultiSelect.jsx @@ -0,0 +1,43 @@ +import { h } from 'preact'; +import { useRef, useState } from 'preact/hooks'; +import Menu from './Menu'; +import { ArrowDropdown } from '../icons/ArrowDropdown'; +import Heading from './Heading'; + +export default function MultiSelect({ className, title, options, selection, onToggle }) { + + const popupRef = useRef(null); + + const [state, setState] = useState({ + showMenu: false, + }); + + return ( +
+
setState({ showMenu: true })} + > + + +
+ {state.showMenu ? ( + setState({ showMenu: false })}> + {title} + {options.map((item) => ( + + ))} + + ): null} +
+ ); +} \ No newline at end of file diff --git a/web/src/routes/Camera.jsx b/web/src/routes/Camera.jsx index 57e7c9e7e..abcda7d94 100644 --- a/web/src/routes/Camera.jsx +++ b/web/src/routes/Camera.jsx @@ -171,7 +171,7 @@ export default function Camera({ camera }) { className="mb-4 mr-4" key={objectType} header={objectType} - href={`/events?camera=${camera}&label=${encodeURIComponent(objectType)}`} + href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`} media={} /> ))} diff --git a/web/src/routes/Events.jsx b/web/src/routes/Events.jsx index 417423cca..f259c31af 100644 --- a/web/src/routes/Events.jsx +++ b/web/src/routes/Events.jsx @@ -23,6 +23,7 @@ import Calendar from '../components/Calendar'; import Button from '../components/Button'; import Dialog from '../components/Dialog'; import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns'; +import MultiSelect from '../components/MultiSelect'; const API_LIMIT = 25; @@ -53,10 +54,10 @@ export default function Events({ path, ...props }) { const [searchParams, setSearchParams] = useState({ before: null, after: null, - camera: props.camera ?? 'all', - label: props.label ?? 'all', - zone: props.zone ?? 'all', - sub_label: props.sub_label ?? 'all', + cameras: props.cameras ?? 'all', + labels: props.labels ?? 'all', + zones: props.zones ?? 'all', + sub_labels: props.sub_labels ?? 'all', }); const [state, setState] = useState({ showDownloadMenu: false, @@ -100,7 +101,7 @@ export default function Events({ path, ...props }) { const { data: config } = useSWR('config'); - const { data: allSubLabels } = useSWR('sub_labels'); + const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]); const filterValues = useMemo( () => ({ @@ -148,6 +149,40 @@ export default function Events({ path, ...props }) { } }; + 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 datePicker = useRef(); const downloadButton = useRef(); @@ -260,56 +295,37 @@ export default function Events({ path, ...props }) {
Events
- - - - {filterValues.sub_labels.length > 0 && ( - - )} + title="Zones" + options={filterValues.zones} + selection={searchParams.zones} + onToggle={(item) => onToggleNamedFilter("zones", item) } + /> + { + filterValues.sub_labels.length > 0 && ( + onToggleNamedFilter("sub_labels", item) } + /> + )}