mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-01 19:17:41 +03:00
Implement full deletion
This commit is contained in:
parent
234c65b5c3
commit
b3cee44f06
@ -2,7 +2,8 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"deleteClassificationAttempts": "Delete Classification Images",
|
"deleteClassificationAttempts": "Delete Classification Images",
|
||||||
"renameCategory": "Rename Category",
|
"renameCategory": "Rename Category",
|
||||||
"deleteCategory": "Rename Category"
|
"deleteCategory": "Rename Category",
|
||||||
|
"deleteImages": "Delete Images"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
@ -18,12 +19,20 @@
|
|||||||
"title": "Delete Category",
|
"title": "Delete Category",
|
||||||
"desc": "Are you sure you want to delete the category {{name}}? This will permanently delete all associated images and require re-training the model."
|
"desc": "Are you sure you want to delete the category {{name}}? This will permanently delete all associated images and require re-training the model."
|
||||||
},
|
},
|
||||||
|
"deleteDatasetImages": {
|
||||||
|
"title": "Delete Dataset Images",
|
||||||
|
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
||||||
|
},
|
||||||
|
"deleteTrainImages": {
|
||||||
|
"title": "Delete Train Images",
|
||||||
|
"desc": "Are you sure you want to delete {{count}} images? This action cannot be undone."
|
||||||
|
},
|
||||||
"renameCategory": {
|
"renameCategory": {
|
||||||
"title": "Rename Category",
|
"title": "Rename Category",
|
||||||
"desc": "Enter a new name for {{name}}. You will be required to retrain the model for the name change to take affect."
|
"desc": "Enter a new name for {{name}}. You will be required to retrain the model for the name change to take affect."
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"invalidName": " \"invalidName\": \"Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens.\""
|
"invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens."
|
||||||
},
|
},
|
||||||
"train": {
|
"train": {
|
||||||
"title": "Train",
|
"title": "Train",
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -21,14 +31,15 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CustomClassificationModelConfig } from "@/types/frigateConfig";
|
import { CustomClassificationModelConfig } from "@/types/frigateConfig";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -50,12 +61,51 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
[id: string]: string[];
|
[id: string]: string[];
|
||||||
}>(`classification/${model.name}/dataset`);
|
}>(`classification/${model.name}/dataset`);
|
||||||
|
|
||||||
|
// image multiselect
|
||||||
|
|
||||||
|
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const onClickImages = useCallback(
|
||||||
|
(images: string[], ctrl: boolean) => {
|
||||||
|
if (selectedImages.length == 0 && !ctrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newSelectedImages = [...selectedImages];
|
||||||
|
|
||||||
|
images.forEach((imageId) => {
|
||||||
|
const index = newSelectedImages.indexOf(imageId);
|
||||||
|
|
||||||
|
if (index != -1) {
|
||||||
|
if (selectedImages.length == 1) {
|
||||||
|
newSelectedImages = [];
|
||||||
|
} else {
|
||||||
|
const copy = [
|
||||||
|
...newSelectedImages.slice(0, index),
|
||||||
|
...newSelectedImages.slice(index + 1),
|
||||||
|
];
|
||||||
|
newSelectedImages = copy;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newSelectedImages.push(imageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedImages(newSelectedImages);
|
||||||
|
},
|
||||||
|
[selectedImages, setSelectedImages],
|
||||||
|
);
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
|
|
||||||
const trainModel = useCallback(() => {
|
const trainModel = useCallback(() => {
|
||||||
axios.post(`classification/${model.name}/train`);
|
axios.post(`classification/${model.name}/train`);
|
||||||
}, [model]);
|
}, [model]);
|
||||||
|
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState<string[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const onDelete = useCallback(
|
const onDelete = useCallback(
|
||||||
(ids: string[], isName: boolean = false) => {
|
(ids: string[], isName: boolean = false) => {
|
||||||
const api =
|
const api =
|
||||||
@ -66,7 +116,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
axios
|
axios
|
||||||
.post(api, { ids })
|
.post(api, { ids })
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
//setSelectedFaces([]);
|
setSelectedImages([]);
|
||||||
|
|
||||||
if (resp.status == 200) {
|
if (resp.status == 200) {
|
||||||
if (isName) {
|
if (isName) {
|
||||||
@ -114,11 +164,85 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
[pageToggle, model, refreshTrain, refreshDataset, t],
|
[pageToggle, model, refreshTrain, refreshDataset, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// keyboard
|
||||||
|
|
||||||
|
useKeyboardListener(["a", "Escape"], (key, modifiers) => {
|
||||||
|
if (modifiers.repeat || !modifiers.down) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "a":
|
||||||
|
if (modifiers.ctrl) {
|
||||||
|
if (selectedImages.length) {
|
||||||
|
setSelectedImages([]);
|
||||||
|
} else {
|
||||||
|
setSelectedImages([
|
||||||
|
...(pageToggle === "train"
|
||||||
|
? trainImages || []
|
||||||
|
: dataset?.[pageToggle] || []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
setSelectedImages([]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedImages([]);
|
||||||
|
}, [pageToggle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col overflow-hidden p-2">
|
<div className="flex size-full flex-col overflow-hidden">
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
<div className="mb-2 flex flex-row justify-between gap-2 align-middle">
|
<AlertDialog
|
||||||
|
open={!!deleteDialogOpen}
|
||||||
|
onOpenChange={() => setDeleteDialogOpen(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t(
|
||||||
|
pageToggle == "train"
|
||||||
|
? "deleteTrainImages.title"
|
||||||
|
: "deleteDatasetImages.title",
|
||||||
|
)}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans
|
||||||
|
ns="views/classificationModel"
|
||||||
|
values={{ count: deleteDialogOpen?.length, dataset: pageToggle }}
|
||||||
|
>
|
||||||
|
{pageToggle == "train"
|
||||||
|
? "deleteTrainImages.desc"
|
||||||
|
: "deleteDatasetImages.desc"}
|
||||||
|
</Trans>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={() => {
|
||||||
|
if (deleteDialogOpen) {
|
||||||
|
onDelete(deleteDialogOpen);
|
||||||
|
setDeleteDialogOpen(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-between gap-2 px-2 pt-2 align-middle">
|
||||||
<LibrarySelector
|
<LibrarySelector
|
||||||
pageToggle={pageToggle}
|
pageToggle={pageToggle}
|
||||||
dataset={dataset || {}}
|
dataset={dataset || {}}
|
||||||
@ -127,14 +251,36 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRename={() => {}}
|
onRename={() => {}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={trainModel}>Train Model</Button>
|
{selectedImages?.length > 0 ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<div className="p-1">{`${selectedImages.length} selected`}</div>
|
||||||
|
<div className="p-1">{"|"}</div>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
||||||
|
onClick={() => setSelectedImages([])}
|
||||||
|
>
|
||||||
|
{t("button.unselect", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="flex gap-2"
|
||||||
|
onClick={() => setDeleteDialogOpen(selectedImages)}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
{isDesktop && t("button.deleteImages")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button onClick={trainModel}>Train Model</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pageToggle == "train" ? (
|
{pageToggle == "train" ? (
|
||||||
<TrainGrid
|
<TrainGrid
|
||||||
model={model}
|
model={model}
|
||||||
trainImages={trainImages || []}
|
trainImages={trainImages || []}
|
||||||
selected={false}
|
selectedImages={selectedImages}
|
||||||
onClickImages={() => {}}
|
onClickImages={onClickImages}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -142,6 +288,8 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
modelName={model.name}
|
modelName={model.name}
|
||||||
categoryName={pageToggle}
|
categoryName={pageToggle}
|
||||||
images={dataset?.[pageToggle] || []}
|
images={dataset?.[pageToggle] || []}
|
||||||
|
selectedImages={selectedImages}
|
||||||
|
onClickImages={onClickImages}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -338,27 +486,36 @@ type DatasetGridProps = {
|
|||||||
modelName: string;
|
modelName: string;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
images: string[];
|
images: string[];
|
||||||
|
selectedImages: string[];
|
||||||
|
onClickImages: (images: string[], ctrl: boolean) => void;
|
||||||
onDelete: (ids: string[]) => void;
|
onDelete: (ids: string[]) => void;
|
||||||
};
|
};
|
||||||
function DatasetGrid({
|
function DatasetGrid({
|
||||||
modelName,
|
modelName,
|
||||||
categoryName,
|
categoryName,
|
||||||
images,
|
images,
|
||||||
|
selectedImages,
|
||||||
|
onClickImages,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: DatasetGridProps) {
|
}: DatasetGridProps) {
|
||||||
const { t } = useTranslation(["views/classificationModel"]);
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-10 gap-2 overflow-y-auto">
|
<div className="grid grid-cols-10 gap-2 overflow-y-auto p-2">
|
||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-60 cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
|
"flex h-60 cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
|
||||||
"outline-transparent duration-500",
|
selectedImages.includes(image)
|
||||||
|
? "shadow-selected outline-selected"
|
||||||
|
: "outline-transparent duration-500",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
//e.stopPropagation();
|
e.stopPropagation();
|
||||||
//onClickImages([data.raw], e.ctrlKey || e.metaKey);
|
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
onClickImages([image], true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -401,14 +558,14 @@ function DatasetGrid({
|
|||||||
type TrainGridProps = {
|
type TrainGridProps = {
|
||||||
model: CustomClassificationModelConfig;
|
model: CustomClassificationModelConfig;
|
||||||
trainImages: string[];
|
trainImages: string[];
|
||||||
selected: boolean;
|
selectedImages: string[];
|
||||||
onClickImages: (images: string[], ctrl: boolean) => void;
|
onClickImages: (images: string[], ctrl: boolean) => void;
|
||||||
onDelete: (ids: string[]) => void;
|
onDelete: (ids: string[]) => void;
|
||||||
};
|
};
|
||||||
function TrainGrid({
|
function TrainGrid({
|
||||||
model,
|
model,
|
||||||
trainImages,
|
trainImages,
|
||||||
selected,
|
selectedImages,
|
||||||
onClickImages,
|
onClickImages,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: TrainGridProps) {
|
}: TrainGridProps) {
|
||||||
@ -429,13 +586,13 @@ function TrainGrid({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-10 gap-2 overflow-y-auto">
|
<div className="grid grid-cols-10 gap-2 overflow-y-auto p-2">
|
||||||
{trainData?.map((data) => (
|
{trainData?.map((data) => (
|
||||||
<div
|
<div
|
||||||
key={data.timestamp}
|
key={data.timestamp}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
|
"flex cursor-pointer flex-col gap-2 rounded-lg bg-card outline outline-[3px]",
|
||||||
selected
|
selectedImages.includes(data.raw)
|
||||||
? "shadow-selected outline-selected"
|
? "shadow-selected outline-selected"
|
||||||
: "outline-transparent duration-500",
|
: "outline-transparent duration-500",
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user