Files
frigate/web/src/views/settings/EnrichmentsSettingsView.tsx
T
Josh HawkinsandGitHub 6fdd65ddb5
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
UI tweaks (#23346)
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00

615 lines
21 KiB
TypeScript

import Heading from "@/components/ui/heading";
import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig";
import useSWR from "swr";
import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useContext, useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Trans, useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
import { useDocDomain } from "@/hooks/use-doc-domain";
type EnrichmentsSettings = {
search: {
enabled?: boolean;
model_size?: SearchModelSize;
};
face: {
enabled?: boolean;
model_size?: SearchModelSize;
};
lpr: {
enabled?: boolean;
};
bird: {
enabled?: boolean;
};
};
type EnrichmentsSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function EnrichmentsSettingsView({
setUnsavedChanges,
}: EnrichmentsSettingsViewProps) {
const { t } = useTranslation("views/settings");
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isReindexDialogOpen, setIsReindexDialogOpen] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const [enrichmentsSettings, setEnrichmentsSettings] =
useState<EnrichmentsSettings>({
search: { enabled: undefined, model_size: undefined },
face: { enabled: undefined, model_size: undefined },
lpr: { enabled: undefined },
bird: { enabled: undefined },
});
const [origSearchSettings, setOrigSearchSettings] =
useState<EnrichmentsSettings>({
search: { enabled: undefined, model_size: undefined },
face: { enabled: undefined, model_size: undefined },
lpr: { enabled: undefined },
bird: { enabled: undefined },
});
useEffect(() => {
if (config) {
if (enrichmentsSettings?.search.enabled == undefined) {
setEnrichmentsSettings({
search: {
enabled: config.semantic_search.enabled,
model_size: config.semantic_search.model_size,
},
face: {
enabled: config.face_recognition.enabled,
model_size: config.face_recognition.model_size,
},
lpr: { enabled: config.lpr.enabled },
bird: {
enabled: config.classification.bird.enabled,
},
});
}
setOrigSearchSettings({
search: {
enabled: config.semantic_search.enabled,
model_size: config.semantic_search.model_size,
},
face: {
enabled: config.face_recognition.enabled,
model_size: config.face_recognition.model_size,
},
lpr: { enabled: config.lpr.enabled },
bird: { enabled: config.classification.bird.enabled },
});
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const handleEnrichmentsConfigChange = (
newConfig: Partial<EnrichmentsSettings>,
) => {
setEnrichmentsSettings((prevConfig) => ({
search: { ...prevConfig.search, ...newConfig.search },
face: { ...prevConfig.face, ...newConfig.face },
lpr: { ...prevConfig.lpr, ...newConfig.lpr },
bird: { ...prevConfig.bird, ...newConfig.bird },
}));
setUnsavedChanges(true);
setChangedValue(true);
};
const saveToConfig = useCallback(async () => {
setIsLoading(true);
axios
.put(
`config/set?semantic_search.enabled=${enrichmentsSettings.search.enabled ? "True" : "False"}&semantic_search.model_size=${enrichmentsSettings.search.model_size}&face_recognition.enabled=${enrichmentsSettings.face.enabled ? "True" : "False"}&face_recognition.model_size=${enrichmentsSettings.face.model_size}&lpr.enabled=${enrichmentsSettings.lpr.enabled ? "True" : "False"}&classification.bird.enabled=${enrichmentsSettings.bird.enabled ? "True" : "False"}`,
{ requires_restart: 0 },
)
.then((res) => {
if (res.status === 200) {
toast.success(t("enrichments.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(
t("enrichments.toast.error", { errorMessage: res.statusText }),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
addMessage(
"enrichments_settings_restart",
t("enrichments.restart_required"),
undefined,
"enrichments_settings",
);
setIsLoading(false);
});
}, [enrichmentsSettings, t, addMessage, updateConfig]);
const onCancel = useCallback(() => {
setEnrichmentsSettings(origSearchSettings);
setChangedValue(false);
removeMessage("enrichments_settings", "enrichments_settings");
}, [origSearchSettings, removeMessage]);
const onReindex = useCallback(() => {
setIsLoading(true);
axios
.put("/reindex")
.then((res) => {
if (res.status === 202) {
toast.success(t("enrichments.semanticSearch.reindexNow.success"), {
position: "top-center",
});
} else {
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage,
}),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
setIsReindexDialogOpen(false);
});
}, [t]);
useEffect(() => {
if (changedValue) {
addMessage(
"enrichments_settings",
t("enrichments.unsavedChanges"),
undefined,
"enrichments_settings",
);
} else {
removeMessage("enrichments_settings", "enrichments_settings");
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue]);
useEffect(() => {
document.title = t("documentTitle.enrichments");
}, [t]);
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<Heading as="h4" className="mb-2">
{t("enrichments.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("enrichments.semanticSearch.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("enrichments.semanticSearch.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/semantic_search")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={enrichmentsSettings.search.enabled === undefined}
checked={enrichmentsSettings.search.enabled === true}
onCheckedChange={(isChecked) => {
handleEnrichmentsConfigChange({
search: { enabled: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
<div className="space-y-3">
<Button
variant="default"
disabled={isLoading || !enrichmentsSettings.search.enabled}
onClick={() => setIsReindexDialogOpen(true)}
aria-label={t("enrichments.semanticSearch.reindexNow.label")}
>
{t("enrichments.semanticSearch.reindexNow.label")}
</Button>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.desc
</Trans>
</div>
</div>
<div className="mt-2 flex flex-col space-y-6">
<div className="space-y-0.5">
<div>{t("enrichments.semanticSearch.modelSize.label")}</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
enrichments.semanticSearch.modelSize.desc
</Trans>
</p>
<ul className="list-disc pl-5 text-sm">
<li>
<Trans ns="views/settings">
enrichments.semanticSearch.modelSize.small.desc
</Trans>
</li>
<li>
<Trans ns="views/settings">
enrichments.semanticSearch.modelSize.large.desc
</Trans>
</li>
</ul>
</div>
</div>
<Select
value={enrichmentsSettings.search.model_size}
onValueChange={(value) =>
handleEnrichmentsConfigChange({
search: { model_size: value as SearchModelSize },
})
}
>
<SelectTrigger className="w-20">
{t(
`enrichments.semanticSearch.modelSize.${enrichmentsSettings.search.model_size}.title`,
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["small", "large"].map((size) => (
<SelectItem
key={size}
className="cursor-pointer"
value={size}
>
{t(
"enrichments.semanticSearch.modelSize." +
size +
".title",
)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<AlertDialog
open={isReindexDialogOpen}
onOpenChange={setIsReindexDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("enrichments.semanticSearch.reindexNow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.confirmDesc
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsReindexDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
onClick={onReindex}
className={buttonVariants({ variant: "select" })}
>
{t("enrichments.semanticSearch.reindexNow.confirmButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="my-2 space-y-6">
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("enrichments.faceRecognition.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("enrichments.faceRecognition.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/face_recognition")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={enrichmentsSettings.face.enabled === undefined}
checked={enrichmentsSettings.face.enabled === true}
onCheckedChange={(isChecked) => {
handleEnrichmentsConfigChange({
face: { enabled: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
<div className="space-y-0.5">
<div>{t("enrichments.faceRecognition.modelSize.label")}</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
enrichments.faceRecognition.modelSize.desc
</Trans>
</p>
<ul className="list-disc pl-5 text-sm">
<li>
<Trans ns="views/settings">
enrichments.faceRecognition.modelSize.small.desc
</Trans>
</li>
<li>
<Trans ns="views/settings">
enrichments.faceRecognition.modelSize.large.desc
</Trans>
</li>
</ul>
</div>
</div>
<Select
value={enrichmentsSettings.face.model_size}
onValueChange={(value) =>
handleEnrichmentsConfigChange({
face: {
model_size: value as SearchModelSize,
},
})
}
>
<SelectTrigger className="w-20">
{t(
`enrichments.faceRecognition.modelSize.${enrichmentsSettings.face.model_size}.title`,
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["small", "large"].map((size) => (
<SelectItem
key={size}
className="cursor-pointer"
value={size}
>
{t(
"enrichments.faceRecognition.modelSize." +
size +
".title",
)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("enrichments.licensePlateRecognition.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("enrichments.licensePlateRecognition.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/license_plate_recognition",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={enrichmentsSettings.lpr.enabled === undefined}
checked={enrichmentsSettings.lpr.enabled === true}
onCheckedChange={(isChecked) => {
handleEnrichmentsConfigChange({
lpr: { enabled: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("enrichments.birdClassification.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("enrichments.birdClassification.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/bird_classification")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={enrichmentsSettings.bird.enabled === undefined}
checked={enrichmentsSettings.bird.enabled === true}
onCheckedChange={(isChecked) => {
handleEnrichmentsConfigChange({
bird: { enabled: isChecked },
});
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
aria-label="Save"
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</div>
</div>
);
}