Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explore bulk actions #15307

Merged
merged 12 commits into from
Dec 2, 2024
6 changes: 5 additions & 1 deletion frigate/api/defs/events_body.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional, Union
from typing import List, Optional, Union

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -27,5 +27,9 @@ class EventsEndBody(BaseModel):
end_time: Optional[float] = None


class EventsDeleteBody(BaseModel):
event_ids: List[str] = Field(title="The event IDs to delete")


class SubmitPlusBody(BaseModel):
include_annotation: int = Field(default=1)
59 changes: 45 additions & 14 deletions frigate/api/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from frigate.api.defs.events_body import (
EventsCreateBody,
EventsDeleteBody,
EventsDescriptionBody,
EventsEndBody,
EventsSubLabelBody,
Expand Down Expand Up @@ -1036,34 +1037,64 @@ def regenerate_description(
)


@router.delete("/events/{event_id}")
def delete_event(request: Request, event_id: str):
def delete_single_event(event_id: str, request: Request) -> dict:
try:
event = Event.get(Event.id == event_id)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
status_code=404,
)
return {"success": False, "message": f"Event {event_id} not found"}

media_name = f"{event.camera}-{event.id}"
if event.has_snapshot:
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media.unlink(missing_ok=True)
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media.unlink(missing_ok=True)
snapshot_paths = [
Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg"),
Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"),
]
for media in snapshot_paths:
media.unlink(missing_ok=True)

event.delete_instance()
Timeline.delete().where(Timeline.source_id == event_id).execute()

# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
context.db.delete_embeddings_thumbnail(event_ids=[event_id])
context.db.delete_embeddings_description(event_ids=[event_id])
return JSONResponse(
content=({"success": True, "message": "Event " + event_id + " deleted"}),
status_code=200,
)

return {"success": True, "message": f"Event {event_id} deleted"}


@router.delete("/events/{event_id}")
def delete_event(request: Request, event_id: str):
result = delete_single_event(event_id, request)
status_code = 200 if result["success"] else 404
return JSONResponse(content=result, status_code=status_code)


@router.delete("/events/")
def delete_events(request: Request, body: EventsDeleteBody):
if not body.event_ids:
return JSONResponse(
content=({"success": False, "message": "No event IDs provided."}),
status_code=404,
)

deleted_events = []
not_found_events = []

for event_id in body.event_ids:
result = delete_single_event(event_id, request)
if result["success"]:
deleted_events.append(event_id)
else:
not_found_events.append(event_id)

response = {
"success": True,
"deleted_events": deleted_events,
"not_found_events": not_found_events,
}
return JSONResponse(content=response, status_code=200)


@router.post("/events/{camera_name}/{label}/create")
Expand Down
10 changes: 10 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0",
"vaul": "^0.9.1",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8"
Expand Down
18 changes: 11 additions & 7 deletions web/src/components/card/SearchThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { useApiHost } from "@/api";
import { getIconForLabel } from "@/utils/iconUtil";
import useSWR from "swr";
Expand All @@ -12,10 +12,11 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search";
import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import useContextMenu from "@/hooks/use-contextmenu";

type SearchThumbnailProps = {
searchResult: SearchResult;
onClick: (searchResult: SearchResult) => void;
onClick: (searchResult: SearchResult, ctrl: boolean, detail: boolean) => void;
};

export default function SearchThumbnail({
Expand All @@ -28,9 +29,9 @@ export default function SearchThumbnail({

// interactions

const handleOnClick = useCallback(() => {
onClick(searchResult);
}, [searchResult, onClick]);
useContextMenu(imgRef, () => {
onClick(searchResult, true, false);
});

const objectLabel = useMemo(() => {
if (
Expand All @@ -45,7 +46,10 @@ export default function SearchThumbnail({
}, [config, searchResult]);

return (
<div className="relative size-full cursor-pointer" onClick={handleOnClick}>
<div
className="relative size-full cursor-pointer"
onClick={() => onClick(searchResult, false, true)}
>
<ImageLoadingIndicator
className="absolute inset-0"
imgLoaded={imgLoaded}
Expand Down Expand Up @@ -79,7 +83,7 @@ export default function SearchThumbnail({
<div className="mx-3 pb-1 text-sm text-white">
<Chip
className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`}
onClick={() => onClick(searchResult)}
onClick={() => onClick(searchResult, false, true)}
>
{getIconForLabel(objectLabel, "size-3 text-white")}
{Math.round(
Expand Down
132 changes: 132 additions & 0 deletions web/src/components/filter/SearchActionGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useCallback, useState } from "react";
import axios from "axios";
import { Button, buttonVariants } from "../ui/button";
import { isDesktop } from "react-device-detect";
import { HiTrash } from "react-icons/hi";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { toast } from "sonner";

type SearchActionGroupProps = {
selectedObjects: string[];
setSelectedObjects: (ids: string[]) => void;
pullLatestData: () => void;
};
export default function SearchActionGroup({
selectedObjects,
setSelectedObjects,
pullLatestData,
}: SearchActionGroupProps) {
const onClearSelected = useCallback(() => {
setSelectedObjects([]);
}, [setSelectedObjects]);

const onDelete = useCallback(async () => {
await axios
.delete(`events/`, {
data: { event_ids: selectedObjects },
})
.then((resp) => {
if (resp.status == 200) {
toast.success("Tracked objects deleted successfully.", {
position: "top-center",
});
setSelectedObjects([]);
pullLatestData();
}
})
.catch(() => {
toast.error("Failed to delete tracked objects.", {
position: "top-center",
});
});
}, [selectedObjects, setSelectedObjects, pullLatestData]);

const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bypassDialog, setBypassDialog] = useState(false);

useKeyboardListener(["Shift"], (_, modifiers) => {
setBypassDialog(modifiers.shift);
});

const handleDelete = useCallback(() => {
if (bypassDialog) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialog, onDelete]);

return (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Deleting these {selectedObjects.length} tracked objects removes the
snapshot, any saved embeddings, and any associated object lifecycle
entries. Recorded footage of these tracked objects in History view
will <em>NOT</em> be deleted.
<br />
<br />
Are you sure you want to proceed?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedObjects.length} selected`}</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
Unselect
</div>
</div>
<div className="flex items-center gap-1 md:gap-2">
<Button
className="flex items-center gap-2 p-2"
aria-label="Delete"
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog ? "Delete Now" : "Delete"}
</div>
)}
</Button>
</div>
</div>
</>
);
}
54 changes: 54 additions & 0 deletions web/src/hooks/use-press.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// https://gist.github.com/cpojer/641bf305e6185006ea453e7631b80f95

import { useCallback, useState } from "react";
import {
LongPressCallbackMeta,
LongPressReactEvents,
useLongPress,
} from "use-long-press";

export default function usePress(
options: Omit<Parameters<typeof useLongPress>[1], "onCancel" | "onStart"> & {
onLongPress: NonNullable<Parameters<typeof useLongPress>[0]>;
onPress: (event: LongPressReactEvents<Element>) => void;
},
) {
const { onLongPress, onPress, ...actualOptions } = options;
const [hasLongPress, setHasLongPress] = useState(false);

const onCancel = useCallback(() => {
if (hasLongPress) {
setHasLongPress(false);
}
}, [hasLongPress]);

const bind = useLongPress(
useCallback(
(
event: LongPressReactEvents<Element>,
meta: LongPressCallbackMeta<unknown>,
) => {
setHasLongPress(true);
onLongPress(event, meta);
},
[onLongPress],
),
{
...actualOptions,
onCancel,
onStart: onCancel,
},
);

return useCallback(
() => ({
...bind(),
onClick: (event: LongPressReactEvents<HTMLDivElement>) => {
if (!hasLongPress) {
onPress(event);
}
},
}),
[bind, hasLongPress, onPress],
);
}
Loading
Loading