mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Feat/notifications alerts (#52)
* feat: notifications backend & creation * feat: assign notification to backup schedule * refactor: status dot one component * chore(notification-details): remove refetchInterval
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
|
||||
type BackupStatus = "active" | "paused" | "error" | "in_progress";
|
||||
import { StatusDot } from "~/client/components/status-dot";
|
||||
|
||||
export const BackupStatusDot = ({
|
||||
enabled,
|
||||
@@ -12,60 +9,22 @@ export const BackupStatusDot = ({
|
||||
hasError?: boolean;
|
||||
isInProgress?: boolean;
|
||||
}) => {
|
||||
let status: BackupStatus = "paused";
|
||||
let variant: "success" | "neutral" | "error" | "info";
|
||||
let label: string;
|
||||
|
||||
if (isInProgress) {
|
||||
status = "in_progress";
|
||||
variant = "info";
|
||||
label = "Backup in progress";
|
||||
} else if (hasError) {
|
||||
status = "error";
|
||||
variant = "error";
|
||||
label = "Error";
|
||||
} else if (enabled) {
|
||||
status = "active";
|
||||
variant = "success";
|
||||
label = "Active";
|
||||
} else {
|
||||
variant = "neutral";
|
||||
label = "Paused";
|
||||
}
|
||||
|
||||
const statusMapping = {
|
||||
active: {
|
||||
color: "bg-green-500",
|
||||
colorLight: "bg-emerald-400",
|
||||
animated: true,
|
||||
label: "Active",
|
||||
},
|
||||
paused: {
|
||||
color: "bg-gray-500",
|
||||
colorLight: "bg-gray-400",
|
||||
animated: false,
|
||||
label: "Paused",
|
||||
},
|
||||
error: {
|
||||
color: "bg-red-500",
|
||||
colorLight: "bg-red-400",
|
||||
animated: true,
|
||||
label: "Error",
|
||||
},
|
||||
in_progress: {
|
||||
color: "bg-blue-500",
|
||||
colorLight: "bg-blue-400",
|
||||
animated: true,
|
||||
label: "Backup in progress",
|
||||
},
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="relative flex size-3 mx-auto">
|
||||
{statusMapping.animated && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
|
||||
`${statusMapping.colorLight}`,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{statusMapping.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
return <StatusDot variant={variant} label={label} />;
|
||||
};
|
||||
|
||||
@@ -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<Map<number, NotificationAssignment>>(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<number, NotificationAssignment>();
|
||||
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<number, NotificationAssignment>();
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notifications
|
||||
</CardTitle>
|
||||
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
|
||||
</div>
|
||||
{!isAddingNew && availableDestinations.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add notification
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isAddingNew && (
|
||||
<div className="mb-6 flex items-center gap-2 max-w-md">
|
||||
<Select onValueChange={addDestination}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a notification destination..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDestinations.map((destination) => (
|
||||
<SelectItem key={destination.id} value={destination.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{destination.name}</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">({destination.type})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignedDestinations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<Bell className="h-8 w-8 mb-2 opacity-20" />
|
||||
<p className="text-sm">No notifications configured for this schedule.</p>
|
||||
<p className="text-xs mt-1">Click "Add notification" to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Start</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Success</TableHead>
|
||||
<TableHead className="text-center w-[100px]">Failure</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignedDestinations.map((destination) => {
|
||||
const assignment = assignments.get(destination.id);
|
||||
if (!assignment) return null;
|
||||
|
||||
return (
|
||||
<TableRow key={destination.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{destination.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] align-middle">
|
||||
{destination.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
className="align-middle"
|
||||
checked={assignment.notifyOnStart}
|
||||
onCheckedChange={() => toggleEvent(destination.id, "notifyOnStart")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
className="align-middle"
|
||||
checked={assignment.notifyOnSuccess}
|
||||
onCheckedChange={() => toggleEvent(destination.id, "notifyOnSuccess")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
className="align-middle"
|
||||
checked={assignment.notifyOnFailure}
|
||||
onCheckedChange={() => toggleEvent(destination.id, "notifyOnFailure")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeDestination(destination.id)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex gap-2 justify-end mt-4 pt-4">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleSave} loading={updateNotifications.isPending}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user