From ac0ced98421b8047b233d5f703b71a63bd062230 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 11 Dec 2024 10:36:05 -0800 Subject: [PATCH 1/2] init --- .../EnvironmentPolicyDrawer.tsx | 14 ++++++++++- packages/api/src/router/environment-policy.ts | 15 ++++++++--- packages/db/src/schema/environment.ts | 25 ++++++++++++++++--- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx index 86a498536..f2cb9ce93 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx @@ -1,6 +1,5 @@ "use client"; -import type * as SCHEMA from "@ctrlplane/db/schema"; import type React from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { @@ -14,6 +13,7 @@ import { IconTrash, } from "@tabler/icons-react"; +import * as SCHEMA from "@ctrlplane/db/schema"; import { Button } from "@ctrlplane/ui/button"; import { Drawer, DrawerContent, DrawerTitle } from "@ctrlplane/ui/drawer"; import { @@ -22,6 +22,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@ctrlplane/ui/dropdown-menu"; +import { useForm } from "@ctrlplane/ui/form"; import { api } from "~/trpc/react"; import { TabButton } from "../TabButton"; @@ -163,6 +164,17 @@ export const EnvironmentPolicyDrawer: React.FC = () => { ); const deployments = deploymentsQ.data; + const form = useForm({ + schema: SCHEMA.updateEnvironmentPolicy, + defaultValues: { + ...environmentPolicy, + releaseChannels: environmentPolicy?.releaseChannels.map((rc) => ({ + channelId: rc.channelId, + deploymentId: rc.deploymentId, + })), + }, + }); + return ( - ctx.db + .mutation(async ({ ctx, input }) => { + const policy = await ctx.db .update(environmentPolicy) .set(input.data) .where(eq(environmentPolicy.id, input.id)) .returning() - .then(takeFirst), - ), + .then(takeFirst); + + if (input.data.releaseChannels != null) { + const [nulled, set] = _.partition( + input.data.releaseChannels, + (c) => channelId == null, + ); + } + }), updateReleaseChannels: protectedProcedure .input( diff --git a/packages/db/src/schema/environment.ts b/packages/db/src/schema/environment.ts index 194d8534b..0d417edc9 100644 --- a/packages/db/src/schema/environment.ts +++ b/packages/db/src/schema/environment.ts @@ -141,9 +141,28 @@ export const environmentPolicy = pgTable("environment_policy", { export type EnvironmentPolicy = InferSelectModel; -export const createEnvironmentPolicy = createInsertSchema( - environmentPolicy, -).omit({ id: true }); +export const createEnvironmentPolicy = createInsertSchema(environmentPolicy) + .omit({ id: true }) + .extend({ + releaseChannels: z + .array( + z.object({ + channelId: z.string().uuid().nullable(), + deploymentId: z.string().uuid(), + }), + ) + .optional() + .refine((channels) => { + if (channels == null) return true; + const deploymentsWithNonNullChannels = channels.filter( + (c) => c.channelId != null, + ); + const deploymentIds = new Set( + deploymentsWithNonNullChannels.map((c) => c.deploymentId), + ); + return deploymentIds.size === deploymentsWithNonNullChannels.length; + }), + }); export const updateEnvironmentPolicy = createEnvironmentPolicy.partial(); From a75b838c06bf561222bd028928bd2661d24913e4 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 11 Dec 2024 17:25:20 -0800 Subject: [PATCH 2/2] changes --- .../ApprovalAndGovernance.tsx | 234 +++++------- .../DeploymentControl.tsx | 161 ++++---- .../EnvironmentPolicyDrawer.tsx | 198 +++++----- .../environment-policy-drawer/Overview.tsx | 107 ++---- .../PolicyDeleteDialog.tsx | 2 +- .../PolicyFormSchema.ts | 19 + .../ReleaseChannels.tsx | 53 +-- .../ReleaseManagement.tsx | 136 +++---- .../RolloutAndTiming.tsx | 356 +++++++----------- .../useEnvironmentPolicyDrawer.ts | 55 +++ .../environments/EnvFlowBuilder.tsx | 2 +- packages/api/src/router/environment-policy.ts | 130 +++---- packages/db/src/schema/environment.ts | 48 +-- 13 files changed, 667 insertions(+), 834 deletions(-) create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/PolicyFormSchema.ts create mode 100644 apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/useEnvironmentPolicyDrawer.ts diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/ApprovalAndGovernance.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/ApprovalAndGovernance.tsx index 63e1d7649..d22501bbf 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/ApprovalAndGovernance.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/ApprovalAndGovernance.tsx @@ -1,16 +1,11 @@ -import type * as SCHEMA from "@ctrlplane/db/schema"; import React from "react"; -import { z } from "zod"; -import { Button } from "@ctrlplane/ui/button"; import { - Form, FormControl, FormDescription, FormField, FormItem, FormLabel, - useForm, } from "@ctrlplane/ui/form"; import { Input } from "@ctrlplane/ui/input"; import { RadioGroup, RadioGroupItem } from "@ctrlplane/ui/radio-group"; @@ -22,143 +17,112 @@ import { SelectValue, } from "@ctrlplane/ui/select"; -import { api } from "~/trpc/react"; - -const schema = z.object({ - approvalRequirement: z.enum(["automatic", "manual"]), - successType: z.enum(["all", "some", "optional"]), - successMinimum: z.number().min(0, "Must be a positive number"), -}); +import type { PolicyFormSchema } from "./PolicyFormSchema"; export const ApprovalAndGovernance: React.FC<{ - environmentPolicy: SCHEMA.EnvironmentPolicy; -}> = ({ environmentPolicy }) => { - const form = useForm({ schema, defaultValues: { ...environmentPolicy } }); + form: PolicyFormSchema; +}> = ({ form }) => { const { successMinimum } = form.watch(); - const updatePolicy = api.environment.policy.update.useMutation(); - const utils = api.useUtils(); - - const { id, systemId } = environmentPolicy; - const onSubmit = form.handleSubmit((data) => - updatePolicy - .mutateAsync({ id, data }) - .then(() => form.reset(data)) - .then(() => utils.environment.policy.byId.invalidate(id)) - .then(() => utils.environment.policy.bySystemId.invalidate(systemId)), - ); - return ( -
- -
-

Approval & Governance

- - This category defines policies that govern the oversight and - approval process for deployments. These policies ensure that - deployments meet specific criteria or gain necessary approvals - before proceeding, contributing to compliance, quality assurance, - and overall governance of the deployment process. - -
- - ( - -
- Approval gates - - If enabled, a release will require approval from an authorized - user before it can be deployed to any environment with this - policy. - -
- -
- -
-
-
- )} - /> +
+
+

Approval & Governance

+ + This category defines policies that govern the oversight and approval + process for deployments. These policies ensure that deployments meet + specific criteria or gain necessary approvals before proceeding, + contributing to compliance, quality assurance, and overall governance + of the deployment process. + +
- ( - -
- Previous Deploy Status - - Specify a minimum number of resources in dependent - environments to successfully be deployed to before triggering - a release. For example, specifying that all resources in QA - must be deployed to before releasing to PROD. - + ( + +
+ Approval gates + + If enabled, a release will require approval from an authorized + user before it can be deployed to any environment with this + policy. + +
+ +
+
- - - - - - - - All resources in dependent environments must complete - successfully - - - - - - - - A minimum of{" "} - - form.setValue( - "successMinimum", - e.target.valueAsNumber, - ) - } - className="border-b-1 h-6 w-16 text-xs" - /> - resources must be successfully deployed to - - - - - - - - No validation required - - - - -
- )} - /> + + + )} + /> - - - + ( + +
+ Previous Deploy Status + + Specify a minimum number of resources in dependent environments + to successfully be deployed to before triggering a release. For + example, specifying that all resources in QA must be deployed to + before releasing to PROD. + +
+ + + + + + + + All resources in dependent environments must complete + successfully + + + + + + + + A minimum of{" "} + + form.setValue("successMinimum", e.target.valueAsNumber) + } + className="border-b-1 h-6 w-16 text-xs" + /> + resources must be successfully deployed to + + + + + + + + No validation required + + + + +
+ )} + /> +
); }; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/DeploymentControl.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/DeploymentControl.tsx index a6387fa6e..f1d5bea96 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/DeploymentControl.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/DeploymentControl.tsx @@ -1,117 +1,84 @@ -import type * as SCHEMA from "@ctrlplane/db/schema"; import React from "react"; -import { z } from "zod"; -import { Button } from "@ctrlplane/ui/button"; import { - Form, FormControl, FormDescription, FormField, FormItem, FormLabel, - useForm, } from "@ctrlplane/ui/form"; import { Input } from "@ctrlplane/ui/input"; import { RadioGroup, RadioGroupItem } from "@ctrlplane/ui/radio-group"; -import { api } from "~/trpc/react"; - -const schema = z.object({ - concurrencyType: z.enum(["all", "some"]), - concurrencyLimit: z.number().min(1, "Must be a positive number"), -}); +import type { PolicyFormSchema } from "./PolicyFormSchema"; export const DeploymentControl: React.FC<{ - environmentPolicy: SCHEMA.EnvironmentPolicy; -}> = ({ environmentPolicy }) => { - const form = useForm({ schema, defaultValues: environmentPolicy }); - - const updatePolicy = api.environment.policy.update.useMutation(); - const utils = api.useUtils(); - - const { id, systemId } = environmentPolicy; - const onSubmit = form.handleSubmit((data) => { - updatePolicy - .mutateAsync({ id, data }) - .then(() => form.reset(data)) - .then(() => utils.environment.policy.byId.invalidate(id)) - .then(() => utils.environment.policy.bySystemId.invalidate(systemId)); - }); - + form: PolicyFormSchema; +}> = ({ form }) => { const { concurrencyLimit } = form.watch(); return ( -
- -
-

Deployment Control

- - Deployment control policies focus on regulating how deployments are - executed within an environment. These policies manage concurrency, - filtering of releases, and other operational constraints, ensuring - efficient and orderly deployment processes without overwhelming - resources or violating environment-specific rules. - -
- ( - -
-
- Concurrency - - The number of jobs that can run concurrently in an - environment. - -
- - - - - - - - All jobs can run concurrently - - - - - - - - A maximum of - - form.setValue( - "concurrencyLimit", - e.target.valueAsNumber, - ) - } - className="border-b-1 h-6 w-16 text-xs" - /> - jobs can run concurrently - - - - +
+
+

Deployment Control

+ + Deployment control policies focus on regulating how deployments are + executed within an environment. These policies manage concurrency, + filtering of releases, and other operational constraints, ensuring + efficient and orderly deployment processes without overwhelming + resources or violating environment-specific rules. + +
+ ( + +
+
+ Concurrency + + The number of jobs that can run concurrently in an + environment. +
- - )} - /> - - - - + + + + + + + + All jobs can run concurrently + + + + + + + + A maximum of + + form.setValue( + "concurrencyLimit", + e.target.valueAsNumber, + ) + } + className="border-b-1 h-6 w-16 text-xs" + /> + jobs can run concurrently + + + + +
+
+ )} + /> +
); }; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx index f2cb9ce93..9fe711bd1 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/EnvironmentPolicyDrawer.tsx @@ -1,7 +1,7 @@ "use client"; +import type * as SCHEMA from "@ctrlplane/db/schema"; import type React from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { IconCalendar, IconCircuitDiode, @@ -9,11 +9,14 @@ import { IconEye, IconFilter, IconInfoCircle, + IconLoader2, IconRocket, IconTrash, } from "@tabler/icons-react"; +import _ from "lodash"; +import ms from "ms"; +import prettyMilliseconds from "pretty-ms"; -import * as SCHEMA from "@ctrlplane/db/schema"; import { Button } from "@ctrlplane/ui/button"; import { Drawer, DrawerContent, DrawerTitle } from "@ctrlplane/ui/drawer"; import { @@ -22,17 +25,20 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@ctrlplane/ui/dropdown-menu"; -import { useForm } from "@ctrlplane/ui/form"; +import { Form, useForm } from "@ctrlplane/ui/form"; +import type { PolicyFormSchema } from "./PolicyFormSchema"; import { api } from "~/trpc/react"; import { TabButton } from "../TabButton"; import { ApprovalAndGovernance } from "./ApprovalAndGovernance"; import { DeploymentControl } from "./DeploymentControl"; import { Overview } from "./Overview"; import { DeleteEnvironmentPolicyDialog } from "./PolicyDeleteDialog"; +import { policyFormSchema } from "./PolicyFormSchema"; import { ReleaseChannels } from "./ReleaseChannels"; import { ReleaseManagement } from "./ReleaseManagement"; import { RolloutAndTiming } from "./RolloutAndTiming"; +import { useEnvironmentPolicyDrawer } from "./useEnvironmentPolicyDrawer"; export enum EnvironmentPolicyDrawerTab { Overview = "overview", @@ -43,88 +49,98 @@ export enum EnvironmentPolicyDrawerTab { Rollout = "rollout", } -const tabParam = "tab"; -const useEnvironmentPolicyDrawerTab = () => { - const router = useRouter(); - const params = useSearchParams(); - const tab = params.get(tabParam) as EnvironmentPolicyDrawerTab | null; - - const setTab = (tab: EnvironmentPolicyDrawerTab | null) => { - const url = new URL(window.location.href); - if (tab === null) { - url.searchParams.delete(tabParam); - router.replace(`${url.pathname}?${url.searchParams.toString()}`); - return; - } - url.searchParams.set(tabParam, tab); - router.replace(`${url.pathname}?${url.searchParams.toString()}`); +type PolicyConfigProps = { + activeTab: EnvironmentPolicyDrawerTab; + environmentPolicy: SCHEMA.EnvironmentPolicy & { + releaseWindows: SCHEMA.EnvironmentPolicyReleaseWindow[]; + releaseChannels: SCHEMA.ReleaseChannel[]; }; + deployments: Deployment[]; +}; - return { tab, setTab }; +type Deployment = SCHEMA.Deployment & { + releaseChannels: SCHEMA.ReleaseChannel[]; }; -const param = "environment_policy_id"; -export const useEnvironmentPolicyDrawer = () => { - const router = useRouter(); - const params = useSearchParams(); - const environmentPolicyId = params.get(param); - const { tab, setTab } = useEnvironmentPolicyDrawerTab(); +const View: React.FC< + PolicyConfigProps & { + form: PolicyFormSchema; + } +> = (props) => + ({ + [EnvironmentPolicyDrawerTab.Overview]: , + [EnvironmentPolicyDrawerTab.Approval]: , + [EnvironmentPolicyDrawerTab.Concurrency]: , + [EnvironmentPolicyDrawerTab.Management]: , + [EnvironmentPolicyDrawerTab.Rollout]: , + [EnvironmentPolicyDrawerTab.ReleaseChannels]: ( + + ), + })[props.activeTab]; - const setEnvironmentPolicyId = (id: string | null) => { - const url = new URL(window.location.href); - if (id === null) { - url.searchParams.delete(param); - url.searchParams.delete(tabParam); - router.replace(`${url.pathname}?${url.searchParams.toString()}`); - return; - } - url.searchParams.set(param, id); - router.replace(`${url.pathname}?${url.searchParams.toString()}`); - }; +const PolicyConfigForm: React.FC = ({ + activeTab, + environmentPolicy, + deployments, +}) => { + const updateEnvironmentPolicy = api.environment.policy.update.useMutation(); + const utils = api.useUtils(); - const removeEnvironmentPolicyId = () => setEnvironmentPolicyId(null); + const form = useForm({ + schema: policyFormSchema, + defaultValues: { + ...environmentPolicy, + description: environmentPolicy.description ?? "", + rolloutDuration: prettyMilliseconds(environmentPolicy.rolloutDuration), + releaseChannels: _.chain(deployments) + .keyBy((d) => d.id) + .mapValues( + (d) => + environmentPolicy.releaseChannels.find( + (rc) => rc.deploymentId === d.id, + )?.id ?? null, + ) + .value(), + }, + }); - return { - environmentPolicyId, - setEnvironmentPolicyId, - removeEnvironmentPolicyId, - tab, - setTab, - }; -}; + const { id, systemId } = environmentPolicy; + const onSubmit = form.handleSubmit(async (policy) => { + const data = { ...policy, rolloutDuration: ms(policy.rolloutDuration) }; + await updateEnvironmentPolicy + .mutateAsync({ data, id }) + .then(() => form.reset(policy)) + .then(() => utils.environment.policy.byId.invalidate(id)) + .then(() => utils.environment.policy.bySystemId.invalidate(systemId)); + }); -type Deployment = SCHEMA.Deployment & { - releaseChannels: SCHEMA.ReleaseChannel[]; -}; + return ( +
+ + -const View: React.FC<{ - activeTab: EnvironmentPolicyDrawerTab; - environmentPolicy: SCHEMA.EnvironmentPolicy & { - releaseWindows: SCHEMA.EnvironmentPolicyReleaseWindow[]; - releaseChannels: SCHEMA.ReleaseChannel[]; - }; - deployments?: Deployment[]; -}> = ({ activeTab, environmentPolicy, deployments }) => { - return { - [EnvironmentPolicyDrawerTab.Overview]: ( - - ), - [EnvironmentPolicyDrawerTab.Approval]: ( - - ), - [EnvironmentPolicyDrawerTab.Concurrency]: ( - - ), - [EnvironmentPolicyDrawerTab.Management]: ( - - ), - [EnvironmentPolicyDrawerTab.Rollout]: ( - - ), - [EnvironmentPolicyDrawerTab.ReleaseChannels]: deployments != null && ( - - ), - }[activeTab]; +
+ +
+ + + ); }; const PolicyDropdownMenu: React.FC<{ @@ -164,16 +180,7 @@ export const EnvironmentPolicyDrawer: React.FC = () => { ); const deployments = deploymentsQ.data; - const form = useForm({ - schema: SCHEMA.updateEnvironmentPolicy, - defaultValues: { - ...environmentPolicy, - releaseChannels: environmentPolicy?.releaseChannels.map((rc) => ({ - channelId: rc.channelId, - deploymentId: rc.deploymentId, - })), - }, - }); + const loading = environmentPolicyQ.isLoading || deploymentsQ.isLoading; return ( @@ -199,7 +206,7 @@ export const EnvironmentPolicyDrawer: React.FC = () => { )} -
+
{ />
- {environmentPolicy != null && ( -
- + {loading && ( +
+
)} + {!loading && environmentPolicy != null && deployments != null && ( + + )}
diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/Overview.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/Overview.tsx index 32f8a895f..25323bf91 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/Overview.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/environment-policy-drawer/Overview.tsx @@ -1,85 +1,50 @@ -import type * as SCHEMA from "@ctrlplane/db/schema"; import React from "react"; -import { z } from "zod"; -import { Button } from "@ctrlplane/ui/button"; import { - Form, FormControl, FormField, FormItem, FormLabel, FormMessage, - useForm, } from "@ctrlplane/ui/form"; import { Input } from "@ctrlplane/ui/input"; import { Textarea } from "@ctrlplane/ui/textarea"; -import { api } from "~/trpc/react"; - -const schema = z.object({ name: z.string(), description: z.string() }); +import type { PolicyFormSchema } from "./PolicyFormSchema"; export const Overview: React.FC<{ - environmentPolicy: SCHEMA.EnvironmentPolicy; -}> = ({ environmentPolicy }) => { - const form = useForm({ - schema, - defaultValues: { - name: environmentPolicy.name, - description: environmentPolicy.description ?? "", - }, - }); - - const updatePolicy = api.environment.policy.update.useMutation(); - const utils = api.useUtils(); - - const { id, systemId } = environmentPolicy; - const onSubmit = form.handleSubmit((data) => - updatePolicy - .mutateAsync({ id, data }) - .then(() => form.reset(data)) - .then(() => utils.environment.policy.byId.invalidate(id)) - .then(() => utils.environment.policy.bySystemId.invalidate(systemId)), - ); - - return ( -
-
- - ( - - Name - - - - - - )} - /> - ( - - Description - -