Working on implementation of alert policies.

This commit is contained in:
charlesgauthereau
2025-11-23 17:13:07 +01:00
parent eb471c7cdb
commit d968aa9c16
20 changed files with 2598 additions and 56 deletions
@@ -0,0 +1,287 @@
import {Form, FormControl, FormField, FormItem, FormMessage, useZodForm} from "@/components/ui/form";
import {Plus, Trash2} from "lucide-react";
import {useFieldArray} from "react-hook-form";
import {
AlertPoliciesSchema, AlertPoliciesType, AlertPolicySchema, AlertPolicyType,
EVENT_KIND_OPTIONS
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.schema";
import {DatabaseWith} from "@/db/schema/07_database";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Label} from "@/components/ui/label";
import {Button} from "@/components/ui/button";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Separator} from "@/components/ui/separator";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {useMutation} from "@tanstack/react-query";
import {deleteBackupAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {
createAlertPoliciesAction, deleteAlertPoliciesAction,
updateAlertPoliciesAction
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.action";
import {useRouter} from "next/navigation";
type AlertPolicyFormProps = {
onSuccess?: () => void;
notificationChannels: NotificationChannel[];
organizationId: string;
database: DatabaseWith;
};
export const AlertPolicyForm = ({database, notificationChannels, organizationId, onSuccess}: AlertPolicyFormProps) => {
const router = useRouter()
console.log("database",database)
const organizationNotificationChannels = notificationChannels.map(channel => channel.id) ?? [];
const formattedAlertPoliciesList = (alertPolicies: AlertPolicy[]) => {
return alertPolicies.map((alertPolicy) => ({
notificationChannelId: alertPolicy.notificationChannelId,
eventKinds: alertPolicy.eventKinds
}));
};
const form = useZodForm({
schema: AlertPoliciesSchema,
defaultValues: {
alertPolicies: database.alertPolicies && database.alertPolicies.length > 0 ? formattedAlertPoliciesList(database.alertPolicies) : [{
notificationChannelId: "",
eventKinds: [],
}],
},
});
const {fields, append, remove} = useFieldArray({
control: form.control,
name: "alertPolicies",
})
const addAlertPolicy = () => {
append({id: "", eventKinds: []})
}
const removeAlertPolicy = (index: number) => {
remove(index)
}
const onCancel = () => {
form.reset()
onSuccess?.()
}
const mutation = useMutation({
mutationFn: async ({alertPolicies}: AlertPoliciesType) => {
console.log(alertPolicies)
const defaultFormatedAlertPolicies = formattedAlertPoliciesList(database?.alertPolicies ?? []);
const alertPoliciesToAdd = alertPolicies?.filter(
(alertPolicy) => !defaultFormatedAlertPolicies.some((a) => a.notificationChannelId == alertPolicy.notificationChannelId)
) ?? [];
const alertPoliciesToRemove = defaultFormatedAlertPolicies.filter(
(alertPolicy) => !alertPolicies?.some((v) => v.notificationChannelId === alertPolicy.notificationChannelId)
) ?? [];
const alertPoliciesToUpdate = alertPolicies?.filter((alertPolicy) => {
const existing = defaultFormatedAlertPolicies.find((a) => a.notificationChannelId === alertPolicy.notificationChannelId);
return existing &&
(existing.eventKinds !== alertPolicy.eventKinds);
}) ?? [];
console.log("alertPoliciesToAdd", alertPoliciesToAdd)
console.log("alertPoliciesToRemove", alertPoliciesToRemove)
console.log("alertPoliciesToUpdate", alertPoliciesToUpdate)
const results = await Promise.allSettled([
alertPoliciesToAdd.length > 0
? createAlertPoliciesAction({
databaseId: database.id,
alertPolicies: alertPoliciesToAdd,
})
: Promise.resolve(null),
alertPoliciesToUpdate.length > 0
? updateAlertPoliciesAction({
databaseId: database.id,
alertPolicies: alertPoliciesToUpdate,
})
: Promise.resolve(null),
alertPoliciesToRemove.length > 0
? deleteAlertPoliciesAction({
databaseId: database.id,
alertPolicies: alertPoliciesToRemove as AlertPolicyType[],
})
: Promise.resolve(null),
]);
console.log(results);
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (rejected) {
throw new Error(rejected.reason?.message || "Network or server error");
}
const failedActions = results
.filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled")
.map(r => r.value)
.filter((value): value is { data: { success: false; actionError: any } } =>
value !== null && typeof value === "object" && value.data.success === false
);
console.log("failedActions",failedActions);
if (failedActions.length > 0) {
const firstError = failedActions[0].data.actionError;
const message = firstError?.message || "One or more operations failed";
throw new Error(message);
}
return {success: true};
},
onSuccess: () => {
toast.success("Alert policies saved successfully");
onSuccess?.();
router.refresh();
},
onError: (error: any) => {
toast.error(error.message || "Failed to save alert policies");
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Alert Policies</Label>
<Button
disabled={
fields.length >= notificationChannels.length ||
notificationChannels.length === 0
}
type="button"
variant="outline" size="sm" onClick={addAlertPolicy}>
<Plus className="w-4 h-4 mr-2"/>
Add
</Button>
</div>
<div className="space-y-3 w-full">
{organizationNotificationChannels.length === 0 ? (
<div className="text-muted-foreground text-sm text-center border border-dashed rounded-lg p-4">
No alerts policy in organization
</div>
) : fields.length === 0 ? (
<div className="text-muted-foreground text-sm text-center">
No alert policy, create one
</div>
) : (
fields.map((field, index) => (
<div key={field.id}>
<div className="flex w-full gap-3">
<div className="flex-1">
<FormField
control={form.control}
name={`alertPolicies.${index}.notificationChannelId`}
render={({field}) => {
const selectedIds = form
.watch("alertPolicies")
.map((a: AlertPolicyType) => a.notificationChannelId)
.filter(Boolean);
const availableNotificationChannels = notificationChannels.filter(
(channel) =>
channel.id.toString() === field.value?.toString() ||
!selectedIds.includes(channel.id.toString())
);
return (
<FormItem>
<Select onValueChange={field.onChange}
value={field.value?.toString() || ""}
>
<FormControl>
<SelectTrigger className="h-10 w-full">
<SelectValue
placeholder="Select notification channel"/>
</SelectTrigger>
</FormControl>
<SelectContent>
{availableNotificationChannels.map((channel) => (
<SelectItem key={channel.id.toString()}
value={channel.id.toString()}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage className="mb-2"/>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name={`alertPolicies.${index}.eventKinds`}
render={({field}) => (
<FormItem>
<FormControl>
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select event kinds"
variant="inverted"
animation={2}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div>
<Button type="button" variant="outline"
onClick={() => removeAlertPolicy(index)}>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
</div>
{index + 1 < fields.length && <Separator className="mt-3"/>}
</div>
))
)}
</div>
</div>
<div className="flex gap-4 justify-end">
<ButtonWithLoading variant="outline" type="button" onClick={onCancel}>
Cancel
</ButtonWithLoading>
<ButtonWithLoading isPending={false}>
Submit
</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,53 @@
"use client"
import {Megaphone} from "lucide-react";
import {useState} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {AlertPolicyForm} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy-form";
import {Database} from "@/db/schema/07_database";
import {NotificationChannel} from "@/db/schema/09_notification-channel";
import {Separator} from "@/components/ui/separator";
type AlertPolicyModalProps = {
database: Database;
notificationChannels: NotificationChannel[];
organizationId: string;
}
export const AlertPolicyModal = ({database, notificationChannels, organizationId}: AlertPolicyModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Megaphone/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Alert policies</DialogTitle>
<DialogDescription>
Add and manage your database alert policies
</DialogDescription>
<Separator className="mt-3 mb-3"/>
<AlertPolicyForm
organizationId={organizationId}
notificationChannels={notificationChannels}
database={database}
onSuccess={() => setOpen(false)}
/>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,184 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
import {
AlertPolicySchema
} from "@/components/wrappers/dashboard/database/alert-policy/alert-policy.schema";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
export const createAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string(),
alertPolicies: z.array(AlertPolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
try {
const valuesToInsert = parsedInput.alertPolicies.map((policy) => ({
databaseId: parsedInput.databaseId,
notificationChannelId: policy.notificationChannelId,
eventKinds: policy.eventKinds,
}));
const insertedPolicies = await db
.insert(drizzleDb.schemas.alertPolicy)
.values(valuesToInsert)
.returning();
return {
success: true,
value: insertedPolicies,
actionSuccess: {
message: `Alert policies successfully added`,
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to add policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const updateAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
alertPolicies: z.array(AlertPolicySchema),
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<AlertPolicy[]>> => {
const {databaseId, alertPolicies} = parsedInput;
try {
const updatedPolicies = await db.transaction(async (tx) => {
const results: AlertPolicy[] = [];
for (const policy of alertPolicies) {
const {notificationChannelId, ...updateData} = policy;
const updated = await tx
.update(drizzleDb.schemas.alertPolicy)
.set(withUpdatedAt({
...updateData,
}))
.where(
and(
eq(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelId),
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId)
)
)
.returning();
if (updated[0]) {
results.push(updated[0]);
}
}
return results;
});
if (updatedPolicies.length === 0) {
return {
success: false,
actionError: {message: "No policies were updated."},
};
}
return {
success: true,
value: updatedPolicies,
actionSuccess: {
message: `Successfully updated ${updatedPolicies.length} alert policy(ies).`,
},
};
} catch (error) {
console.error("Update alert policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to update alert policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const deleteAlertPoliciesAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
alertPolicies: z.array(AlertPolicySchema),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<AlertPolicy[]>> => {
const { databaseId, alertPolicies } = parsedInput;
try {
const notificationChannelIds = alertPolicies.map((alertPolicy) => alertPolicy.notificationChannelId);
const policiesToDelete = await db
.select()
.from(drizzleDb.schemas.alertPolicy)
.where(
and(
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
)
);
if (policiesToDelete.length === 0) {
return {
success: false,
actionError: { message: "No alert policies found to delete." },
};
}
await db
.delete(drizzleDb.schemas.alertPolicy)
.where(
and(
eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId),
inArray(drizzleDb.schemas.alertPolicy.notificationChannelId, notificationChannelIds)
)
);
return {
success: true,
value: policiesToDelete,
actionSuccess: {
message: `Successfully deleted ${policiesToDelete.length} alert policy(ies).`,
},
};
} catch (error) {
console.error("Delete alert policies failed:", error);
return {
success: false,
actionError: {
message: "Failed to delete alert policies.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,26 @@
import {z} from "zod";
export const AlertPolicySchema =
z.object({
notificationChannelId: z.string().min(1, "Please select a notification channel"),
eventKinds: z.enum(['error_backup', 'error_restore', 'success_restore', 'success_backup', 'weekly_report']).array().nonempty(),
}
)
export const AlertPoliciesSchema = z.object({
alertPolicies: z.array(AlertPolicySchema)
});
export type AlertPoliciesType = z.infer<typeof AlertPoliciesSchema>;
export type AlertPolicyType = z.infer<typeof AlertPolicySchema>;
export const EVENT_KIND_OPTIONS = [
{label: "Error Backup", value: "error_backup"},
{label: "Error Restore", value: "error_restore"},
{label: "Success Restore", value: "success_restore"},
{label: "Success Backup", value: "success_backup"},
{label: "Weekly Report", value: "weekly_report"},
];