diff --git a/app/client/lib/types.ts b/app/client/lib/types.ts index 872d6381..4c99ae57 100644 --- a/app/client/lib/types.ts +++ b/app/client/lib/types.ts @@ -3,6 +3,7 @@ import type { GetMeResponse, GetRepositoryResponse, GetVolumeResponse, + ListNotificationDestinationsResponse, ListSnapshotsResponse, } from "../api-client"; @@ -17,3 +18,5 @@ export type Repository = GetRepositoryResponse; export type BackupSchedule = GetBackupScheduleResponse; export type Snapshot = ListSnapshotsResponse[number]; + +export type NotificationDestination = ListNotificationDestinationsResponse[number]; diff --git a/app/client/modules/backups/components/schedule-notifications-config.tsx b/app/client/modules/backups/components/schedule-notifications-config.tsx new file mode 100644 index 00000000..40358c96 --- /dev/null +++ b/app/client/modules/backups/components/schedule-notifications-config.tsx @@ -0,0 +1,267 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Bell, Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "~/client/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import { Switch } from "~/client/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; +import { Badge } from "~/client/components/ui/badge"; +import { + getScheduleNotificationsOptions, + updateScheduleNotificationsMutation, +} from "~/client/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/client/lib/errors"; +import type { NotificationDestination } from "~/client/lib/types"; + +type Props = { + scheduleId: number; + destinations: NotificationDestination[]; +}; + +type NotificationAssignment = { + destinationId: number; + notifyOnStart: boolean; + notifyOnSuccess: boolean; + notifyOnFailure: boolean; +}; + +export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) => { + const [assignments, setAssignments] = useState>(new Map()); + const [hasChanges, setHasChanges] = useState(false); + const [isAddingNew, setIsAddingNew] = useState(false); + + const { data: currentAssignments } = useQuery({ + ...getScheduleNotificationsOptions({ path: { scheduleId: scheduleId.toString() } }), + }); + + const updateNotifications = useMutation({ + ...updateScheduleNotificationsMutation(), + onSuccess: () => { + toast.success("Notification settings saved successfully"); + setHasChanges(false); + }, + onError: (error) => { + toast.error("Failed to save notification settings", { + description: parseError(error)?.message, + }); + }, + }); + + useEffect(() => { + if (currentAssignments) { + const map = new Map(); + for (const assignment of currentAssignments) { + map.set(assignment.destinationId, { + destinationId: assignment.destinationId, + notifyOnStart: assignment.notifyOnStart, + notifyOnSuccess: assignment.notifyOnSuccess, + notifyOnFailure: assignment.notifyOnFailure, + }); + } + + setAssignments(map); + } + }, [currentAssignments]); + + const addDestination = (destinationId: string) => { + const id = Number.parseInt(destinationId, 10); + const newAssignments = new Map(assignments); + newAssignments.set(id, { + destinationId: id, + notifyOnStart: false, + notifyOnSuccess: false, + notifyOnFailure: true, + }); + + setAssignments(newAssignments); + setHasChanges(true); + setIsAddingNew(false); + }; + + const removeDestination = (destinationId: number) => { + const newAssignments = new Map(assignments); + newAssignments.delete(destinationId); + setAssignments(newAssignments); + setHasChanges(true); + }; + + const toggleEvent = (destinationId: number, event: "notifyOnStart" | "notifyOnSuccess" | "notifyOnFailure") => { + const assignment = assignments.get(destinationId); + if (!assignment) return; + + const newAssignments = new Map(assignments); + newAssignments.set(destinationId, { + ...assignment, + [event]: !assignment[event], + }); + + setAssignments(newAssignments); + setHasChanges(true); + }; + + const handleSave = () => { + const assignmentsList = Array.from(assignments.values()); + updateNotifications.mutate({ + path: { scheduleId: scheduleId.toString() }, + body: { + assignments: assignmentsList, + }, + }); + }; + + const handleReset = () => { + if (currentAssignments) { + const map = new Map(); + for (const assignment of currentAssignments) { + map.set(assignment.destinationId, { + destinationId: assignment.destinationId, + notifyOnStart: assignment.notifyOnStart, + notifyOnSuccess: assignment.notifyOnSuccess, + notifyOnFailure: assignment.notifyOnFailure, + }); + } + setAssignments(map); + setHasChanges(false); + } + }; + + const getDestinationById = (id: number) => { + return destinations?.find((d) => d.id === id); + }; + + const availableDestinations = destinations?.filter((d) => !assignments.has(d.id)) || []; + const assignedDestinations = Array.from(assignments.keys()) + .map((id) => getDestinationById(id)) + .filter((d) => d !== undefined); + + return ( + + +
+
+ + + Notifications + + Configure which notifications to send for this backup schedule +
+ {!isAddingNew && availableDestinations.length > 0 && ( + + )} +
+
+ + {isAddingNew && ( +
+ + +
+ )} + + {assignedDestinations.length === 0 ? ( +
+ +

No notifications configured for this schedule.

+

Click "Add notification" to get started.

+
+ ) : ( +
+ + + + Destination + Start + Success + Failure + + + + + {assignedDestinations.map((destination) => { + const assignment = assignments.get(destination.id); + if (!assignment) return null; + + return ( + + +
+ {destination.name} + + {destination.type} + +
+
+ + toggleEvent(destination.id, "notifyOnStart")} + /> + + + toggleEvent(destination.id, "notifyOnSuccess")} + /> + + + toggleEvent(destination.id, "notifyOnFailure")} + /> + + + + +
+ ); + })} +
+
+
+ )} + + {hasChanges && ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 0be7a2a1..c7c82889 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -29,7 +29,9 @@ import { ScheduleSummary } from "../components/schedule-summary"; import type { Route } from "./+types/backup-details"; import { SnapshotFileBrowser } from "../components/snapshot-file-browser"; import { SnapshotTimeline } from "../components/snapshot-timeline"; -import { getBackupSchedule } from "~/client/api-client"; +import { getBackupSchedule, listNotificationDestinations } from "~/client/api-client"; +import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config"; +import { cn } from "~/client/lib/utils"; export const handle = { breadcrumb: (match: Route.MetaArgs) => [ @@ -49,11 +51,12 @@ export function meta(_: Route.MetaArgs) { } export const clientLoader = async ({ params }: Route.LoaderArgs) => { - const { data } = await getBackupSchedule({ path: { scheduleId: params.id } }); + const schedule = await getBackupSchedule({ path: { scheduleId: params.id } }); + const notifs = await listNotificationDestinations(); - if (!data) return redirect("/backups"); + if (!schedule.data) return redirect("/backups"); - return data; + return { schedule: schedule.data, notifs: notifs.data }; }; export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) { @@ -66,7 +69,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const { data: schedule } = useQuery({ ...getBackupScheduleOptions({ path: { scheduleId: params.id } }), - initialData: loaderData, + initialData: loaderData.schedule, refetchInterval: 10000, refetchOnWindowFocus: true, }); @@ -222,6 +225,9 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon setIsEditMode={setIsEditMode} schedule={schedule} /> +
+ +