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). # Optional: Delay when updating camera motion through MQTT from ON -> OFF (default: shown below).
mqtt_off_delay: 30 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 # Optional: Record configuration
# NOTE: Can be overridden at the camera level # NOTE: Can be overridden at the camera level
record: record:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,53 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button"; 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 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 { Toaster } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios"; 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 useSWR from "swr";
import { z } from "zod";
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.ts"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.ts";
export default function NotificationView() { type NotificationSettingsValueType = {
const { data: config } = useSWR<FrigateConfig>("config", { 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, revalidateOnFocus: false,
}); },
);
// status bar
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// notification key handling // notification key handling
@ -23,6 +59,13 @@ export default function NotificationView() {
const subscribeToNotifications = useCallback( const subscribeToNotifications = useCallback(
(registration: ServiceWorkerRegistration) => { (registration: ServiceWorkerRegistration) => {
if (registration) { if (registration) {
addMessage(
"notification_settings",
"Unsaved Notification Registrations",
undefined,
"registration",
);
registration.pushManager registration.pushManager
.subscribe({ .subscribe({
userVisibleOnly: true, userVisibleOnly: true,
@ -32,10 +75,16 @@ export default function NotificationView() {
axios.post("notifications/register", { axios.post("notifications/register", {
sub: pushSubscription, sub: pushSubscription,
}); });
toast.success(
"Successfully registered for notifications. Restart to start receiving notifications.",
{
position: "top-center",
},
);
}); });
} }
}, },
[publicKey], [publicKey, addMessage],
); );
// notification state // 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 ( return (
<> <>
<div className="flex size-full flex-col md:flex-row"> <div className="flex size-full flex-col md:flex-row">
@ -67,13 +188,85 @@ export default function NotificationView() {
Notification Settings Notification Settings
</Heading> </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 && ( {config?.notifications.enabled && (
<div className="mt-2 space-y-6"> <div className="mt-4 space-y-6">
<div className="space-y-3"> <div className="space-y-3">
{ <Separator className="my-2 flex bg-secondary" />
// TODO need to register the worker before enabling the notifications button
// TODO make the notifications button show enable / disable depending on current state
}
<Button <Button
disabled={publicKey == undefined} disabled={publicKey == undefined}
onClick={() => { onClick={() => {
@ -99,6 +292,7 @@ export default function NotificationView() {
} else { } else {
registration.unregister(); registration.unregister();
setRegistration(null); setRegistration(null);
removeMessage("notification_settings", "registration");
} }
}} }}
> >