Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions apps/event-worker/src/job-dispatch/github.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Job } from "@ctrlplane/db/schema";

import { and, eq, takeFirstOrNull } from "@ctrlplane/db";
import { and, eq, or, takeFirstOrNull } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import {
environment,
githubOrganization,
job,
releaseJobTrigger,
runbook,
runbookJobTrigger,
system,
workspace,
} from "@ctrlplane/db/schema";
Expand Down Expand Up @@ -37,16 +39,21 @@ export const dispatchGithubJob = async (je: Job) => {
.from(githubOrganization)
.innerJoin(workspace, eq(githubOrganization.workspaceId, workspace.id))
.innerJoin(system, eq(system.workspaceId, workspace.id))
.innerJoin(environment, eq(environment.systemId, system.id))
.innerJoin(
.leftJoin(environment, eq(environment.systemId, system.id))
.leftJoin(
releaseJobTrigger,
eq(releaseJobTrigger.environmentId, environment.id),
)
.leftJoin(runbook, eq(runbook.systemId, system.id))
.leftJoin(runbookJobTrigger, eq(runbookJobTrigger.runbookId, runbook.id))
.where(
and(
eq(githubOrganization.installationId, parsed.data.installationId),
eq(githubOrganization.organizationName, parsed.data.owner),
eq(releaseJobTrigger.jobId, je.id),
or(
eq(releaseJobTrigger.jobId, je.id),
eq(runbookJobTrigger.jobId, je.id),
),
),
)
.then(takeFirstOrNull);
Expand Down
24 changes: 6 additions & 18 deletions apps/jobs/src/expired-env-checker/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import _ from "lodash";
import { isPresent } from "ts-is-present";

import { eq, inArray, lte } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import * as SCHEMA from "@ctrlplane/db/schema";
import { logger } from "@ctrlplane/logger";
import { handleTargetsFromEnvironmentToBeDeleted } from "@ctrlplane/job-dispatch";

type QueryRow = {
environment: SCHEMA.Environment;
Expand Down Expand Up @@ -32,22 +31,11 @@ export const run = async () => {
.then(groupByEnvironment);
if (expiredEnvironments.length === 0) return;

const targetPromises = expiredEnvironments
.filter((env) => isPresent(env.targetFilter))
.map(async (env) => {
const targets = await db
.select()
.from(SCHEMA.target)
.where(SCHEMA.targetMatchesMetadata(db, env.targetFilter));

return { environmentId: env.id, targets };
});
const associatedTargets = await Promise.all(targetPromises);

for (const { environmentId, targets } of associatedTargets)
logger.info(
`[${targets.length}] targets are associated with expired environment [${environmentId}]`,
);
await Promise.all(
expiredEnvironments.map((env) =>
handleTargetsFromEnvironmentToBeDeleted(db, env),
),
);
Comment on lines +34 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling and logging for reliability.

The concurrent processing of environments is good for performance, but there are some reliability concerns:

  1. Missing error handling around Promise.all
  2. No logging of success/failure
  3. Potential race condition between target handling and environment deletion

Consider applying these improvements:

-  await Promise.all(
-    expiredEnvironments.map((env) =>
-      handleTargetsFromEnvironmentToBeDeleted(db, env),
-    ),
-  );
+  try {
+    console.log(`Processing ${expiredEnvironments.length} expired environments`);
+    await Promise.all(
+      expiredEnvironments.map(async (env) => {
+        try {
+          await handleTargetsFromEnvironmentToBeDeleted(db, env);
+          console.log(`Successfully processed environment ${env.id}`);
+        } catch (error) {
+          console.error(`Failed to process environment ${env.id}:`, error);
+          throw error; // Re-throw to fail the entire batch
+        }
+      }),
+    );
+  } catch (error) {
+    console.error('Failed to process expired environments:', error);
+    throw error; // Prevent environment deletion on failure
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await Promise.all(
expiredEnvironments.map((env) =>
handleTargetsFromEnvironmentToBeDeleted(db, env),
),
);
try {
console.log(`Processing ${expiredEnvironments.length} expired environments`);
await Promise.all(
expiredEnvironments.map(async (env) => {
try {
await handleTargetsFromEnvironmentToBeDeleted(db, env);
console.log(`Successfully processed environment ${env.id}`);
} catch (error) {
console.error(`Failed to process environment ${env.id}:`, error);
throw error; // Re-throw to fail the entire batch
}
}),
);
} catch (error) {
console.error('Failed to process expired environments:', error);
throw error; // Prevent environment deletion on failure
}

Comment on lines +34 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

🛠️ Refactor suggestion

The shell script results show that Promise.allSettled is consistently used across the codebase for handling concurrent operations, particularly in similar scenarios involving multiple asynchronous tasks. The implementation patterns found in other files (like kubernetes-job-agent/src/index.ts and apps/event-worker/src/target-scan/gke.ts) demonstrate robust error handling with detailed logging.

Consider using Promise.allSettled with proper error handling, following established patterns

-  await Promise.all(
-    expiredEnvironments.map((env) =>
-      handleTargetsFromEnvironmentToBeDeleted(db, env),
-    ),
-  );
+  const results = await Promise.allSettled(
+    expiredEnvironments.map((env) =>
+      handleTargetsFromEnvironmentToBeDeleted(db, env)
+        .catch(error => {
+          console.error(`Failed to process environment ${env.id}:`, error);
+          return { environmentId: env.id, error };
+        })
+    ),
+  );
+
+  const failures = results.filter(
+    (result): result is PromiseRejectedResult => result.status === 'rejected'
+  ).length;
+
+  if (failures > 0) {
+    console.error(`Failed to process ${failures} out of ${expiredEnvironments.length} environments`);
+  }
🔗 Analysis chain

Consider adding error handling for concurrent operations.

While using Promise.all for concurrent processing is efficient, it has some potential issues:

  1. It will fail fast if any single environment processing fails
  2. Multiple concurrent database operations might impact system performance

Consider this more robust implementation:

-  await Promise.all(
-    expiredEnvironments.map((env) =>
-      handleTargetsFromEnvironmentToBeDeleted(db, env),
-    ),
-  );
+  const results = await Promise.allSettled(
+    expiredEnvironments.map((env) =>
+      handleTargetsFromEnvironmentToBeDeleted(db, env)
+        .catch(error => {
+          console.error(`Failed to handle targets for environment ${env.id}:`, error);
+          return { environmentId: env.id, error };
+        })
+    ),
+  );
+  
+  const failures = results.filter(
+    (result): result is PromiseRejectedResult => result.status === 'rejected'
+  );
+  
+  if (failures.length > 0) {
+    console.error(`Failed to process ${failures.length} environments`);
+  }

Let's verify the potential impact of concurrent operations:

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check for other uses of Promise.all with database operations
rg -l "Promise\.all.*db\." --type ts

# Look for any existing error handling patterns in similar operations
ast-grep --pattern 'Promise.allSettled($$$)'

Length of output: 6178


const envIds = expiredEnvironments.map((env) => env.id);
await db
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,21 @@ export const DeploymentNavBar: React.FC<DeploymentNavBarProps> = ({
const releasesUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}/releases`;
const variablesUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}/variables`;
const releaseChannelsUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}/release-channels`;
const lifecycleHooksUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}/lifecycle-hooks`;
const overviewUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}`;

const isReleasesActive = pathname.includes("/releases");
const isVariablesActive = pathname.includes("/variables");
const isJobsActive = pathname.includes("/jobs");
const isReleaseChannelsActive = pathname.includes("/release-channels");
const isReleasesActive = pathname.endsWith("/releases");
const isVariablesActive = pathname.endsWith("/variables");
const isJobsActive = pathname.endsWith("/jobs");
const isReleaseChannelsActive = pathname.endsWith("/release-channels");
const isLifecycleHooksActive = pathname.endsWith("/lifecycle-hooks");

const isSettingsActive =
!isReleasesActive &&
!isVariablesActive &&
!isJobsActive &&
!isReleaseChannelsActive;
!isReleaseChannelsActive &&
!isLifecycleHooksActive;

return (
<div className="flex items-center justify-between border-b p-2">
Expand Down Expand Up @@ -116,6 +120,16 @@ export const DeploymentNavBar: React.FC<DeploymentNavBarProps> = ({
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href={lifecycleHooksUrl} legacyBehavior passHref>
<NavigationMenuLink
className="group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors hover:bg-accent/50 hover:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
active={isLifecycleHooksActive}
>
Lifecycle Hooks
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href={overviewUrl} legacyBehavior passHref>
<NavigationMenuLink
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import React from "react";
import { usePathname } from "next/navigation";
import { IconLoader2 } from "@tabler/icons-react";

import { Button } from "@ctrlplane/ui/button";

import { CreateReleaseDialog } from "~/app/[workspaceSlug]/_components/CreateRelease";
import { api } from "~/trpc/react";
import { CreateLifecycleHookDialog } from "./lifecycle-hooks/CreateLifecycleHookDialog";
import { CreateReleaseChannelDialog } from "./release-channels/CreateReleaseChannelDialog";
import { CreateVariableDialog } from "./releases/CreateVariableDialog";

Expand All @@ -14,8 +17,11 @@ export const NavigationMenuAction: React.FC<{
systemId: string;
}> = ({ deploymentId, systemId }) => {
const pathname = usePathname();
const isVariablesActive = pathname.includes("variables");
const isReleaseChannelsActive = pathname.includes("release-channels");
const isVariablesActive = pathname.endsWith("variables");
const isReleaseChannelsActive = pathname.endsWith("release-channels");
const isLifecycleHooksActive = pathname.endsWith("lifecycle-hooks");

const runbooksQ = api.runbook.bySystemId.useQuery(systemId);

return (
<div>
Expand All @@ -35,13 +41,34 @@ export const NavigationMenuAction: React.FC<{
</CreateReleaseChannelDialog>
)}

{!isVariablesActive && !isReleaseChannelsActive && (
<CreateReleaseDialog deploymentId={deploymentId} systemId={systemId}>
<Button size="sm" variant="secondary">
New Release
{isLifecycleHooksActive && (
<CreateLifecycleHookDialog
deploymentId={deploymentId}
runbooks={runbooksQ.data ?? []}
>
<Button
size="sm"
variant="secondary"
disabled={runbooksQ.isLoading}
className="w-36"
>
{runbooksQ.isLoading && (
<IconLoader2 className="h-3 w-3 animate-spin" />
)}
{!runbooksQ.isLoading && "New Lifecycle Hook"}
</Button>
</CreateReleaseDialog>
</CreateLifecycleHookDialog>
)}

{!isVariablesActive &&
!isReleaseChannelsActive &&
!isLifecycleHooksActive && (
<CreateReleaseDialog deploymentId={deploymentId} systemId={systemId}>
<Button size="sm" variant="secondary">
New Release
</Button>
</CreateReleaseDialog>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import type * as SCHEMA from "@ctrlplane/db/schema";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { z } from "zod";

import { Button } from "@ctrlplane/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ctrlplane/ui/dialog";
import {
Form,
FormField,
FormItem,
FormLabel,
useForm,
} from "@ctrlplane/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ctrlplane/ui/select";

import { api } from "~/trpc/react";

type CreateLifecycleHookDialogProps = {
deploymentId: string;
runbooks: SCHEMA.Runbook[];
children: React.ReactNode;
};

const schema = z.object({ runbookId: z.string() });

export const CreateLifecycleHookDialog: React.FC<
CreateLifecycleHookDialogProps
> = ({ deploymentId, runbooks, children }) => {
const [open, setOpen] = useState(false);
const createLifecycleHook = api.deployment.lifecycleHook.create.useMutation();
const router = useRouter();
const form = useForm({ schema });
const onSubmit = form.handleSubmit((data) =>
createLifecycleHook
.mutateAsync({ deploymentId, ...data })
.then(() => form.reset())
.then(() => router.refresh())
.then(() => setOpen(false)),
);
Comment on lines +49 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling and loading state for form submission.

The form submission lacks proper error handling and user feedback:

  1. No error handling for failed mutations
  2. No loading state during submission
  3. No user notification on success/failure
 const onSubmit = form.handleSubmit((data) =>
   createLifecycleHook
     .mutateAsync({ deploymentId, ...data })
     .then(() => form.reset())
     .then(() => router.refresh())
-    .then(() => setOpen(false)),
+    .then(() => {
+      setOpen(false);
+      // Add success toast notification
+    })
+    .catch((error) => {
+      // Add error toast notification
+      console.error('Failed to create lifecycle hook:', error);
+    }),
 );

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +49 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling and loading state management.

The form submission lacks error handling and loading state management. This could lead to a poor user experience if the API call fails.

Consider implementing:

  1. Error handling in the promise chain
  2. Loading state during submission
  3. User feedback for success/failure
+  const [isSubmitting, setIsSubmitting] = useState(false);
   const onSubmit = form.handleSubmit((data) => {
+    setIsSubmitting(true);
     createLifecycleHook
       .mutateAsync({ deploymentId, ...data })
       .then(() => form.reset())
       .then(() => router.refresh())
-      .then(() => setOpen(false)),
+      .then(() => setOpen(false))
+      .catch((error) => {
+        console.error('Failed to create lifecycle hook:', error);
+        // Show error to user using your preferred toast/alert component
+      })
+      .finally(() => setIsSubmitting(false));
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onSubmit = form.handleSubmit((data) =>
createLifecycleHook
.mutateAsync({ deploymentId, ...data })
.then(() => form.reset())
.then(() => router.refresh())
.then(() => setOpen(false)),
);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = form.handleSubmit((data) => {
setIsSubmitting(true);
createLifecycleHook
.mutateAsync({ deploymentId, ...data })
.then(() => form.reset())
.then(() => router.refresh())
.then(() => setOpen(false))
.catch((error) => {
console.error('Failed to create lifecycle hook:', error);
// Show error to user using your preferred toast/alert component
})
.finally(() => setIsSubmitting(false));
});


return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Lifecycle Hook</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="runbookId"
render={({ field: { value, onChange } }) => (
<FormItem>
<FormLabel>Runbook</FormLabel>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a runbook" />
</SelectTrigger>
<SelectContent>
{runbooks.map((runbook) => (
<SelectItem key={runbook.id} value={runbook.id}>
{runbook.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
Comment on lines +65 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Display form validation state to users.

The form doesn't show validation errors to users, which could lead to confusion about why the form submission isn't working.

Add error messages to the form:

 <FormItem>
   <FormLabel>Runbook</FormLabel>
   <Select value={value} onValueChange={onChange}>
     <SelectTrigger>
       <SelectValue placeholder="Select a runbook" />
     </SelectTrigger>
     <SelectContent>
       {runbooks.map((runbook) => (
         <SelectItem key={runbook.id} value={runbook.id}>
           {runbook.name}
         </SelectItem>
       ))}
     </SelectContent>
   </Select>
+  {form.formState.errors.runbookId && (
+    <div className="text-sm text-red-500">
+      {form.formState.errors.runbookId.message}
+    </div>
+  )}
 </FormItem>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="runbookId"
render={({ field: { value, onChange } }) => (
<FormItem>
<FormLabel>Runbook</FormLabel>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a runbook" />
</SelectTrigger>
<SelectContent>
{runbooks.map((runbook) => (
<SelectItem key={runbook.id} value={runbook.id}>
{runbook.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="runbookId"
render={({ field: { value, onChange } }) => (
<FormItem>
<FormLabel>Runbook</FormLabel>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a runbook" />
</SelectTrigger>
<SelectContent>
{runbooks.map((runbook) => (
<SelectItem key={runbook.id} value={runbook.id}>
{runbook.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.runbookId && (
<div className="text-sm text-red-500">
{form.formState.errors.runbookId.message}
</div>
)}
</FormItem>
)}
/>

<DialogFooter>
<Button type="submit" disabled={createLifecycleHook.isPending}>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type * as SCHEMA from "@ctrlplane/db/schema";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { IconTrash } from "@tabler/icons-react";

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@ctrlplane/ui/alert-dialog";
import { buttonVariants } from "@ctrlplane/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ctrlplane/ui/dropdown-menu";

import { api } from "~/trpc/react";

type DeleteLifecycleHookDialogProps = {
lifecycleHookId: string;
onClose: () => void;
children: React.ReactNode;
};

const DeleteLifecycleHookDialog: React.FC<DeleteLifecycleHookDialogProps> = ({
lifecycleHookId,
onClose,
children,
}) => {
const [open, setOpen] = useState(false);
const router = useRouter();
const deleteLifecycleHook = api.deployment.lifecycleHook.delete.useMutation();

const onDelete = async () =>
deleteLifecycleHook
.mutateAsync(lifecycleHookId)
.then(() => router.refresh())
.then(() => setOpen(false));
Comment on lines +42 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling and improve promise chain.

The deletion logic has the following issues:

  1. Missing error handling for the mutation
  2. Inconsistent behavior: onClose is not called after successful deletion
  3. Promise chain can be simplified

Consider this improvement:

-  const onDelete = async () =>
-    deleteLifecycleHook
-      .mutateAsync(lifecycleHookId)
-      .then(() => router.refresh())
-      .then(() => setOpen(false));
+  const onDelete = async () => {
+    try {
+      await deleteLifecycleHook.mutateAsync(lifecycleHookId);
+      router.refresh();
+      setOpen(false);
+      onClose();
+    } catch (error) {
+      console.error('Failed to delete lifecycle hook:', error);
+      // Consider adding a toast notification here
+    }
+  };

Committable suggestion skipped: line range outside the PR's diff.


return (
<AlertDialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) onClose();
}}
>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Lifecycle Hook?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<div className="flex-grow" />
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
disabled={deleteLifecycleHook.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

type LifecycleHookActionsDropdownProps = {
lifecycleHook: SCHEMA.DeploymentLifecycleHook;
children: React.ReactNode;
};

export const LifecycleHookActionsDropdown: React.FC<
LifecycleHookActionsDropdownProps
> = ({ lifecycleHook, children }) => {
const [open, setOpen] = useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent>
<DeleteLifecycleHookDialog
lifecycleHookId={lifecycleHook.id}
onClose={() => setOpen(false)}
>
<DropdownMenuItem
className="flex items-center gap-2"
onSelect={(e) => e.preventDefault()}
>
Delete
<IconTrash size="icon" className="h-4 w-4 text-destructive" />
</DropdownMenuItem>
</DeleteLifecycleHookDialog>
</DropdownMenuContent>
</DropdownMenu>
);
};
Loading
Loading