Compare commits

...

10 Commits

Author SHA1 Message Date
Dmytro Marchuk
a8cd87c373
Merge 8f6e083420 into 39a3667f39 2026-05-25 07:05:29 -06:00
Josh Hawkins
39a3667f39
add motion review docs (#23307)
Some checks failed
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
2026-05-25 07:04:45 -06:00
Josh Hawkins
2ed70bd693
Profiles fixes (#23306)
* add prop to disable id field

* disable id field when editing profile mask/zone

also, disable if the zone name already exists in required_zones or the base config is being edited and the id already exists on a profile

* add backend validation to reject profile-omly masks/zones

* add tests

* update docs

* tweak
2026-05-25 07:04:00 -06:00
Josh Hawkins
90248ef243
remove camera name badge (#23305) 2026-05-25 07:02:57 -06:00
Dmitry Marchuk
8f6e083420 Review changes 2026-05-24 07:45:04 +03:00
Dmitry Marchuk
bf25560067 Make Dropdown look like Select 2026-05-23 22:20:04 +03:00
Dmitry Marchuk
df40d9e2b5 Review changes 2026-05-23 21:32:02 +03:00
Dmitry Marchuk
263554a5f6 Improve on the dialog, fix some bugs 2026-05-22 12:18:35 +03:00
Dmytro Marchuk
597a9f9fb4
Merge branch 'blakeblackshear:dev' into dev 2026-05-17 08:50:35 +03:00
Dmitry Marchuk
0d05f0feaa Add custom notification suspension dialog 2026-05-17 08:50:19 +03:00
18 changed files with 599 additions and 88 deletions

View File

@ -6,19 +6,8 @@
"initializeCommand": ".devcontainer/initialize.sh",
"postCreateCommand": ".devcontainer/post_create.sh",
"overrideCommand": false,
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {}
// Uncomment the following lines to use ONNX Runtime with CUDA support
// "ghcr.io/devcontainers/features/nvidia-cuda:1": {
// "installCudnn": true,
// "installNvtx": true,
// "installToolkit": true,
// "cudaVersion": "12.5",
// "cudnnVersion": "9.4.0.58"
// },
// "./features/onnxruntime-gpu": {}
},
"remoteUser": "root",
"features": {},
"forwardPorts": [
8971,
5000,

View File

@ -13,8 +13,12 @@ fi
# Frigate normal container runs as root, so it have permission to create
# the folders. But the devcontainer runs as the host user, so we need to
# create the folders and give the host user permission to write to them.
sudo mkdir -p /media/frigate
sudo chown -R "$(id -u):$(id -g)" /media/frigate
SUDO=""
if [[ $EUID -ne 0 ]]; then
SUDO="sudo"
fi
$SUDO mkdir -p /media/frigate
$SUDO chown -R "$(id -u):$(id -g)" /media/frigate
# When started as a service, LIBAVFORMAT_VERSION_MAJOR is defined in the
# s6 service file. For dev, where frigate is started from an interactive

View File

@ -197,3 +197,7 @@ This option is handy when you want to prevent large transient changes from trigg
When the skip threshold is exceeded, **no motion is reported** for that frame, meaning **nothing is recorded** for that frame. That means you can miss something important, like a PTZ camera auto-tracking an object or activity while the camera is moving. If you prefer to guarantee that every frame is saved, leave this unset and accept occasional recordings containing scene noise — they typically only take up a few megabytes and are quick to scan in the timeline UI.
:::
## Reviewing Detected Motion
To review what the detector picked up — or to search past recordings for motion in a specific region — see [Reviewing Motion](review.md#reviewing-motion) on the Review page.

View File

@ -126,7 +126,9 @@ Only the fields you explicitly set in a profile override are applied. All other
## Activating Profiles
Profiles can be activated and deactivated from the Frigate UI. Open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
## Example: Home / Away Setup
@ -207,3 +209,27 @@ In this example:
- **Away profile**: The front door camera enables notifications and tracks specific alert labels. The indoor camera is fully enabled with detection and recording.
- **Home profile**: The front door camera disables notifications. The indoor camera is completely disabled for privacy.
- **No profile active**: All cameras use their base configuration values.
## FAQ
### Can I define a zone or mask in a profile but not have it in the base config?
No. Profiles are pure overrides. Every zone and mask defined under a profile must reference an entry that already exists on the base camera config. Configurations that introduce profile-only zones or masks are rejected at startup.
If you want a zone or mask to be active only under a specific profile, define it on the base config with `enabled: false`, then enable it in that profile's overrides.
### How do I revert a profile zone or mask override back to the base configuration?
Delete the override. In the Frigate UI, edit the profile and use the "Revert override" action (the trash can icon) on the zone or mask. The base entry is left untouched, and once the override is removed the profile inherits the base values for that zone or mask.
### Can multiple profiles be active at the same time?
No. Only one profile can be active at a time. Activating a new profile automatically deactivates the current one.
### What happens to my profile overrides if I delete a zone or mask from the base?
When you delete a base zone or mask in the Frigate UI, any profile overrides for that entry are deleted automatically as part of the same operation. If you remove a base entry by editing your config file directly and leave a profile override behind, the config will fail validation at startup until the orphaned override is removed as well.
### Why are some settings missing when I configure a profile override?
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.

View File

@ -130,3 +130,43 @@ By default a review item will be created if any `review -> alerts -> labels` and
Because zones don't apply to audio, audio labels will always be marked as a detection by default.
:::
## Reviewing Motion
The Review page also can show periods of motion that didn't produce a tracked object, and provides a way to search past recordings for motion in a specific region. These tools complement the alerts and detections workflow above — see [Tuning Motion Detection](motion_detection.md) for how the underlying motion detector is configured.
### Motion Previews
The Motion Previews pane shows preview clips for periods of significant motion that did not produce a tracked object. It is useful for spotting things that motion detection picked up but object detection did not, which can help validate tuning or catch missed objects.
On the <NavPath path="Review > Motion" /> page, click the 3-dots menu on a camera and choose **Motion Previews**. Each card represents a continuous range of motion-only activity and plays back the recorded preview for that range. A heatmap overlay dims areas of the frame with no motion so the moving regions stand out.
The pane provides a few controls:
- **Speed** — speeds up or slows down all of the preview clips at once.
- **Dim** — controls how strongly non-motion areas are darkened by the heatmap overlay. Higher values increase motion area visibility.
- **Filter** — opens a 16×16 grid overlaid on a snapshot of the camera. Select one or more cells to only show clips with motion in those regions. This is helpful for filtering out motion in areas like a busy street while keeping motion in your driveway.
Clicking a preview clip seeks the recording player to that timestamp so you can review the full footage.
### Motion Search
Motion Search lets you scan recorded footage for changes inside a region of interest you draw on the camera. Unlike Motion Previews, which surfaces what Frigate's motion detector flagged in real time, Motion Search re-analyzes the saved recordings, so it can find changes that were missed (for example, an object that appeared while motion detection was paused by `lightning_threshold`, or in a region that is normally motion-masked).
To start a search, click the 3-dots menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
1. Pick the camera and time range to scan.
2. Draw a polygon on the camera frame to define the region of interest.
3. Adjust the search parameters if needed:
| Field | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
| **Maximum Results** | Maximum number of matching timestamps to return. |
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.

View File

@ -326,6 +326,47 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
)
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
for profile_name, profile in camera_config.profiles.items():
if profile.zones:
for zone_name in profile.zones:
if zone_name not in camera_config.zones:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"zone '{zone_name}' that does not exist on the base config"
)
if profile.motion and profile.motion.mask:
for mask_name in profile.motion.mask:
if mask_name not in camera_config.motion.mask:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"motion mask '{mask_name}' that does not exist on the base config"
)
if profile.objects:
for mask_name in profile.objects.mask or {}:
if mask_name not in (camera_config.objects.mask or {}):
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' that does not exist on the base config"
)
for label, filter_config in (profile.objects.filters or {}).items():
base_filter = (camera_config.objects.filters or {}).get(label)
profile_filter_masks = (
filter_config.mask if filter_config else None
) or {}
base_filter_masks = (base_filter.mask if base_filter else None) or {}
for mask_name in profile_filter_masks:
if mask_name not in base_filter_masks:
raise ValueError(
f"Camera '{camera_config.name}' profile '{profile_name}' defines "
f"object mask '{mask_name}' for '{label}' that does not exist "
f"on the base config"
)
def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
"""Verify that required_zones are specified when autotracking is enabled."""
if (
@ -952,6 +993,7 @@ class FrigateConfig(FrigateBaseModel):
verify_recording_segments_setup_with_reasonable_time(camera_config)
verify_zone_objects_are_tracked(camera_config)
verify_required_zones_exist(camera_config)
verify_profile_overrides_match_base(camera_config)
verify_autotrack_zones(camera_config)
verify_motion_and_detect(camera_config)
verify_objects_track(camera_config, labelmap_objects)

View File

@ -178,6 +178,141 @@ class TestCameraProfileConfig(unittest.TestCase):
with self.assertRaises(ValidationError):
FrigateConfig(**config_data)
def test_profile_zone_without_base_rejected(self):
"""Profile defining a zone not present on the base camera is rejected."""
from pydantic import ValidationError
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"profiles": {
"armed": {
"zones": {
"phantom": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom", str(ctx.exception))
def test_profile_motion_mask_without_base_rejected(self):
"""Profile defining a motion mask not present on the base camera is rejected."""
from pydantic import ValidationError
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"motion": {
"mask": {
"base_mask": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"motion": {
"mask": {
"phantom_mask": {
"coordinates": "0,0,50,0,50,50,0,50",
},
},
},
},
},
},
},
}
with self.assertRaises(ValidationError) as ctx:
FrigateConfig(**config_data)
self.assertIn("phantom_mask", str(ctx.exception))
def test_profile_overrides_matching_base_accepted(self):
"""Profile overrides that reference existing base zones/masks parse cleanly."""
config_data = {
"mqtt": {"host": "mqtt"},
"profiles": {
"armed": {"friendly_name": "Armed"},
},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"front_yard": {"coordinates": "0,0,100,0,100,100,0,100"},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,100,0,100,100,0,100",
},
},
},
"profiles": {
"armed": {
"zones": {
"front_yard": {
"coordinates": "0,0,50,0,50,50,0,50",
"inertia": 5,
},
},
"motion": {
"mask": {
"tree": {
"coordinates": "0,0,75,0,75,75,0,75",
},
},
},
},
},
},
},
}
config = FrigateConfig(**config_data)
assert "armed" in config.cameras["front"].profiles
class TestProfileInConfig(unittest.TestCase):
"""Test that profiles parse correctly in FrigateConfig."""

View File

@ -21,6 +21,7 @@
"1hour": "1 hour",
"12hours": "12 hours",
"24hours": "24 hours",
"custom": "Custom...",
"pm": "pm",
"am": "am",
"yr": "{{time}}yr",

View File

@ -1100,8 +1100,15 @@
"1hour": "Suspend for 1 hour",
"12hours": "Suspend for 12 hours",
"24hours": "Suspend for 24 hours",
"custom": "Suspend until...",
"untilRestart": "Suspend until restart"
},
"customSuspension": {
"title": "Custom suspension time",
"description": "Suspend notifications for this camera until the selected time.",
"untilLabel": "Suspend until",
"invalidTime": "Pick a time in the future."
},
"cancelSuspension": "Cancel Suspension",
"toast": {
"success": {

View File

@ -24,7 +24,7 @@ import {
useState,
} from "react";
import { useForm } from "react-hook-form";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { LuCheck, LuChevronDown, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
import { toast } from "sonner";
@ -36,12 +36,12 @@ import {
useNotificationTest,
} from "@/api/ws";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import FilterSwitch from "@/components/filter/FilterSwitch";
@ -50,6 +50,7 @@ import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import cloneDeep from "lodash/cloneDeep";
@ -741,6 +742,8 @@ export function CameraNotificationSwitch({
}
}, [notificationSuspendUntil, notificationState]);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const handleSuspend = (duration: string) => {
setIsSuspended(true);
if (duration == "off") {
@ -750,6 +753,11 @@ export function CameraNotificationSwitch({
}
};
const handleCustomSuspend = (totalMinutes: number) => {
setIsSuspended(true);
sendNotificationSuspend(totalMinutes);
};
const handleCancelSuspension = () => {
sendNotification("ON");
sendNotificationSuspend(0);
@ -809,34 +817,41 @@ export function CameraNotificationSwitch({
</div>
{!isSuspended ? (
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="flex gap-2">
{t("notification.suspendTime.suspend")}
<LuChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleSuspend("5")}>
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("10")}>
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("30")}>
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("60")}>
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("840")}>
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("1440")}>
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSuspend("off")}>
{t("notification.suspendTime.untilRestart")}
</SelectItem>
</SelectContent>
</Select>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCustomDialogOpen(true)}>
{t("notification.suspendTime.custom")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="destructive"
@ -846,6 +861,12 @@ export function CameraNotificationSwitch({
{t("notification.cancelSuspension")}
</Button>
)}
<CustomSuspensionDialog
open={customDialogOpen}
onOpenChange={setCustomDialogOpen}
onConfirm={handleCustomSuspend}
/>
</div>
);
}

View File

@ -26,6 +26,7 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
placeholderName?: string;
placeholderId?: string;
idVisible?: boolean;
idDisabled?: boolean;
};
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@ -41,6 +42,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
placeholderName,
placeholderId,
idVisible,
idDisabled,
}: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>();
@ -59,6 +61,9 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
const effectiveProcessId = processId || defaultProcessId;
useEffect(() => {
if (idDisabled) {
return;
}
const subscription = watch((value, { name }) => {
if (name === nameField) {
hasUserTypedRef.current = true;
@ -68,7 +73,15 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
}
});
return () => subscription.unsubscribe();
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
}, [
watch,
setValue,
trigger,
nameField,
idField,
effectiveProcessId,
idDisabled,
]);
// Auto-expand if there's an error on the ID field after user has typed
useEffect(() => {
@ -123,6 +136,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
<Input
className="text-md"
placeholder={placeholderId}
disabled={idDisabled}
{...field}
/>
</FormControl>

View File

@ -50,6 +50,7 @@ import { use24HourTime } from "@/hooks/use-date-utils";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { LiveStreamMetadata } from "@/types/live";
import CustomSuspensionDialog from "@/components/overlay/dialog/CustomSuspensionDialog";
type LiveContextMenuProps = {
className?: string;
@ -238,6 +239,8 @@ export default function LiveContextMenu({
}
}, [notificationSuspendUntil, notificationState]);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const handleSuspend = (duration: string) => {
if (duration === "off") {
sendNotification("OFF");
@ -534,6 +537,16 @@ export default function LiveContextMenu({
>
{t("time.24hours", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
isEnabled
? () => setCustomDialogOpen(true)
: undefined
}
>
{t("time.custom", { ns: "common" })}
</ContextMenuItem>
<ContextMenuItem
disabled={!isEnabled}
onClick={
@ -566,6 +579,12 @@ export default function LiveContextMenu({
streamMetadata={streamMetadata}
/>
</Dialog>
<CustomSuspensionDialog
open={customDialogOpen}
onOpenChange={setCustomDialogOpen}
onConfirm={(minutes) => sendNotificationSuspend(minutes)}
/>
</div>
);
}

View File

@ -1,6 +1,12 @@
import { RecordingsSummary, ReviewSummary } from "@/types/review";
import { Calendar } from "../ui/calendar";
import { ButtonHTMLAttributes, useEffect, useMemo, useRef } from "react";
import {
ButtonHTMLAttributes,
ComponentProps,
useEffect,
useMemo,
useRef,
} from "react";
import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil";
import { type DayButtonProps } from "react-day-picker";
@ -156,11 +162,13 @@ type TimezoneAwareCalendarProps = {
timezone?: string;
selectedDay?: Date;
onSelect: (day?: Date) => void;
disabled?: ComponentProps<typeof Calendar>["disabled"];
};
export function TimezoneAwareCalendar({
timezone,
selectedDay,
onSelect,
disabled,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
@ -169,7 +177,7 @@ export function TimezoneAwareCalendar({
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
[timezone],
);
const disabledDates = useMemo(() => {
const defaultDisabledDates = useMemo(() => {
const tomorrow = new Date();
if (timezoneOffset) {
@ -187,6 +195,7 @@ export function TimezoneAwareCalendar({
future.setFullYear(tomorrow.getFullYear() + 10);
return { from: tomorrow, to: future };
}, [timezoneOffset]);
const disabledDates = disabled ?? defaultDisabledDates;
const today = useMemo(() => {
if (!timezoneOffset) {

View File

@ -0,0 +1,167 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { isDesktop } from "react-device-detect";
import { FaCalendarAlt } from "react-icons/fa";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
type CustomSuspensionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (minutes: number) => void;
};
const ONE_HOUR_MS = 60 * 60 * 1000;
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
function isValidDate(d: Date): boolean {
return !Number.isNaN(d.getTime());
}
export default function CustomSuspensionDialog({
open,
onOpenChange,
onConfirm,
}: CustomSuspensionDialogProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [until, setUntil] = useState<Date>(
() => new Date(Date.now() + ONE_HOUR_MS),
);
const [calendarOpen, setCalendarOpen] = useState(false);
useEffect(() => {
if (open) setUntil(new Date(Date.now() + ONE_HOUR_MS));
}, [open]);
const formattedDate = useFormattedTimestamp(
isValidDate(until) ? Math.floor(until.getTime() / 1000) : 0,
t("time.formattedTimestampMonthDayYear.24hour", { ns: "common" }),
config?.ui.timezone,
);
const isFuture = isValidDate(until) && until.getTime() > Date.now();
const handleApply = () => {
if (!isFuture) return;
onConfirm(Math.ceil((until.getTime() - Date.now()) / 60_000));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("notification.customSuspension.title")}</DialogTitle>
<DialogDescription>
{t("notification.customSuspension.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label>{t("notification.customSuspension.untilLabel")}</Label>
<div className="flex items-center gap-2 rounded-lg bg-secondary p-2 text-secondary-foreground">
<FaCalendarAlt />
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={calendarOpen ? "select" : "default"}
size="sm"
>
{isValidDate(until) ? formattedDate : "—"}
</Button>
</PopoverTrigger>
<PopoverContent
className="flex flex-col items-center"
disablePortal
>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={isValidDate(until) ? until : undefined}
disabled={{
before: new Date(new Date().setHours(0, 0, 0, 0)),
}}
onSelect={(day) => {
if (!day) return;
const next = new Date(day);
const carry = isValidDate(until) ? until : new Date();
next.setHours(
carry.getHours(),
carry.getMinutes(),
carry.getSeconds(),
0,
);
setUntil(next);
setCalendarOpen(false);
}}
/>
</PopoverContent>
</Popover>
<input
className="text-md border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
aria-label={t("notification.customSuspension.untilLabel")}
type="time"
value={
isValidDate(until)
? `${pad(until.getHours())}:${pad(until.getMinutes())}`
: ""
}
step="60"
onChange={(e) => {
const [h, m] = e.target.value.split(":");
const hh = Number.parseInt(h ?? "", 10);
const mm = Number.parseInt(m ?? "", 10);
if (Number.isNaN(hh) || Number.isNaN(mm)) return;
const base = isValidDate(until) ? until : new Date();
const next = new Date(base);
next.setHours(hh, mm, 0, 0);
setUntil(next);
}}
/>
</div>
{!isFuture && (
<p className="text-sm text-danger">
{t("notification.customSuspension.invalidTime")}
</p>
)}
</div>
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
type="button"
disabled={!isFuture}
onClick={handleApply}
>
{t("button.apply", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -258,8 +258,9 @@ export default function MotionMaskEditPane({
},
);
updateConfig();
// Only publish WS state for base config when mask has a name
if (!editingProfile && maskName) {
// Only publish WS state for base config when mask has a name and
// wasn't renamed (the hook is bound to the old name).
if (!editingProfile && maskName && !renamingMask) {
sendMotionMaskState(enabled ? "ON" : "OFF");
}
} else {
@ -414,6 +415,7 @@ export default function MotionMaskEditPane({
nameLabel={t("masksAndZones.motionMasks.name.title")}
nameDescription={t("masksAndZones.motionMasks.name.description")}
placeholderName={t("masksAndZones.motionMasks.name.placeholder")}
idDisabled={!!editingProfile && polygon.name.length > 0}
/>
<FormField
control={form.control}

View File

@ -263,8 +263,9 @@ export default function ObjectMaskEditPane({
},
);
updateConfig();
// Only publish WS state for base config when mask has a name
if (!editingProfile && maskName) {
// Only publish WS state for base config when mask has a name and
// wasn't renamed (the hook is bound to the old name).
if (!editingProfile && maskName && !renamingMask) {
sendObjectMaskState(enabled ? "ON" : "OFF");
}
} else {
@ -389,6 +390,7 @@ export default function ObjectMaskEditPane({
placeholderName={t(
"masksAndZones.objectMasks.name.placeholder",
)}
idDisabled={!!editingProfile && polygon.name.length > 0}
/>
<FormField
control={form.control}

View File

@ -94,6 +94,28 @@ export default function ZoneEditPane({
const zoneName = polygon?.name || "";
const { send: sendZoneState } = useZoneState(polygon?.camera || "", zoneName);
const isExistingZone = !!polygon && polygon.name.length > 0;
const idDisabled = useMemo(() => {
if (!isExistingZone || !polygon) {
return false;
}
if (editingProfile) {
return true;
}
const cam = config?.cameras[polygon.camera];
if (!cam) {
return false;
}
const inRequiredZones =
cam.review.alerts.required_zones.includes(polygon.name) ||
cam.review.detections.required_zones.includes(polygon.name);
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
(profile) => profile?.zones && polygon.name in profile.zones,
);
return inRequiredZones || hasProfileOverride;
}, [config, polygon, editingProfile, isExistingZone]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
@ -419,6 +441,7 @@ export default function ZoneEditPane({
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
@ -444,6 +467,7 @@ export default function ZoneEditPane({
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
}
@ -527,8 +551,9 @@ export default function ZoneEditPane({
},
);
updateConfig();
// Only publish WS state for base config when zone has a name
if (!editingProfile && polygon?.name) {
// Only publish WS state for base config when zone has a name and
// wasn't renamed (the hook is bound to the old name).
if (!editingProfile && polygon?.name && !renamingZone) {
sendZoneState(enabled ? "ON" : "OFF");
}
} else {
@ -650,6 +675,7 @@ export default function ZoneEditPane({
nameLabel={t("masksAndZones.zones.name.title")}
nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
idDisabled={idDisabled}
/>
<FormField
control={form.control}

View File

@ -15,7 +15,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
Collapsible,
@ -1652,8 +1651,6 @@ export default function Settings() {
const isMultiItem = filteredItems.length > 1;
const renderedExpanded =
!isMultiItem || !collapsedGroups.has(group.label);
const showCameraBadge =
group.label === "cameras" && !!selectedCamera;
const items = filteredItems.map((item) => (
<MobileMenuItem
key={item.key}
@ -1681,7 +1678,16 @@ export default function Settings() {
onOpenChange={() => toggleGroupCollapsed(group.label)}
>
<CollapsibleTrigger className="flex min-h-10 w-full items-center justify-between rounded-md py-2 pl-2 pr-2 text-sm font-medium text-secondary-foreground">
<div>{t("menu." + group.label)}</div>
<div className="flex flex-col justify-start gap-0.5 text-left">
{t("menu." + group.label)}
{group.label === "cameras" &&
renderedExpanded &&
selectedCamera && (
<div className="max-w-full break-words text-xs text-secondary-foreground/80 smart-capitalize">
<CameraNameLabel camera={selectedCamera} />
</div>
)}
</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",
@ -1689,19 +1695,7 @@ export default function Settings() {
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
{showCameraBadge && (
<div className="mb-2 ml-4 mr-4 mt-2">
<Badge
variant="outline"
className="max-w-full break-words smart-capitalize"
>
<CameraNameLabel camera={selectedCamera} />
</Badge>
</div>
)}
{items}
</CollapsibleContent>
<CollapsibleContent>{items}</CollapsibleContent>
</Collapsible>
) : (
items
@ -2030,8 +2024,33 @@ export default function Settings() {
: "text-sidebar-foreground/80",
)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div>{t("menu." + group.label)}</div>
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between",
renderedExpanded &&
group.label == "cameras" &&
"mb-2",
)}
>
<div className="flex flex-col justify-start gap-0.5 text-left">
{t("menu." + group.label)}
{group.label === "cameras" &&
renderedExpanded &&
selectedCamera && (
<div
className={cn(
"max-w-full break-words text-xs smart-capitalize",
hasActiveItem
? "text-primary/60"
: "text-sidebar-foreground/80",
)}
>
<CameraNameLabel
camera={selectedCamera}
/>
</div>
)}
</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",
@ -2042,22 +2061,6 @@ export default function Settings() {
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarMenuSub className="mx-2 border-0 md:mx-0">
{group.label === "cameras" &&
selectedCamera && (
<li
className="mb-1 ml-0 mr-3 mt-0"
aria-hidden="true"
>
<Badge
variant="outline"
className="max-w-full break-words smart-capitalize"
>
<CameraNameLabel
camera={selectedCamera}
/>
</Badge>
</li>
)}
{filteredItems.map((item) => (
<SidebarMenuSubItem key={item.key}>
<SidebarMenuSubButton