Use zod form to control notification settings in the UI

This commit is contained in:
Nicolas Mowen 2024-07-20 14:42:15 -06:00
parent 104d56af25
commit fa8a0cc5fc
8 changed files with 245 additions and 26 deletions

View File

@ -0,0 +1,22 @@
---
id: notifications
title: Notifications
---
# Notifications
Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption.
## Configuration
To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save.
:::note
Currently, notifications are only supported in Chrome and Firefox browsers.
:::
## Registration
Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent.

View File

@ -372,6 +372,14 @@ motion:
# Optional: Delay when updating camera motion through MQTT from ON -> OFF (default: shown below).
mqtt_off_delay: 30
# Optional: Notification Configuration
notifications:
# Optional: Enable notification service (default: shown below)
enabled: False
# Optional: Email for push service to reach out to
# NOTE: This is required to use notifications
email: "admin@example.com"
# Optional: Record configuration
# NOTE: Can be overridden at the camera level
record:
@ -642,8 +650,8 @@ cameras:
user: admin
# Optional: password for login.
password: admin
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
ignore_time_mismatch: False
# Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera.

View File

@ -54,6 +54,7 @@ module.exports = {
],
"Extra Configuration": [
"configuration/authentication",
"configuration/notifications",
"configuration/hardware_acceleration",
"configuration/ffmpeg_presets",
"configuration/tls",

View File

@ -62,7 +62,6 @@ class WebPushClient(Communicator): # type: ignore[misc]
).timestamp(),
}
self.claim_headers = self.vapid.sign(self.claim)
logger.info(f"Updated claim with new headers {self.claim_headers}")
# Only notify for alerts
if payload["after"]["severity"] != "alert":
@ -94,8 +93,6 @@ class WebPushClient(Communicator): # type: ignore[misc]
direct_url = f"/review?id={reviewId}"
image = f'{payload["after"]["thumb_path"].replace("/media/frigate", "")}'
logger.info(f"the image for testing is {image}")
for pusher in self.web_pushers:
pusher.send(
headers=self.claim_headers,

View File

@ -171,9 +171,7 @@ class AuthConfig(FrigateBaseModel):
class NotificationConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable notifications")
email: Optional[str] = Field(
default=None, title="Email required for push."
)
email: Optional[str] = Field(default=None, title="Email required for push.")
class StatsConfig(FrigateBaseModel):

View File

@ -60,17 +60,13 @@ export default function Settings() {
const settingsViews = useMemo(() => {
const views = [...allSettingsViews];
if (
!("Notification" in window) ||
!window.isSecureContext ||
!config?.notifications.enabled
) {
if (!("Notification" in window) || !window.isSecureContext) {
const index = views.indexOf("notifications");
views.splice(index, 1);
}
return views;
}, [config]);
}, []);
// TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false);
@ -200,7 +196,9 @@ export default function Settings() {
/>
)}
{page == "users" && <AuthenticationView />}
{page == "notifications" && <NotificationView />}
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}
</div>
{confirmationDialogOpen && (
<AlertDialog

View File

@ -343,6 +343,7 @@ export interface FrigateConfig {
notifications: {
enabled: boolean;
email?: string;
};
objects: {

View File

@ -1,17 +1,53 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Toaster } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
import { z } from "zod";
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.ts";
export default function NotificationView() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
type NotificationSettingsValueType = {
enabled: boolean;
email?: string;
};
type NotificationsSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function NotificationView({
setUnsavedChanges,
}: NotificationsSettingsViewProps) {
const { data: config, mutate: updateConfig } = useSWR<FrigateConfig>(
"config",
{
revalidateOnFocus: false,
},
);
// status bar
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// notification key handling
@ -23,6 +59,13 @@ export default function NotificationView() {
const subscribeToNotifications = useCallback(
(registration: ServiceWorkerRegistration) => {
if (registration) {
addMessage(
"notification_settings",
"Unsaved Notification Registrations",
undefined,
"registration",
);
registration.pushManager
.subscribe({
userVisibleOnly: true,
@ -32,10 +75,16 @@ export default function NotificationView() {
axios.post("notifications/register", {
sub: pushSubscription,
});
toast.success(
"Successfully registered for notifications. Restart to start receiving notifications.",
{
position: "top-center",
},
);
});
}
},
[publicKey],
[publicKey, addMessage],
);
// notification state
@ -58,6 +107,78 @@ export default function NotificationView() {
});
}, []);
// form
const [isLoading, setIsLoading] = useState(false);
const formSchema = z.object({
enabled: z.boolean(),
email: z.string(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
enabled: config?.notifications.enabled,
email: config?.notifications.email,
},
});
const onCancel = useCallback(() => {
if (!config) {
return;
}
setUnsavedChanges(false);
form.reset({
enabled: config.notifications.enabled,
email: config.notifications.email || "",
});
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, removeMessage, setUnsavedChanges]);
const saveToConfig = useCallback(
async (
{ enabled, email }: NotificationSettingsValueType, // values submitted via the form
) => {
axios
.put(
`config/set?notifications.enabled=${enabled}&notifications.email=${email}`,
{
requires_restart: 0,
},
)
.then((res) => {
if (res.status === 200) {
toast.success("Notification settings have been saved.", {
position: "top-center",
});
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, setIsLoading],
);
function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
saveToConfig(values as NotificationSettingsValueType);
}
return (
<>
<div className="flex size-full flex-col md:flex-row">
@ -67,13 +188,85 @@ export default function NotificationView() {
Notification Settings
</Heading>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6"
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex flex-row items-center justify-start gap-2">
<Label className="cursor-pointer" htmlFor="auto-live">
Notifications
</Label>
<Switch
id="auto-live"
checked={field.value}
onCheckedChange={(checked) => {
return field.onChange(checked);
}}
/>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Loitering Time</FormLabel>
<FormControl>
<Input
className="w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="0"
{...field}
/>
</FormControl>
<FormDescription>
Entering a valid email is required, as this is used by the
push server in case problems occur.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
onClick={onCancel}
type="button"
>
Cancel
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</form>
</Form>
{config?.notifications.enabled && (
<div className="mt-2 space-y-6">
<div className="mt-4 space-y-6">
<div className="space-y-3">
{
// TODO need to register the worker before enabling the notifications button
// TODO make the notifications button show enable / disable depending on current state
}
<Separator className="my-2 flex bg-secondary" />
<Button
disabled={publicKey == undefined}
onClick={() => {
@ -99,6 +292,7 @@ export default function NotificationView() {
} else {
registration.unregister();
setRegistration(null);
removeMessage("notification_settings", "registration");
}
}}
>