)}
@@ -308,11 +317,16 @@ export default function MotionMaskEditPane({
/>
-
);
}
diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx
index 68aa89978..db3f173a3 100644
--- a/web/src/components/settings/PolygonItem.tsx
+++ b/web/src/components/settings/PolygonItem.tsx
@@ -35,6 +35,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
+import { buttonVariants } from "../ui/button";
type PolygonItemProps = {
polygon: Polygon;
@@ -185,10 +186,13 @@ export default function PolygonItem({
}
})
.catch((error) => {
- toast.error(
- `Failed to save config changes: ${error.response.data.message}`,
- { position: "top-center" },
- );
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Unknown error";
+ toast.error(`Failed to save config changes: ${errorMessage}`, {
+ position: "top-center",
+ });
})
.finally(() => {
setIsLoading(false);
@@ -257,7 +261,10 @@ export default function PolygonItem({
Cancel
-
+
Delete
@@ -272,6 +279,7 @@ export default function PolygonItem({
{
setActivePolygonIndex(index);
setEditPane(polygon.type);
@@ -279,10 +287,14 @@ export default function PolygonItem({
>
Edit
- handleCopyCoordinates(index)}>
+ handleCopyCoordinates(index)}
+ >
Copy
setDeleteDialogOpen(true)}
>
diff --git a/web/src/components/settings/SearchSettings.tsx b/web/src/components/settings/SearchSettings.tsx
new file mode 100644
index 000000000..788072ff1
--- /dev/null
+++ b/web/src/components/settings/SearchSettings.tsx
@@ -0,0 +1,203 @@
+import { Button } from "../ui/button";
+import { useState } from "react";
+import { isDesktop, isMobileOnly } from "react-device-detect";
+import { cn } from "@/lib/utils";
+import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
+import { FaCog } from "react-icons/fa";
+import { Slider } from "../ui/slider";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+} from "@/components/ui/select";
+import { DropdownMenuSeparator } from "../ui/dropdown-menu";
+import FilterSwitch from "../filter/FilterSwitch";
+import { SearchFilter, SearchSource } from "@/types/search";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+
+type SearchSettingsProps = {
+ className?: string;
+ columns: number;
+ defaultView: string;
+ filter?: SearchFilter;
+ setColumns: (columns: number) => void;
+ setDefaultView: (view: string) => void;
+ onUpdateFilter: (filter: SearchFilter) => void;
+};
+export default function SearchSettings({
+ className,
+ columns,
+ setColumns,
+ defaultView,
+ filter,
+ setDefaultView,
+ onUpdateFilter,
+}: SearchSettingsProps) {
+ const { data: config } = useSWR("config");
+ const [open, setOpen] = useState(false);
+
+ const [searchSources, setSearchSources] = useState([
+ "thumbnail",
+ ]);
+
+ const trigger = (
+
+
+ Settings
+
+ );
+ const content = (
+
+
+
+
Default View
+
+ When no filters are selected, display a summary of the most recent
+ tracked objects per label, or display an unfiltered grid.
+
+
+
+
+ {!isMobileOnly && (
+ <>
+
+
+
+
Grid Columns
+
+ Select the number of columns in the grid view.
+
+
+
+ setColumns(value)}
+ max={8}
+ min={2}
+ step={1}
+ className="flex-grow"
+ />
+
+ {columns}
+
+
+
+ >
+ )}
+ {config?.semantic_search?.enabled && (
+
{
+ setSearchSources(sources as SearchSource[]);
+ onUpdateFilter({ ...filter, search_type: sources });
+ }}
+ />
+ )}
+
+ );
+
+ return (
+ {
+ setOpen(open);
+ }}
+ />
+ );
+}
+
+type SearchTypeContentProps = {
+ searchSources: SearchSource[] | undefined;
+ setSearchSources: (sources: SearchSource[] | undefined) => void;
+};
+export function SearchTypeContent({
+ searchSources,
+ setSearchSources,
+}: SearchTypeContentProps) {
+ return (
+ <>
+
+
+
+
Search Source
+
+ Choose whether to search the thumbnails or descriptions of your
+ tracked objects.
+
+
+
+ {
+ const updatedSources = searchSources ? [...searchSources] : [];
+
+ if (isChecked) {
+ updatedSources.push("thumbnail");
+ setSearchSources(updatedSources);
+ } else {
+ if (updatedSources.length > 1) {
+ const index = updatedSources.indexOf("thumbnail");
+ if (index !== -1) updatedSources.splice(index, 1);
+ setSearchSources(updatedSources);
+ }
+ }
+ }}
+ />
+ {
+ const updatedSources = searchSources ? [...searchSources] : [];
+
+ if (isChecked) {
+ updatedSources.push("description");
+ setSearchSources(updatedSources);
+ } else {
+ if (updatedSources.length > 1) {
+ const index = updatedSources.indexOf("description");
+ if (index !== -1) updatedSources.splice(index, 1);
+ setSearchSources(updatedSources);
+ }
+ }
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx
index 7600c3b29..7adb3e194 100644
--- a/web/src/components/settings/ZoneEditPane.tsx
+++ b/web/src/components/settings/ZoneEditPane.tsx
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
+import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -28,6 +28,7 @@ import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import ActivityIndicator from "../indicators/activity-indicator";
+import { getAttributeLabels } from "@/utils/iconUtil";
type ZoneEditPaneProps = {
polygons?: Polygon[];
@@ -39,6 +40,9 @@ type ZoneEditPaneProps = {
setIsLoading: React.Dispatch>;
onSave?: () => void;
onCancel?: () => void;
+ setActiveLine: React.Dispatch>;
+ snapPoints: boolean;
+ setSnapPoints: React.Dispatch>;
};
export default function ZoneEditPane({
@@ -51,6 +55,9 @@ export default function ZoneEditPane({
setIsLoading,
onSave,
onCancel,
+ setActiveLine,
+ snapPoints,
+ setSnapPoints,
}: ZoneEditPaneProps) {
const { data: config, mutate: updateConfig } =
useSWR("config");
@@ -61,7 +68,7 @@ export default function ZoneEditPane({
}
return Object.values(config.cameras)
- .filter((conf) => conf.ui.dashboard && conf.enabled)
+ .filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
@@ -79,69 +86,144 @@ export default function ZoneEditPane({
}
}, [polygon, config]);
- const formSchema = z.object({
- name: z
- .string()
- .min(2, {
- message: "Zone name must be at least 2 characters.",
- })
- .transform((val: string) => val.trim().replace(/\s+/g, "_"))
- .refine(
- (value: string) => {
- return !cameras.map((cam) => cam.name).includes(value);
- },
- {
- message: "Zone name must not be the name of a camera.",
- },
- )
- .refine(
- (value: string) => {
- const otherPolygonNames =
- polygons
- ?.filter((_, index) => index !== activePolygonIndex)
- .map((polygon) => polygon.name) || [];
+ const [lineA, lineB, lineC, lineD] = useMemo(() => {
+ const distances =
+ polygon?.camera &&
+ polygon?.name &&
+ config?.cameras[polygon.camera]?.zones[polygon.name]?.distances;
- return !otherPolygonNames.includes(value);
- },
- {
- message: "Zone name already exists on this camera.",
- },
- )
- .refine(
- (value: string) => {
- return !value.includes(".");
- },
- {
- message: "Zone name must not contain a period.",
- },
- )
- .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
- message: "Zone name has an illegal character.",
+ return Array.isArray(distances)
+ ? distances.map((value) => parseFloat(value) || 0)
+ : [undefined, undefined, undefined, undefined];
+ }, [polygon, config]);
+
+ const formSchema = z
+ .object({
+ name: z
+ .string()
+ .min(2, {
+ message: "Zone name must be at least 2 characters.",
+ })
+ .transform((val: string) => val.trim().replace(/\s+/g, "_"))
+ .refine(
+ (value: string) => {
+ return !cameras.map((cam) => cam.name).includes(value);
+ },
+ {
+ message: "Zone name must not be the name of a camera.",
+ },
+ )
+ .refine(
+ (value: string) => {
+ const otherPolygonNames =
+ polygons
+ ?.filter((_, index) => index !== activePolygonIndex)
+ .map((polygon) => polygon.name) || [];
+
+ return !otherPolygonNames.includes(value);
+ },
+ {
+ message: "Zone name already exists on this camera.",
+ },
+ )
+ .refine(
+ (value: string) => {
+ return !value.includes(".");
+ },
+ {
+ message: "Zone name must not contain a period.",
+ },
+ )
+ .refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
+ message: "Zone name has an illegal character.",
+ }),
+ inertia: z.coerce
+ .number()
+ .min(1, {
+ message: "Inertia must be above 0.",
+ })
+ .or(z.literal("")),
+ loitering_time: z.coerce
+ .number()
+ .min(0, {
+ message: "Loitering time must be greater than or equal to 0.",
+ })
+ .optional()
+ .or(z.literal("")),
+ isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
+ message: "The polygon drawing must be finished before saving.",
}),
- inertia: z.coerce
- .number()
- .min(1, {
- message: "Inertia must be above 0.",
- })
- .or(z.literal("")),
- loitering_time: z.coerce
- .number()
- .min(0, {
- message: "Loitering time must be greater than or equal to 0.",
- })
- .optional()
- .or(z.literal("")),
- isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
- message: "The polygon drawing must be finished before saving.",
- }),
- objects: z.array(z.string()).optional(),
- review_alerts: z.boolean().default(false).optional(),
- review_detections: z.boolean().default(false).optional(),
- });
+ objects: z.array(z.string()).optional(),
+ review_alerts: z.boolean().default(false).optional(),
+ review_detections: z.boolean().default(false).optional(),
+ speedEstimation: z.boolean().default(false),
+ lineA: z.coerce
+ .number()
+ .min(0.1, {
+ message: "Distance must be greater than or equal to 0.1",
+ })
+ .optional()
+ .or(z.literal("")),
+ lineB: z.coerce
+ .number()
+ .min(0.1, {
+ message: "Distance must be greater than or equal to 0.1",
+ })
+ .optional()
+ .or(z.literal("")),
+ lineC: z.coerce
+ .number()
+ .min(0.1, {
+ message: "Distance must be greater than or equal to 0.1",
+ })
+ .optional()
+ .or(z.literal("")),
+ lineD: z.coerce
+ .number()
+ .min(0.1, {
+ message: "Distance must be greater than or equal to 0.1",
+ })
+ .optional()
+ .or(z.literal("")),
+ speed_threshold: z.coerce
+ .number()
+ .min(0.1, {
+ message: "Speed threshold must be greater than or equal to 0.1",
+ })
+ .optional()
+ .or(z.literal("")),
+ })
+ .refine(
+ (data) => {
+ if (data.speedEstimation) {
+ return !!data.lineA && !!data.lineB && !!data.lineC && !!data.lineD;
+ }
+ return true;
+ },
+ {
+ message: "All distance fields must be filled to use speed estimation.",
+ path: ["speedEstimation"],
+ },
+ )
+ .refine(
+ (data) => {
+ // Prevent speed estimation when loitering_time is greater than 0
+ return !(
+ data.speedEstimation &&
+ data.loitering_time &&
+ data.loitering_time > 0
+ );
+ },
+ {
+ message:
+ "Zones with loitering times greater than 0 should not be used with speed estimation.",
+ path: ["loitering_time"],
+ },
+ );
const form = useForm>({
resolver: zodResolver(formSchema),
- mode: "onChange",
+ mode: "onBlur",
defaultValues: {
name: polygon?.name ?? "",
inertia:
@@ -154,9 +236,31 @@ export default function ZoneEditPane({
config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time,
isFinished: polygon?.isFinished ?? false,
objects: polygon?.objects ?? [],
+ speedEstimation: !!(lineA || lineB || lineC || lineD),
+ lineA,
+ lineB,
+ lineC,
+ lineD,
+ speed_threshold:
+ polygon?.camera &&
+ polygon?.name &&
+ config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold,
},
});
+ useEffect(() => {
+ if (
+ form.watch("speedEstimation") &&
+ polygon &&
+ polygon.points.length !== 4
+ ) {
+ toast.error(
+ "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
+ );
+ form.setValue("speedEstimation", false);
+ }
+ }, [polygon, form]);
+
const saveToConfig = useCallback(
async (
{
@@ -164,6 +268,12 @@ export default function ZoneEditPane({
inertia,
loitering_time,
objects: form_objects,
+ speedEstimation,
+ lineA,
+ lineB,
+ lineC,
+ lineD,
+ speed_threshold,
}: ZoneFormValuesType, // values submitted via the form
objects: string[],
) => {
@@ -260,9 +370,32 @@ export default function ZoneEditPane({
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
}
+ let distancesQuery = "";
+ const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
+ if (speedEstimation) {
+ distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`;
+ } else {
+ if (distances != "") {
+ distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`;
+ }
+ }
+
+ let speedThresholdQuery = "";
+ if (speed_threshold >= 0 && speedEstimation) {
+ speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`;
+ } else {
+ if (
+ polygon?.camera &&
+ polygon?.name &&
+ config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold
+ ) {
+ speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`;
+ }
+ }
+
axios
.put(
- `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`,
+ `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{ requires_restart: 0 },
)
.then((res) => {
@@ -281,10 +414,13 @@ export default function ZoneEditPane({
}
})
.catch((error) => {
- toast.error(
- `Failed to save config changes: ${error.response.data.message}`,
- { position: "top-center" },
- );
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Unknown error";
+ toast.error(`Failed to save config changes: ${errorMessage}`, {
+ position: "top-center",
+ });
})
.finally(() => {
setIsLoading(false);
@@ -354,6 +490,8 @@ export default function ZoneEditPane({
polygons={polygons}
setPolygons={setPolygons}
activePolygonIndex={activePolygonIndex}
+ snapPoints={snapPoints}
+ setSnapPoints={setSnapPoints}
/>
)}
@@ -455,6 +593,183 @@ export default function ZoneEditPane({
/>
+