mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Add multiselect filtering to events view (#3367)
This commit is contained in:
parent
45c43d7cf5
commit
b1ec56de29
@ -159,9 +159,9 @@ Events from the database. Accepts the following query string parameters:
|
|||||||
| -------------------- | ---- | --------------------------------------------- |
|
| -------------------- | ---- | --------------------------------------------- |
|
||||||
| `before` | int | Epoch time |
|
| `before` | int | Epoch time |
|
||||||
| `after` | int | Epoch time |
|
| `after` | int | Epoch time |
|
||||||
| `camera` | str | Camera name |
|
| `cameras` | str | , separated list of cameras |
|
||||||
| `label` | str | Label name |
|
| `labels` | str | , separated list of labels |
|
||||||
| `zone` | str | Zone name |
|
| `zones` | str | , separated list of zones |
|
||||||
| `limit` | int | Limit the number of events returned |
|
| `limit` | int | Limit the number of events returned |
|
||||||
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
|
| `has_snapshot` | int | Filter to events that have snapshots (0 or 1) |
|
||||||
| `has_clip` | int | Filter to events that have clips (0 or 1) |
|
| `has_clip` | int | Filter to events that have clips (0 or 1) |
|
||||||
|
@ -278,6 +278,8 @@ def set_sub_label(id):
|
|||||||
|
|
||||||
@bp.route("/sub_labels")
|
@bp.route("/sub_labels")
|
||||||
def get_sub_labels():
|
def get_sub_labels():
|
||||||
|
split_joined = request.args.get("split_joined", type=int)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
events = Event.select(Event.sub_label).distinct()
|
events = Event.select(Event.sub_label).distinct()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -290,6 +292,16 @@ def get_sub_labels():
|
|||||||
if None in sub_labels:
|
if None in sub_labels:
|
||||||
sub_labels.remove(None)
|
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)
|
return jsonify(sub_labels)
|
||||||
|
|
||||||
|
|
||||||
@ -519,11 +531,35 @@ def event_clip(id):
|
|||||||
|
|
||||||
@bp.route("/events")
|
@bp.route("/events")
|
||||||
def events():
|
def events():
|
||||||
limit = request.args.get("limit", 100)
|
|
||||||
camera = request.args.get("camera", "all")
|
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"))
|
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_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")
|
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)
|
after = request.args.get("after", type=float)
|
||||||
before = request.args.get("before", type=float)
|
before = request.args.get("before", type=float)
|
||||||
has_clip = request.args.get("has_clip", type=int)
|
has_clip = request.args.get("has_clip", type=int)
|
||||||
@ -551,14 +587,36 @@ def events():
|
|||||||
if camera != "all":
|
if camera != "all":
|
||||||
clauses.append((Event.camera == camera))
|
clauses.append((Event.camera == camera))
|
||||||
|
|
||||||
if label != "all":
|
if cameras != "all":
|
||||||
clauses.append((Event.label == label))
|
camera_list = cameras.split(",")
|
||||||
|
clauses.append((Event.camera << camera_list))
|
||||||
|
|
||||||
if sub_label != "all":
|
if labels != "all":
|
||||||
clauses.append((Event.sub_label == sub_label))
|
label_list = labels.split(",")
|
||||||
|
clauses.append((Event.label << label_list))
|
||||||
|
|
||||||
if zone != "all":
|
if sub_labels != "all":
|
||||||
clauses.append((Event.zones.cast("text") % f'*"{zone}"*'))
|
# 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:
|
if after:
|
||||||
clauses.append((Event.start_time > after))
|
clauses.append((Event.start_time > after))
|
||||||
|
43
web/src/components/MultiSelect.jsx
Normal file
43
web/src/components/MultiSelect.jsx
Normal file
@ -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 (
|
||||||
|
<div className={`${className} p-2`} ref={popupRef}>
|
||||||
|
<div
|
||||||
|
className="flex justify-between min-w-[120px]"
|
||||||
|
onClick={() => setState({ showMenu: true })}
|
||||||
|
>
|
||||||
|
<label>{title}</label>
|
||||||
|
<ArrowDropdown className="w-6" />
|
||||||
|
</div>
|
||||||
|
{state.showMenu ? (
|
||||||
|
<Menu relativeTo={popupRef} onDismiss={() => setState({ showMenu: false })}>
|
||||||
|
<Heading className="p-4 justify-center" size="md">{title}</Heading>
|
||||||
|
{options.map((item) => (
|
||||||
|
<label
|
||||||
|
className={`flex flex-shrink space-x-2 p-1 my-1 min-w-[176px] hover:bg-gray-200 dark:hover:bg-gray-800 dark:hover:text-white cursor-pointer capitalize text-sm`}
|
||||||
|
key={item}>
|
||||||
|
<input
|
||||||
|
className="mx-4 m-0 align-middle"
|
||||||
|
type="checkbox"
|
||||||
|
checked={selection == "all" || selection.indexOf(item) > -1}
|
||||||
|
onChange={() => onToggle(item)} />
|
||||||
|
{item.replaceAll("_", " ")}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
): null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -171,7 +171,7 @@ export default function Camera({ camera }) {
|
|||||||
className="mb-4 mr-4"
|
className="mb-4 mr-4"
|
||||||
key={objectType}
|
key={objectType}
|
||||||
header={objectType}
|
header={objectType}
|
||||||
href={`/events?camera=${camera}&label=${encodeURIComponent(objectType)}`}
|
href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`}
|
||||||
media={<img src={`${apiHost}/api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
|
media={<img src={`${apiHost}/api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -23,6 +23,7 @@ import Calendar from '../components/Calendar';
|
|||||||
import Button from '../components/Button';
|
import Button from '../components/Button';
|
||||||
import Dialog from '../components/Dialog';
|
import Dialog from '../components/Dialog';
|
||||||
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
|
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
|
||||||
|
import MultiSelect from '../components/MultiSelect';
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
const API_LIMIT = 25;
|
||||||
|
|
||||||
@ -53,10 +54,10 @@ export default function Events({ path, ...props }) {
|
|||||||
const [searchParams, setSearchParams] = useState({
|
const [searchParams, setSearchParams] = useState({
|
||||||
before: null,
|
before: null,
|
||||||
after: null,
|
after: null,
|
||||||
camera: props.camera ?? 'all',
|
cameras: props.cameras ?? 'all',
|
||||||
label: props.label ?? 'all',
|
labels: props.labels ?? 'all',
|
||||||
zone: props.zone ?? 'all',
|
zones: props.zones ?? 'all',
|
||||||
sub_label: props.sub_label ?? 'all',
|
sub_labels: props.sub_labels ?? 'all',
|
||||||
});
|
});
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
showDownloadMenu: false,
|
showDownloadMenu: false,
|
||||||
@ -100,7 +101,7 @@ export default function Events({ path, ...props }) {
|
|||||||
|
|
||||||
const { data: config } = useSWR('config');
|
const { data: config } = useSWR('config');
|
||||||
|
|
||||||
const { data: allSubLabels } = useSWR('sub_labels');
|
const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
|
||||||
|
|
||||||
const filterValues = useMemo(
|
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 datePicker = useRef();
|
||||||
|
|
||||||
const downloadButton = useRef();
|
const downloadButton = useRef();
|
||||||
@ -260,56 +295,37 @@ export default function Events({ path, ...props }) {
|
|||||||
<div className="space-y-4 p-2 px-4 w-full">
|
<div className="space-y-4 p-2 px-4 w-full">
|
||||||
<Heading>Events</Heading>
|
<Heading>Events</Heading>
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<select
|
<MultiSelect
|
||||||
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
value={searchParams.camera}
|
title="Cameras"
|
||||||
onChange={(e) => onFilter('camera', e.target.value)}
|
options={filterValues.cameras}
|
||||||
>
|
selection={searchParams.cameras}
|
||||||
<option value="all">all cameras</option>
|
onToggle={(item) => onToggleNamedFilter("cameras", item)}
|
||||||
{filterValues.cameras.map((item) => (
|
/>
|
||||||
<option key={item} value={item}>
|
<MultiSelect
|
||||||
{item.replaceAll('_', ' ')}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
value={searchParams.label}
|
title="Labels"
|
||||||
onChange={(e) => onFilter('label', e.target.value)}
|
options={filterValues.labels}
|
||||||
>
|
selection={searchParams.labels}
|
||||||
<option value="all">all labels</option>
|
onToggle={(item) => onToggleNamedFilter("labels", item) }
|
||||||
{filterValues.labels.map((item) => (
|
/>
|
||||||
<option key={item.replaceAll('_', ' ')} value={item}>
|
<MultiSelect
|
||||||
{item}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
value={searchParams.zone}
|
title="Zones"
|
||||||
onChange={(e) => onFilter('zone', e.target.value)}
|
options={filterValues.zones}
|
||||||
>
|
selection={searchParams.zones}
|
||||||
<option value="all">all zones</option>
|
onToggle={(item) => onToggleNamedFilter("zones", item) }
|
||||||
{filterValues.zones.map((item) => (
|
/>
|
||||||
<option key={item} value={item}>
|
{
|
||||||
{item.replaceAll('_', ' ')}
|
filterValues.sub_labels.length > 0 && (
|
||||||
</option>
|
<MultiSelect
|
||||||
))}
|
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
||||||
</select>
|
title="Sub Labels"
|
||||||
{filterValues.sub_labels.length > 0 && (
|
options={filterValues.sub_labels}
|
||||||
<select
|
selection={searchParams.sub_labels}
|
||||||
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
|
onToggle={(item) => onToggleNamedFilter("sub_labels", item) }
|
||||||
value={searchParams.sub_label}
|
/>
|
||||||
onChange={(e) => onFilter('sub_label', e.target.value)}
|
)}
|
||||||
>
|
|
||||||
<option value="all">all sub labels</option>
|
|
||||||
{filterValues.sub_labels.map((item) => (
|
|
||||||
<option key={item} value={item}>
|
|
||||||
{item}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<div ref={datePicker} className="ml-auto">
|
<div ref={datePicker} className="ml-auto">
|
||||||
<CalendarIcon
|
<CalendarIcon
|
||||||
className="h-8 w-8 cursor-pointer"
|
className="h-8 w-8 cursor-pointer"
|
||||||
|
Loading…
Reference in New Issue
Block a user