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 86a498536..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 @@ -2,7 +2,6 @@ import type * as SCHEMA from "@ctrlplane/db/schema"; import type React from "react"; -import { useRouter, useSearchParams } from "next/navigation"; import { IconCalendar, IconCircuitDiode, @@ -10,9 +9,13 @@ import { IconEye, IconFilter, IconInfoCircle, + IconLoader2, IconRocket, IconTrash, } from "@tabler/icons-react"; +import _ from "lodash"; +import ms from "ms"; +import prettyMilliseconds from "pretty-ms"; import { Button } from "@ctrlplane/ui/button"; import { Drawer, DrawerContent, DrawerTitle } from "@ctrlplane/ui/drawer"; @@ -22,16 +25,20 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@ctrlplane/ui/dropdown-menu"; +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", @@ -42,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()}`); - }; - - return { tab, setTab }; -}; - -const param = "environment_policy_id"; -export const useEnvironmentPolicyDrawer = () => { - const router = useRouter(); - const params = useSearchParams(); - const environmentPolicyId = params.get(param); - const { tab, setTab } = useEnvironmentPolicyDrawerTab(); - - 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 removeEnvironmentPolicyId = () => setEnvironmentPolicyId(null); - - return { - environmentPolicyId, - setEnvironmentPolicyId, - removeEnvironmentPolicyId, - tab, - setTab, +type PolicyConfigProps = { + activeTab: EnvironmentPolicyDrawerTab; + environmentPolicy: SCHEMA.EnvironmentPolicy & { + releaseWindows: SCHEMA.EnvironmentPolicyReleaseWindow[]; + releaseChannels: SCHEMA.ReleaseChannel[]; }; + deployments: Deployment[]; }; type Deployment = SCHEMA.Deployment & { releaseChannels: SCHEMA.ReleaseChannel[]; }; -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]: ( - +const View: React.FC< + PolicyConfigProps & { + form: PolicyFormSchema; + } +> = (props) => + ({ + [EnvironmentPolicyDrawerTab.Overview]: , + [EnvironmentPolicyDrawerTab.Approval]: , + [EnvironmentPolicyDrawerTab.Concurrency]: , + [EnvironmentPolicyDrawerTab.Management]: , + [EnvironmentPolicyDrawerTab.Rollout]: , + [EnvironmentPolicyDrawerTab.ReleaseChannels]: ( + ), - [EnvironmentPolicyDrawerTab.Concurrency]: ( - - ), - [EnvironmentPolicyDrawerTab.Management]: ( - - ), - [EnvironmentPolicyDrawerTab.Rollout]: ( - - ), - [EnvironmentPolicyDrawerTab.ReleaseChannels]: deployments != null && ( - - ), - }[activeTab]; + })[props.activeTab]; + +const PolicyConfigForm: React.FC = ({ + activeTab, + environmentPolicy, + deployments, +}) => { + const updateEnvironmentPolicy = api.environment.policy.update.useMutation(); + const utils = api.useUtils(); + + 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(), + }, + }); + + 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)); + }); + + return ( +
+ + + +
+ +
+ + + ); }; const PolicyDropdownMenu: React.FC<{ @@ -163,6 +180,8 @@ export const EnvironmentPolicyDrawer: React.FC = () => { ); const deployments = deploymentsQ.data; + const loading = environmentPolicyQ.isLoading || deploymentsQ.isLoading; + return ( { )} -
+
{ />
- {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 - -