diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx index 62cd029f2..d8b5664b0 100644 --- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx @@ -54,16 +54,19 @@ export const DeploymentNavBar: React.FC = ({ const variablesUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}/variables`; const releaseChannelsUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}/release-channels`; const overviewUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}`; + const hooksUrl = `/${workspaceSlug}/systems/${systemSlug}/deployments/${deploymentSlug}/hooks`; const isReleasesActive = pathname.includes("/releases"); const isVariablesActive = pathname.includes("/variables"); const isJobsActive = pathname.includes("/jobs"); const isReleaseChannelsActive = pathname.includes("/release-channels"); + const isHooksActive = pathname.includes("/hooks"); const isSettingsActive = !isReleasesActive && !isVariablesActive && !isJobsActive && - !isReleaseChannelsActive; + !isReleaseChannelsActive && + !isHooksActive; return (
@@ -116,6 +119,16 @@ export const DeploymentNavBar: React.FC = ({ + + + + Hooks + + + {isVariablesActive && ( @@ -43,7 +48,15 @@ export const NavigationMenuAction: React.FC<{ )} - {!isVariablesActive && !isReleaseChannelsActive && ( + {isHooksActive && ( + + + + )} + + {!isVariablesActive && !isReleaseChannelsActive && !isHooksActive && ( + + + + + + {hookActionsList.map((action) => ( + { + onChange(action); + setActionsOpen(false); + }} + > + {action} + + ))} + + + + + + + )} + /> + +
+ +
+ {fields.map((field, index) => ( + { + const runbook = runbooks.find( + (r) => r.id === field.value, + ); + return ( + + {runbook?.name ?? ""} + remove(index)} + /> + + ); + }} + /> + ))} +
+
+ + + + + + + + + + {unselectedRunbooks.map((runbook) => ( + { + append({ id: runbook.id }); + setRunbooksOpen(false); + }} + > + {runbook.name} + + ))} + + + + + + + + + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/DeleteHookDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/DeleteHookDialog.tsx new file mode 100644 index 000000000..09d75f5b4 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/DeleteHookDialog.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@ctrlplane/ui/alert-dialog"; +import { buttonVariants } from "@ctrlplane/ui/button"; + +import { api } from "~/trpc/react"; + +type DeleteHookDialogProps = { + hookId: string; + onClose: () => void; + children: React.ReactNode; +}; + +export const DeleteHookDialog: React.FC = ({ + hookId, + onClose, + children, +}) => { + const [open, setOpen] = useState(false); + const deleteHook = api.deployment.hook.delete.useMutation(); + const utils = api.useUtils(); + const router = useRouter(); + + const onDelete = () => + deleteHook + .mutateAsync(hookId) + .then(() => utils.deployment.hook.list.invalidate(hookId)) + .then(() => router.refresh()) + .then(() => setOpen(false)) + .then(() => onClose()); + + return ( + { + setOpen(o); + if (!o) onClose(); + }} + > + {children} + + + + Are you sure you want to delete this hook? + + + This action cannot be undone. + + + + Cancel + + Delete + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/EditHookDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/EditHookDialog.tsx new file mode 100644 index 000000000..9c25d809c --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/EditHookDialog.tsx @@ -0,0 +1,237 @@ +"use client"; + +import type { RouterOutputs } from "@ctrlplane/api"; +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { IconPlus, IconSelector, IconX } from "@tabler/icons-react"; +import { z } from "zod"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Button } from "@ctrlplane/ui/button"; +import { + Command, + CommandInput, + CommandItem, + CommandList, +} from "@ctrlplane/ui/command"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useFieldArray, + useForm, +} from "@ctrlplane/ui/form"; +import { Input } from "@ctrlplane/ui/input"; +import { Label } from "@ctrlplane/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover"; +import { hookActions, hookActionsList } from "@ctrlplane/validators/events"; + +import { api } from "~/trpc/react"; + +type Hook = RouterOutputs["deployment"]["hook"]["list"][number]; +type EditHookDialogProps = { + hook: Hook; + runbooks: SCHEMA.Runbook[]; + children: React.ReactNode; + onClose: () => void; +}; + +const schema = z.object({ + name: z.string().min(1), + action: hookActions, + runbookIds: z.array(z.object({ id: z.string().uuid() })), +}); + +export const EditHookDialog: React.FC = ({ + hook, + runbooks, + children, + onClose, +}) => { + const [open, setOpen] = useState(false); + const [actionsOpen, setActionsOpen] = useState(false); + const [runbooksOpen, setRunbooksOpen] = useState(false); + const updateHook = api.deployment.hook.update.useMutation(); + const utils = api.useUtils(); + const router = useRouter(); + + const defaultValues = { + ...hook, + runbookIds: hook.runhooks.map((rh) => ({ id: rh.runbookId })), + }; + const form = useForm({ schema, defaultValues }); + const onSubmit = form.handleSubmit((data) => + updateHook + .mutateAsync({ + id: hook.id, + data: { ...data, runbookIds: data.runbookIds.map((r) => r.id) }, + }) + .then(() => utils.deployment.hook.list.invalidate(hook.scopeId)) + .then(() => router.refresh()) + .then(() => setOpen(false)) + .then(() => onClose()), + ); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "runbookIds", + }); + + const selectedRunbookIds = form.watch("runbookIds").map((r) => r.id); + + const unselectedRunbooks = runbooks.filter( + (r) => !selectedRunbookIds.includes(r.id), + ); + + return ( + { + setOpen(o); + if (!o) onClose(); + }} + > + {children} + + Edit Hook +
+ + ( + + Name + + + + + + )} + /> + + ( + + Action + + + + + + + + + + {hookActionsList.map((action) => ( + { + onChange(action); + setActionsOpen(false); + }} + > + {action} + + ))} + + + + + + + )} + /> + +
+ +
+ {fields.map((field, index) => ( + { + const runbook = runbooks.find( + (r) => r.id === field.value, + ); + return ( + + {runbook?.name ?? ""} + remove(index)} + /> + + ); + }} + /> + ))} +
+
+ + + + + + + + + + {unselectedRunbooks.map((runbook) => ( + { + append({ id: runbook.id }); + setRunbooksOpen(false); + }} + > + {runbook.name} + + ))} + + + + + + + + + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/HookActionsDropdown.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/HookActionsDropdown.tsx new file mode 100644 index 000000000..9547528c8 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/HookActionsDropdown.tsx @@ -0,0 +1,61 @@ +"use client"; + +import type { RouterOutputs } from "@ctrlplane/api"; +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React, { useState } from "react"; +import { IconEdit, IconTrash } from "@tabler/icons-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ctrlplane/ui/dropdown-menu"; + +import { DeleteHookDialog } from "./DeleteHookDialog"; +import { EditHookDialog } from "./EditHookDialog"; + +type Hook = RouterOutputs["deployment"]["hook"]["list"][number]; +type HookActionsDropdownProps = { + hook: Hook; + runbooks: SCHEMA.Runbook[]; + children: React.ReactNode; +}; + +export const HookActionsDropdown: React.FC = ({ + hook, + runbooks, + children, +}) => { + const [open, setOpen] = useState(false); + + return ( + + {children} + + setOpen(false)} + > + e.preventDefault()} + className="flex cursor-pointer items-center gap-2" + > + + Edit + + + setOpen(false)}> + e.preventDefault()} + className="flex cursor-pointer items-center gap-2" + > + + Delete + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/HooksTable.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/HooksTable.tsx new file mode 100644 index 000000000..4a15f0185 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/HooksTable.tsx @@ -0,0 +1,68 @@ +import type { RouterOutputs } from "@ctrlplane/api"; +import type * as SCHEMA from "@ctrlplane/db/schema"; +import { IconDots } from "@tabler/icons-react"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Button } from "@ctrlplane/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; + +import { HookActionsDropdown } from "./HookActionsDropdown"; + +type Hook = RouterOutputs["deployment"]["hook"]["list"][number]; + +type HooksTableProps = { + hooks: Hook[]; + runbooks: SCHEMA.Runbook[]; +}; + +export const HooksTable: React.FC = ({ hooks, runbooks }) => ( + + + + Name + Event + Runbooks + + + + + {hooks.map((hook) => ( + + {hook.name} + + + {hook.action} + + + +
+ {hook.runhooks.map((rh) => ( + + {rh.runbook.name} + + ))} +
+
+ + + + + +
+ ))} +
+
+); diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/page.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/page.tsx new file mode 100644 index 000000000..b17cf36d8 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/hooks/page.tsx @@ -0,0 +1,17 @@ +import { notFound } from "next/navigation"; + +import { api } from "~/trpc/server"; +import { HooksTable } from "./HooksTable"; + +export default async function HooksPage({ + params, +}: { + params: { workspaceSlug: string; systemSlug: string; deploymentSlug: string }; +}) { + const deployment = await api.deployment.bySlug(params); + if (!deployment) notFound(); + + const hooks = await api.deployment.hook.list(deployment.id); + const runbooks = await api.runbook.bySystemId(deployment.systemId); + return ; +} diff --git a/packages/api/src/router/deployment.ts b/packages/api/src/router/deployment.ts index d0b495ed2..ae26af568 100644 --- a/packages/api/src/router/deployment.ts +++ b/packages/api/src/router/deployment.ts @@ -15,9 +15,11 @@ import { } from "@ctrlplane/db"; import { createDeployment, + createHook, createReleaseChannel, deployment, environment, + hook, job, jobAgent, release, @@ -26,8 +28,10 @@ import { releaseMatchesCondition, resource, resourceMatchesMetadata, + runhook, system, updateDeployment, + updateHook, updateReleaseChannel, workspace, } from "@ctrlplane/db/schema"; @@ -38,25 +42,6 @@ import { JobStatus } from "@ctrlplane/validators/jobs"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { deploymentVariableRouter } from "./deployment-variable"; -const latestActiveReleaseSubQuery = (db: Tx) => - db - .select({ - id: release.id, - deploymentId: release.deploymentId, - version: release.version, - createdAt: release.createdAt, - name: release.name, - config: release.config, - environmentId: releaseJobTrigger.environmentId, - - rank: sql`ROW_NUMBER() OVER (PARTITION BY ${release.deploymentId}, ${releaseJobTrigger.environmentId} ORDER BY ${release.createdAt} DESC)`.as( - "rank", - ), - }) - .from(release) - .innerJoin(releaseJobTrigger, eq(releaseJobTrigger.releaseId, release.id)) - .as("active_releases"); - const releaseChannelRouter = createTRPCRouter({ create: protectedProcedure .input(createReleaseChannel) @@ -178,9 +163,157 @@ const releaseChannelRouter = createTRPCRouter({ }), }); +const hookRouter = createTRPCRouter({ + list: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser.perform(Permission.HookList).on({ + type: "deployment", + id: input, + }), + }) + .query(({ ctx, input }) => + ctx.db.query.hook.findMany({ + where: and(eq(hook.scopeId, input), eq(hook.scopeType, "deployment")), + with: { runhooks: { with: { runbook: true } } }, + }), + ), + + byId: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: async ({ canUser, ctx, input }) => { + const h = await ctx.db + .select() + .from(hook) + .where(eq(hook.id, input)) + .then(takeFirstOrNull); + if (h == null) return false; + if (h.scopeType !== "deployment") return false; + return canUser.perform(Permission.HookGet).on({ + type: "deployment", + id: h.scopeId, + }); + }, + }) + .query(({ ctx, input }) => + ctx.db.query.hook.findFirst({ where: eq(hook.id, input) }), + ), + + create: protectedProcedure + .input(createHook) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.HookCreate) + .on({ type: "deployment", id: input.scopeId }), + }) + .mutation(async ({ ctx, input }) => + ctx.db.transaction(async (tx) => { + const h = await tx + .insert(hook) + .values(input) + .returning() + .then(takeFirst); + if (input.runbookIds.length === 0) return h; + const rhInserts = input.runbookIds.map((id) => ({ + hookId: h.id, + runbookId: id, + })); + const runhooks = await tx + .insert(runhook) + .values(rhInserts) + .returning() + .then((r) => r.map((rh) => rh.runbookId)); + return { ...h, runhooks }; + }), + ), + + update: protectedProcedure + .input(z.object({ id: z.string().uuid(), data: updateHook })) + .meta({ + authorizationCheck: async ({ canUser, ctx, input }) => { + const h = await ctx.db + .select() + .from(hook) + .where(eq(hook.id, input.id)) + .then(takeFirstOrNull); + if (h == null) return false; + if (h.scopeType !== "deployment") return false; + return canUser.perform(Permission.HookUpdate).on({ + type: "deployment", + id: h.scopeId, + }); + }, + }) + .mutation(({ ctx, input }) => + ctx.db.transaction(async (tx) => { + const h = await tx + .update(hook) + .set(input.data) + .where(eq(hook.id, input.id)) + .returning() + .then(takeFirst); + + if (input.data.runbookIds == null) return h; + + await tx.delete(runhook).where(eq(runhook.hookId, input.id)); + if (input.data.runbookIds.length === 0) return h; + const rhInserts = input.data.runbookIds.map((id) => ({ + hookId: h.id, + runbookId: id, + })); + const runhooks = await tx.insert(runhook).values(rhInserts).returning(); + return { ...h, runhooks }; + }), + ), + + delete: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: async ({ canUser, ctx, input }) => { + const h = await ctx.db + .select() + .from(hook) + .where(eq(hook.id, input)) + .then(takeFirstOrNull); + if (h == null) return false; + if (h.scopeType !== "deployment") return false; + return canUser.perform(Permission.HookDelete).on({ + type: "deployment", + id: h.scopeId, + }); + }, + }) + .mutation(({ ctx, input }) => + ctx.db.delete(hook).where(eq(hook.id, input)), + ), +}); + +const latestActiveReleaseSubQuery = (db: Tx) => + db + .select({ + id: release.id, + deploymentId: release.deploymentId, + version: release.version, + createdAt: release.createdAt, + name: release.name, + config: release.config, + environmentId: releaseJobTrigger.environmentId, + + rank: sql`ROW_NUMBER() OVER (PARTITION BY ${release.deploymentId}, ${releaseJobTrigger.environmentId} ORDER BY ${release.createdAt} DESC)`.as( + "rank", + ), + }) + .from(release) + .innerJoin(releaseJobTrigger, eq(releaseJobTrigger.releaseId, release.id)) + .as("active_releases"); + export const deploymentRouter = createTRPCRouter({ variable: deploymentVariableRouter, releaseChannel: releaseChannelRouter, + hook: hookRouter, distributionById: protectedProcedure .meta({ authorizationCheck: ({ canUser, input }) => diff --git a/packages/api/src/router/runbook.ts b/packages/api/src/router/runbook.ts index 68038e30c..cb6875134 100644 --- a/packages/api/src/router/runbook.ts +++ b/packages/api/src/router/runbook.ts @@ -36,7 +36,7 @@ export const runbookRouter = createTRPCRouter({ .on({ type: "system", id: input }), }) .input(z.string().uuid()) - .mutation(({ ctx, input }) => + .query(({ ctx, input }) => ctx.db .select() .from(SCHEMA.runbook) diff --git a/packages/db/src/schema/event.ts b/packages/db/src/schema/event.ts index 0747481f5..2425a6f31 100644 --- a/packages/db/src/schema/event.ts +++ b/packages/db/src/schema/event.ts @@ -1,4 +1,8 @@ +import type { InferSelectModel } from "drizzle-orm"; +import { relations } from "drizzle-orm"; import { jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod"; import { runbook } from "./runbook.js"; @@ -19,6 +23,16 @@ export const hook = pgTable("hook", { scopeId: uuid("scope_id").notNull(), }); +export const createHook = createInsertSchema(hook) + .omit({ id: true }) + .extend({ runbookIds: z.array(z.string().uuid()) }); + +export const updateHook = createHook.partial(); +export type Hook = InferSelectModel; +export const hookRelations = relations(hook, ({ many }) => ({ + runhooks: many(runhook), +})); + export const runhook = pgTable("runhook", { id: uuid("id").primaryKey().defaultRandom(), hookId: uuid("hook_id") @@ -28,3 +42,11 @@ export const runhook = pgTable("runhook", { .notNull() .references(() => runbook.id, { onDelete: "cascade" }), }); + +export const runhookRelations = relations(runhook, ({ one }) => ({ + hook: one(hook, { fields: [runhook.hookId], references: [hook.id] }), + runbook: one(runbook, { + fields: [runhook.runbookId], + references: [runbook.id], + }), +})); diff --git a/packages/db/src/schema/runbook.ts b/packages/db/src/schema/runbook.ts index a5fc2ba06..b06f2579d 100644 --- a/packages/db/src/schema/runbook.ts +++ b/packages/db/src/schema/runbook.ts @@ -1,8 +1,10 @@ import type { InferSelectModel } from "drizzle-orm"; +import { relations } from "drizzle-orm"; import { jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { runhook } from "./event.js"; import { jobAgent } from "./job-agent.js"; import { job } from "./job.js"; import { system } from "./system.js"; @@ -32,6 +34,10 @@ export const createRunbook = runbookInsert; export const updateRunbook = runbookInsert.partial(); export type Runbook = InferSelectModel; +export const runbookRelations = relations(runbook, ({ many }) => ({ + runhooks: many(runhook), +})); + export const runbookJobTrigger = pgTable( "runbook_job_trigger", { diff --git a/packages/validators/src/auth/index.ts b/packages/validators/src/auth/index.ts index 0288c7171..e935dc00f 100644 --- a/packages/validators/src/auth/index.ts +++ b/packages/validators/src/auth/index.ts @@ -92,6 +92,12 @@ export enum Permission { RunbookGet = "runbook.get", RunbookList = "runbook.list", RunbookUpdate = "runbook.update", + + HookCreate = "hook.create", + HookGet = "hook.get", + HookList = "hook.list", + HookUpdate = "hook.update", + HookDelete = "hook.delete", } export const permission = z.nativeEnum(Permission); diff --git a/packages/validators/src/events/hooks/index.ts b/packages/validators/src/events/hooks/index.ts index dafb77c23..0b815b07d 100644 --- a/packages/validators/src/events/hooks/index.ts +++ b/packages/validators/src/events/hooks/index.ts @@ -13,3 +13,9 @@ export const isTargetRemoved = (event: HookEvent): event is TargetRemoved => event.action === "deployment.target.removed"; export const isTargetDeleted = (event: HookEvent): event is TargetDeleted => event.action === "deployment.target.deleted"; + +// action +export const hookActionsList = hookEvent.options.map( + (schema) => schema.shape.action.value, +); +export const hookActions = z.enum(hookActionsList as [string, ...string[]]);