mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-13 06:35:24 +03:00
Use zod form to control notification settings in the UI
This commit is contained in:
parent
104d56af25
commit
fa8a0cc5fc
22
docs/docs/configuration/notifications.md
Normal file
22
docs/docs/configuration/notifications.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -54,6 +54,7 @@ module.exports = {
|
||||
],
|
||||
"Extra Configuration": [
|
||||
"configuration/authentication",
|
||||
"configuration/notifications",
|
||||
"configuration/hardware_acceleration",
|
||||
"configuration/ffmpeg_presets",
|
||||
"configuration/tls",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -343,6 +343,7 @@ export interface FrigateConfig {
|
||||
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
objects: {
|
||||
|
||||
@ -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}¬ifications.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");
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user