Quellcode durchsuchen

Merge branch 'nico/preview-envs-frontend-improvements' into nafees/preview-env-new-endpoints

Mohammed Nafees vor 4 Jahren
Ursprung
Commit
4631c94b6e
56 geänderte Dateien mit 2890 neuen und 874 gelöschten Zeilen
  1. 1 1
      api/server/handlers/registry/create_repository.go
  2. 23 0
      cli/cmd/deploy.go
  3. 5 15
      cli/cmd/deploy/build.go
  4. 8 8
      cli/cmd/deploy/deploy.go
  5. 1 0
      cli/cmd/deploy/shared.go
  6. 16 1
      cli/cmd/docker.go
  7. 15 15
      cli/cmd/docker/agent.go
  8. 2 1
      cli/cmd/docker/builder.go
  9. 1 1
      cli/cmd/docker/config.go
  10. 12 12
      cli/cmd/docker/porter.go
  11. 10 3
      cli/cmd/pack/logger.go
  12. 13 6
      cli/cmd/pack/pack.go
  13. 1 1
      cli/cmd/version.go
  14. 23 0
      dashboard/package-lock.json
  15. 3 0
      dashboard/package.json
  16. 15 0
      dashboard/src/assets/code-branch-icon.tsx
  17. 89 0
      dashboard/src/components/OptionsDropdown.tsx
  18. 10 2
      dashboard/src/components/form-components/InputRow.tsx
  19. 8 0
      dashboard/src/components/porter-form/PorterForm.tsx
  20. 88 0
      dashboard/src/components/porter-form/field-components/CronInput.tsx
  21. 128 0
      dashboard/src/components/porter-form/field-components/TextAreaInput.tsx
  22. 2 0
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  23. 27 2
      dashboard/src/components/porter-form/types.ts
  24. 1 0
      dashboard/src/main/home/Home.tsx
  25. 13 19
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  26. 20 1
      dashboard/src/main/home/cluster-dashboard/SortSelector.tsx
  27. 82 0
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  28. 36 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  29. 2 22
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  30. 5 82
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  31. 0 10
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  32. 0 495
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx
  33. 90 15
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  34. 197 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx
  35. 58 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ActionButton.tsx
  36. 1 2
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  37. 26 8
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ConnectNewRepo.tsx
  38. 73 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  39. 124 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx
  40. 144 52
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  41. 6 15
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  42. 435 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx
  43. 282 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx
  44. 297 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  45. 105 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  46. 163 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/mocks.ts
  47. 33 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx
  48. 38 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts
  49. 15 0
      dashboard/src/main/home/sidebar/Sidebar.tsx
  50. 50 26
      dashboard/src/shared/api.tsx
  51. 0 6
      dashboard/src/shared/baseApi.ts
  52. 0 38
      dashboard/src/shared/baseApi.tsx
  53. 3 1
      dashboard/src/shared/routing.tsx
  54. 4 0
      dashboard/src/shared/types.tsx
  55. 20 14
      go.mod
  56. 66 0
      go.sum

+ 1 - 1
api/server/handlers/registry/create_repository.go

@@ -43,7 +43,7 @@ func (p *RegistryCreateRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *ht
 
 	// parse the name from the registry
 	nameSpl := strings.Split(request.ImageRepoURI, "/")
-	repoName := nameSpl[len(nameSpl)-1]
+	repoName := strings.ReplaceAll(nameSpl[len(nameSpl)-1], "_", "-")
 
 	err := regAPI.CreateRepository(p.Repo(), repoName)
 

+ 23 - 0
cli/cmd/deploy.go

@@ -210,6 +210,7 @@ var method string
 var stream bool
 var buildFlagsEnv []string
 var forcePush bool
+var useCache bool
 
 func init() {
 	buildFlagsEnv = []string{}
@@ -225,6 +226,13 @@ func init() {
 
 	updateCmd.MarkPersistentFlagRequired("app")
 
+	updateCmd.PersistentFlags().BoolVar(
+		&useCache,
+		"use-cache",
+		false,
+		"Whether to use cache (currently in beta)",
+	)
+
 	updateCmd.PersistentFlags().StringVar(
 		&namespace,
 		"namespace",
@@ -452,6 +460,7 @@ func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 			OverrideTag:     tag,
 			Method:          buildMethod,
 			AdditionalEnv:   additionalEnv,
+			UseCache:        useCache,
 		},
 		Local: source != "github",
 	})
@@ -471,6 +480,14 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 	}
 
+	if useCache {
+		err := setDockerConfig(updateAgent.Client)
+
+		if err != nil {
+			return err
+		}
+	}
+
 	// read the values if necessary
 	valuesObj, err := readValuesFile()
 	if err != nil {
@@ -539,6 +556,12 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 }
 
 func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
+	if useCache {
+		color.New(color.FgGreen).Println("Skipping image push for", app, "as use-cache is set")
+
+		return nil
+	}
+
 	// push the deployment
 	color.New(color.FgGreen).Println("Pushing new image for", app)
 

+ 5 - 15
cli/cmd/deploy/build.go

@@ -49,6 +49,7 @@ func (b *BuildAgent) BuildDocker(
 		Env:               b.env,
 		DockerfilePath:    dockerfilePath,
 		IsDockerfileInCtx: isDockerfileInCtx,
+		UseCache:          b.UseCache,
 	}
 
 	return dockerAgent.BuildLocal(
@@ -74,26 +75,15 @@ func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag, prevTag stri
 	packAgent := &pack.Agent{}
 
 	opts := &docker.BuildOpts{
-		ImageRepo: b.imageRepo,
-		// We tag the image with a stable param "pack-cache" so that pack can use the
-		// local image without attempting to re-pull from registry. We handle getting
-		// registry credentials and pushing/pulling the image.
-		Tag:          "pack-cache",
+		ImageRepo:    b.imageRepo,
+		Tag:          tag,
 		BuildContext: dst,
 		Env:          b.env,
+		UseCache:     b.UseCache,
 	}
 
 	// call builder
-	err := packAgent.Build(opts, buildConfig)
-
-	if err != nil {
-		return err
-	}
-
-	return dockerAgent.TagImage(
-		fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
-		fmt.Sprintf("%s:%s", b.imageRepo, tag),
-	)
+	return packAgent.Build(opts, buildConfig, fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"))
 }
 
 // ResolveDockerPaths returns a path to the dockerfile that is either relative or absolute, and a path

+ 8 - 8
cli/cmd/deploy/deploy.go

@@ -32,7 +32,7 @@ const (
 type DeployAgent struct {
 	App string
 
-	client         *client.Client
+	Client         *client.Client
 	release        *types.GetReleaseResponse
 	agent          *docker.Agent
 	opts           *DeployOpts
@@ -57,7 +57,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 	deployAgent := &DeployAgent{
 		App:    app,
 		opts:   opts,
-		client: client,
+		Client: client,
 		env:    make(map[string]string),
 	}
 
@@ -137,7 +137,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 
 	deployAgent.tag = opts.OverrideTag
 
-	err = coalesceEnvGroups(deployAgent.client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
+	err = coalesceEnvGroups(deployAgent.Client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
 		deployAgent.opts.Namespace, deployAgent.opts.EnvGroups, deployAgent.release.Config)
 
 	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
@@ -160,7 +160,7 @@ func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, err
 		}
 	}
 
-	env, err := GetEnvForRelease(d.client, conf, d.opts.ProjectID, d.opts.ClusterID, d.opts.Namespace)
+	env, err := GetEnvForRelease(d.Client, conf, d.opts.ProjectID, d.opts.ClusterID, d.opts.Namespace)
 
 	if err != nil {
 		return nil, err
@@ -250,7 +250,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 			return fmt.Errorf("invalid formatting of repo name")
 		}
 
-		zipResp, err := d.client.GetRepoZIPDownloadURL(
+		zipResp, err := d.Client.GetRepoZIPDownloadURL(
 			context.Background(),
 			d.opts.ProjectID,
 			int64(d.release.GitActionConfig.GitRepoID),
@@ -292,7 +292,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 
 	buildAgent := &BuildAgent{
 		SharedOpts:  d.opts.SharedOpts,
-		client:      d.client,
+		client:      d.Client,
 		imageRepo:   d.imageRepo,
 		env:         d.env,
 		imageExists: d.imageExists,
@@ -383,7 +383,7 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 		return err
 	}
 
-	return d.client.UpgradeRelease(
+	return d.Client.UpgradeRelease(
 		context.Background(),
 		d.opts.ProjectID,
 		d.opts.ClusterID,
@@ -650,7 +650,7 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 }
 
 func (d *DeployAgent) StreamEvent(event types.SubEvent) error {
-	return d.client.CreateEvent(
+	return d.Client.CreateEvent(
 		context.Background(),
 		d.opts.ProjectID, d.opts.ClusterID,
 		d.release.Namespace, d.release.Name,

+ 1 - 0
cli/cmd/deploy/shared.go

@@ -19,6 +19,7 @@ type SharedOpts struct {
 	Method          DeployBuildType
 	AdditionalEnv   map[string]string
 	EnvGroups       []types.EnvGroupMeta
+	UseCache        bool
 }
 
 func coalesceEnvGroups(

+ 16 - 1
cli/cmd/docker.go

@@ -46,6 +46,10 @@ func init() {
 }
 
 func dockerConfig(user *ptypes.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	return setDockerConfig(client)
+}
+
+func setDockerConfig(client *api.Client) error {
 	pID := config.Project
 
 	// get all registries that should be added
@@ -82,10 +86,21 @@ func dockerConfig(user *ptypes.GetAuthenticatedUserResponse, client *api.Client,
 		}
 	}
 
+	// create a docker dir if it does not exist
+	dockerDir := filepath.Join(home, ".docker")
+
+	if _, err := os.Stat(dockerDir); os.IsNotExist(err) {
+		err = os.Mkdir(dockerDir, 0700)
+
+		if err != nil {
+			return err
+		}
+	}
+
 	dockerConfigFile := filepath.Join(home, ".docker", "config.json")
 
 	// determine if configfile exists
-	if info, err := os.Stat(dockerConfigFile); info.IsDir() || os.IsNotExist(err) {
+	if _, err := os.Stat(dockerConfigFile); os.IsNotExist(err) {
 		// if it does not exist, create it
 		err := ioutil.WriteFile(dockerConfigFile, []byte("{}"), 0700)
 

+ 15 - 15
cli/cmd/docker/agent.go

@@ -26,8 +26,8 @@ import (
 // Agent is a Docker client for performing operations that interact
 // with the Docker engine over REST
 type Agent struct {
+	*client.Client
 	authGetter *AuthGetter
-	client     *client.Client
 	ctx        context.Context
 	label      string
 }
@@ -36,7 +36,7 @@ type Agent struct {
 // given name if it does not exist. If the volume does exist but does not contain
 // the required label (a.label), an error is thrown.
 func (a *Agent) CreateLocalVolumeIfNotExist(name string) (*types.Volume, error) {
-	volListBody, err := a.client.VolumeList(a.ctx, filters.Args{})
+	volListBody, err := a.VolumeList(a.ctx, filters.Args{})
 
 	if err != nil {
 		return nil, a.handleDockerClientErr(err, "Could not list volumes")
@@ -67,7 +67,7 @@ func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
 		Labels: labels,
 	}
 
-	vol, err := a.client.VolumeCreate(a.ctx, opts)
+	vol, err := a.VolumeCreate(a.ctx, opts)
 
 	if err != nil {
 		return nil, a.handleDockerClientErr(err, "Could not create volume "+name)
@@ -78,14 +78,14 @@ func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
 
 // RemoveLocalVolume removes a volume by name
 func (a *Agent) RemoveLocalVolume(name string) error {
-	return a.client.VolumeRemove(a.ctx, name, true)
+	return a.VolumeRemove(a.ctx, name, true)
 }
 
 // CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
 // given name if it does not exist. If the volume does exist but does not contain
 // the required label (a.label), an error is thrown.
 func (a *Agent) CreateBridgeNetworkIfNotExist(name string) (id string, err error) {
-	networks, err := a.client.NetworkList(a.ctx, types.NetworkListOptions{})
+	networks, err := a.NetworkList(a.ctx, types.NetworkListOptions{})
 
 	if err != nil {
 		return "", a.handleDockerClientErr(err, "Could not list volumes")
@@ -113,7 +113,7 @@ func (a *Agent) CreateBridgeNetwork(name string) (id string, err error) {
 		Attachable: true,
 	}
 
-	net, err := a.client.NetworkCreate(a.ctx, name, opts)
+	net, err := a.NetworkCreate(a.ctx, name, opts)
 
 	if err != nil {
 		return "", a.handleDockerClientErr(err, "Could not create network "+name)
@@ -125,7 +125,7 @@ func (a *Agent) CreateBridgeNetwork(name string) (id string, err error) {
 // ConnectContainerToNetwork attaches a container to a specified network
 func (a *Agent) ConnectContainerToNetwork(networkID, containerID, containerName string) error {
 	// check if the container is connected already
-	net, err := a.client.NetworkInspect(a.ctx, networkID, types.NetworkInspectOptions{})
+	net, err := a.NetworkInspect(a.ctx, networkID, types.NetworkInspectOptions{})
 
 	if err != nil {
 		return a.handleDockerClientErr(err, "Could not inspect network"+networkID)
@@ -138,11 +138,11 @@ func (a *Agent) ConnectContainerToNetwork(networkID, containerID, containerName
 		}
 	}
 
-	return a.client.NetworkConnect(a.ctx, networkID, containerID, &network.EndpointSettings{})
+	return a.NetworkConnect(a.ctx, networkID, containerID, &network.EndpointSettings{})
 }
 
 func (a *Agent) TagImage(old, new string) error {
-	return a.client.ImageTag(a.ctx, old, new)
+	return a.ImageTag(a.ctx, old, new)
 }
 
 // PullImageEvent represents a response from the Docker API with an image pull event
@@ -267,7 +267,7 @@ func (a *Agent) CheckIfImageExists(imageRepo, imageTag string) bool {
 		return false
 	}
 
-	_, err = a.client.DistributionInspect(context.Background(), image, encodedRegistryAuth)
+	_, err = a.DistributionInspect(context.Background(), image, encodedRegistryAuth)
 
 	if err == nil {
 		return true
@@ -288,7 +288,7 @@ func (a *Agent) PullImage(image string) error {
 	}
 
 	// pull the specified image
-	out, err := a.client.ImagePull(a.ctx, image, opts)
+	out, err := a.ImagePull(a.ctx, image, opts)
 
 	if err != nil {
 		if client.IsErrNotFound(err) {
@@ -315,7 +315,7 @@ func (a *Agent) PushImage(image string) error {
 		return err
 	}
 
-	out, err := a.client.ImagePush(
+	out, err := a.ImagePush(
 		context.Background(),
 		image,
 		opts,
@@ -437,7 +437,7 @@ func GetServerURLFromTag(image string) (string, error) {
 // WaitForContainerStop waits until a container has stopped to exit
 func (a *Agent) WaitForContainerStop(id string) error {
 	// wait for container to stop before exit
-	statusCh, errCh := a.client.ContainerWait(a.ctx, id, container.WaitConditionNotRunning)
+	statusCh, errCh := a.ContainerWait(a.ctx, id, container.WaitConditionNotRunning)
 
 	select {
 	case err := <-errCh:
@@ -455,7 +455,7 @@ func (a *Agent) WaitForContainerStop(id string) error {
 // checks.
 func (a *Agent) WaitForContainerHealthy(id string, streak int) error {
 	for {
-		cont, err := a.client.ContainerInspect(a.ctx, id)
+		cont, err := a.ContainerInspect(a.ctx, id)
 
 		if err != nil {
 			return a.handleDockerClientErr(err, "Error waiting for stopped container")
@@ -479,7 +479,7 @@ func (a *Agent) WaitForContainerHealthy(id string, streak int) error {
 
 func (a *Agent) handleDockerClientErr(err error, errPrefix string) error {
 	if strings.Contains(err.Error(), "Cannot connect to the Docker daemon") {
-		return fmt.Errorf("The Docker daemon must be running in order to start Porter: connection to %s failed", a.client.DaemonHost())
+		return fmt.Errorf("The Docker daemon must be running in order to start Porter: connection to %s failed", a.DaemonHost())
 	}
 
 	return fmt.Errorf("%s:%s", errPrefix, err.Error())

+ 2 - 1
cli/cmd/docker/builder.go

@@ -25,6 +25,7 @@ type BuildOpts struct {
 	BuildContext      string
 	DockerfilePath    string
 	IsDockerfileInCtx bool
+	UseCache          bool
 
 	Env map[string]string
 }
@@ -66,7 +67,7 @@ func (a *Agent) BuildLocal(opts *BuildOpts) error {
 	inlineCacheVal := "1"
 	buildArgs["BUILDKIT_INLINE_CACHE"] = &inlineCacheVal
 
-	out, err := a.client.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
+	out, err := a.ImageBuild(context.Background(), tar, types.ImageBuildOptions{
 		Dockerfile: dockerfilePath,
 		BuildArgs:  buildArgs,
 		Tags: []string{

+ 1 - 1
cli/cmd/docker/config.go

@@ -23,7 +23,7 @@ func NewAgentFromEnv() (*Agent, error) {
 	}
 
 	return &Agent{
-		client: cli,
+		Client: cli,
 		ctx:    ctx,
 		label:  label,
 	}, nil

+ 12 - 12
cli/cmd/docker/porter.go

@@ -213,13 +213,13 @@ func (a *Agent) upsertPorterContainer(opts PorterServerStartOpts) (id string, er
 		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
 			timeout, _ := time.ParseDuration("15s")
 
-			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+			err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 			if err != nil {
 				return "", a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 			}
 
-			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+			err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
 
 			if err != nil {
 				return "", a.handleDockerClientErr(err, "Could not remove container "+container.ID)
@@ -247,7 +247,7 @@ func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id str
 	labels[a.label] = "true"
 
 	// create the container with a label specifying this was created via the CLI
-	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+	resp, err := a.ContainerCreate(a.ctx, &container.Config{
 		Image:   opts.Image,
 		Cmd:     opts.StartCmd,
 		Tty:     false,
@@ -274,7 +274,7 @@ func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id str
 
 // start the container
 func (a *Agent) startPorterContainer(id string) error {
-	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+	if err := a.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
 		return a.handleDockerClientErr(err, "Could not start Porter container")
 	}
 
@@ -328,7 +328,7 @@ func (a *Agent) upsertPostgresContainer(opts PostgresOpts) (id string, err error
 		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
 			timeout, _ := time.ParseDuration("15s")
 
-			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+			err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 			if err != nil {
 				return "", a.handleDockerClientErr(err, "Could not stop postgres container "+container.ID)
@@ -349,7 +349,7 @@ func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, er
 	labels[a.label] = "true"
 
 	// create the container with a label specifying this was created via the CLI
-	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+	resp, err := a.ContainerCreate(a.ctx, &container.Config{
 		Image:   opts.Image,
 		Tty:     false,
 		Labels:  labels,
@@ -377,7 +377,7 @@ func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, er
 
 // start the container in the background
 func (a *Agent) startPostgresContainer(id string) error {
-	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+	if err := a.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
 		return a.handleDockerClientErr(err, "Could not start Postgres container")
 	}
 
@@ -397,14 +397,14 @@ func (a *Agent) StopPorterContainers(remove bool) error {
 	for _, container := range containers {
 		timeout, _ := time.ParseDuration("15s")
 
-		err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+		err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 		if err != nil {
 			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 		}
 
 		if remove {
-			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+			err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
 
 			if err != nil {
 				return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
@@ -430,14 +430,14 @@ func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool)
 		if strings.Contains(container.Names[0], "_"+processID) {
 			timeout, _ := time.ParseDuration("15s")
 
-			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+			err := a.ContainerStop(a.ctx, container.ID, &timeout)
 
 			if err != nil {
 				return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 			}
 
 			if remove {
-				err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+				err = a.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
 
 				if err != nil {
 					return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
@@ -452,7 +452,7 @@ func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool)
 // getContainersCreatedByStart gets all containers that were created by the "porter start"
 // command by looking for the label "CreatedByPorterCLI" (or .label of the agent)
 func (a *Agent) getContainersCreatedByStart() ([]types.Container, error) {
-	containers, err := a.client.ContainerList(a.ctx, types.ContainerListOptions{
+	containers, err := a.ContainerList(a.ctx, types.ContainerListOptions{
 		All: true,
 	})
 

+ 10 - 3
cli/cmd/pack/logger.go

@@ -7,7 +7,7 @@ import (
 	"os"
 	"strings"
 
-	"github.com/buildpacks/pack/logging"
+	"github.com/buildpacks/pack/pkg/logging"
 )
 
 type packLogger struct {
@@ -37,9 +37,16 @@ func (l *packLogger) Debugf(format string, v ...interface{}) {
 	// We do not want to print the environment variables for now as they might
 	// contain sensitive information like client IDs and secrets
 	// Refer: https://github.com/buildpacks/pack/blob/main/internal/builder/builder.go#L349
-	if !strings.HasPrefix(format, "Provided Environment Variables") {
-		l.out.Printf(prefixFmt, debugPrefix, fmt.Sprintf(format, v...))
+	if strings.HasPrefix(format, "Provided Environment Variables") {
+		return
 	}
+
+	// We do not print the registry auth credentials -- this should also be treated as sensitive information
+	if strings.Contains(fmt.Sprintf(format, v...), "CNB_REGISTRY_AUTH") {
+		return
+	}
+
+	l.out.Printf(prefixFmt, debugPrefix, fmt.Sprintf(format, v...))
 }
 
 func (l *packLogger) Info(msg string) {

+ 13 - 6
cli/cmd/pack/pack.go

@@ -9,7 +9,7 @@ import (
 	"regexp"
 	"strings"
 
-	"github.com/buildpacks/pack"
+	packclient "github.com/buildpacks/pack/pkg/client"
 	githubApi "github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
@@ -19,9 +19,11 @@ import (
 
 type Agent struct{}
 
-func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) error {
+func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig, cacheImage string) error {
 	//initialize a pack client
-	client, err := pack.NewClient(pack.WithLogger(newPackLogger()))
+	logger := newPackLogger()
+
+	client, err := packclient.NewClient(packclient.WithLogger(logger))
 
 	if err != nil {
 		return err
@@ -33,13 +35,18 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) er
 		return err
 	}
 
-	buildOpts := pack.BuildOptions{
+	buildOpts := packclient.BuildOptions{
 		RelativeBaseDir: filepath.Dir(absPath),
 		Image:           fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
 		Builder:         "paketobuildpacks/builder:full",
 		AppPath:         opts.BuildContext,
-		TrustBuilder:    true,
 		Env:             opts.Env,
+		GroupID:         0,
+	}
+
+	if opts.UseCache {
+		buildOpts.CacheImage = cacheImage
+		buildOpts.Publish = true
 	}
 
 	if buildConfig != nil {
@@ -128,7 +135,7 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) er
 	}
 
 	if len(buildOpts.Buildpacks) > 0 && strings.HasPrefix(buildOpts.Builder, "heroku") {
-		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile")
+		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile@1.0.0")
 	}
 
 	return client.Build(context.Background(), buildOpts)

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "dev"
+var Version string = "v0.19.6"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 23 - 0
dashboard/package-lock.json

@@ -3898,6 +3898,24 @@
         "sha.js": "^2.4.8"
       }
     },
+    "cron-parser": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.3.0.tgz",
+      "integrity": "sha512-mK6qJ6k9Kn0/U7Cv6LKQnReUW3GqAW4exgwmHJGb3tPgcy0LrS+PeqxPPiwL8uW/4IJsMsCZrCc4vf1nnXMjzA==",
+      "requires": {
+        "luxon": "^1.28.0"
+      }
+    },
+    "cron-validator": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz",
+      "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A=="
+    },
+    "cronstrue": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.2.0.tgz",
+      "integrity": "sha512-oM/ftAvCNIdygVGGfYp8gxrVc81mDSA2mff0kvu6+ehrZhfYPzGHG8DVcFdrRVizjHnzWoFIlgEq6KTM/9lPBw=="
+    },
     "cross-spawn": {
       "version": "6.0.5",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -6472,6 +6490,11 @@
         "yallist": "^4.0.0"
       }
     },
+    "luxon": {
+      "version": "1.28.0",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz",
+      "integrity": "sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ=="
+    },
     "make-dir": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",

+ 3 - 0
dashboard/package.json

@@ -25,6 +25,9 @@
     "clipboard": "^2.0.8",
     "cohere-js": "^1.0.19",
     "core-js": "^3.16.1",
+    "cron-parser": "^4.3.0",
+    "cron-validator": "^1.3.1",
+    "cronstrue": "^2.2.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",

+ 15 - 0
dashboard/src/assets/code-branch-icon.tsx

@@ -0,0 +1,15 @@
+import React, { SVGProps } from "react";
+
+function Icon(props: SVGProps<SVGElement>) {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 448 512"
+      className={props.className}
+    >
+      <path d="M160 80c0 32.8-19.7 60.1-48 73.3v87.8c18.8-10.9 40.7-17.1 64-17.1h96c35.3 0 64-28.7 64-64v-6.7c-28.3-13.2-48-40.5-48-73.3 0-44.18 35.8-80 80-80s80 35.82 80 80c0 32.8-19.7 60.1-48 73.3v6.7c0 70.7-57.3 128-128 128h-96c-35.3 0-64 28.7-64 64v6.7c28.3 12.3 48 40.5 48 73.3 0 44.2-35.8 80-80 80-44.18 0-80-35.8-80-80 0-32.8 19.75-61 48-73.3V153.3C19.75 140.1 0 112.8 0 80 0 35.82 35.82 0 80 0c44.2 0 80 35.82 80 80zm-80 24c13.25 0 24-10.75 24-24S93.25 56 80 56 56 66.75 56 80s10.75 24 24 24zm288-48c-13.3 0-24 10.75-24 24s10.7 24 24 24 24-10.75 24-24-10.7-24-24-24zM80 456c13.25 0 24-10.7 24-24s-10.75-24-24-24-24 10.7-24 24 10.75 24 24 24z"></path>
+    </svg>
+  );
+}
+
+export default Icon;

+ 89 - 0
dashboard/src/components/OptionsDropdown.tsx

@@ -0,0 +1,89 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+export const OptionsDropdown: React.FC<{
+  expandIcon?: string;
+  shrinkIcon?: string;
+}> = ({ children, expandIcon = "expand_more", shrinkIcon = "expand_less" }) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const handleClick = (e: any) => {
+    e.stopPropagation();
+    setIsOpen(!isOpen);
+  };
+
+  const handleOnBlur = () => {
+    setIsOpen(false);
+  };
+
+  return (
+    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
+      <i className="material-icons">{isOpen ? shrinkIcon : expandIcon}</i>
+      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
+    </OptionsButton>
+  );
+};
+
+const OptionsButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+`;
+
+const DropdownMenu = styled.div`
+  position: absolute;
+  right: 12px;
+  top: 30px;
+  overflow: hidden;
+  width: 120px;
+  height: auto;
+  background: #26282f;
+  box-shadow: 0 8px 20px 0px #00000088;
+  color: white;
+`;
+
+const DropdownOption = styled.div`
+  width: 100%;
+  height: 37px;
+  font-size: 13px;
+  cursor: pointer;
+  padding-left: 10px;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  :hover {
+    background: #ffffff22;
+  }
+  :not(:first-child) {
+    border-top: 1px solid #00000000;
+  }
+
+  :not(:last-child) {
+    border-bottom: 1px solid #ffffff15;
+  }
+
+  > i {
+    margin-right: 5px;
+    font-size: 16px;
+  }
+`;
+
+export default {
+  Dropdown: OptionsDropdown,
+  Option: DropdownOption,
+};

+ 10 - 2
dashboard/src/components/form-components/InputRow.tsx

@@ -15,6 +15,7 @@ type PropsType = {
   isRequired?: boolean;
   className?: string;
   maxLength?: number;
+  hasError?: boolean;
 };
 
 type StateType = {
@@ -65,7 +66,7 @@ export default class InputRow extends Component<PropsType, StateType> {
             {this.props.isRequired && <Required>{" *"}</Required>}
           </Label>
         )}
-        <InputWrapper>
+        <InputWrapper hasError={this.props.hasError} width={width}>
           <Input
             readOnly={this.state.readOnly}
             onFocus={() => this.setState({ readOnly: false })}
@@ -103,8 +104,15 @@ const InputWrapper = styled.div`
   display: flex;
   margin-bottom: -1px;
   align-items: center;
-  border: 1px solid #ffffff55;
+  border: 1px solid
+    ${(props: { width: string; hasError: boolean }) =>
+      props.hasError ? "red" : "#ffffff55"};
   border-radius: 3px;
+  ${(props: { width: string; hasError: boolean }) => {
+    if (props.width) {
+      return `width:${props.width};`;
+    }
+  }}
 `;
 
 const Input = styled.input<{ disabled: boolean; width: string }>`

+ 8 - 0
dashboard/src/components/porter-form/PorterForm.tsx

@@ -2,6 +2,7 @@ import React, { useContext } from "react";
 import {
   ArrayInputField,
   CheckboxField,
+  CronField,
   FormField,
   InputField,
   KeyValueArrayField,
@@ -9,6 +10,7 @@ import {
   Section,
   SelectField,
   ServiceIPListField,
+  TextAreaField,
 } from "./types";
 import TabRegion, { TabOption } from "../TabRegion";
 import Heading from "../form-components/Heading";
@@ -24,6 +26,8 @@ import Select from "./field-components/Select";
 import ServiceIPList from "./field-components/ServiceIPList";
 import ResourceList from "./field-components/ResourceList";
 import VeleroForm from "./field-components/VeleroForm";
+import CronInput from "./field-components/CronInput";
+import TextAreaInput from "./field-components/TextAreaInput";
 
 interface Props {
   leftTabOptions?: TabOption[];
@@ -84,6 +88,10 @@ const PorterForm: React.FC<Props> = (props) => {
         return <ResourceList {...(bundledProps as ResourceListField)} />;
       case "velero-create-backup":
         return <VeleroForm />;
+      case "cron":
+        return <CronInput {...(bundledProps as CronField)} />;
+      case "text-area":
+        return <TextAreaInput {...(bundledProps as TextAreaField)} />;
     }
     return <p>Not Implemented: {(field as any).type}</p>;
   };

+ 88 - 0
dashboard/src/components/porter-form/field-components/CronInput.tsx

@@ -0,0 +1,88 @@
+import InputRow from "components/form-components/InputRow";
+import React from "react";
+import useFormField from "../hooks/useFormField";
+import { CronField } from "../types";
+import { hasSetValue } from "../utils";
+import { isValidCron } from "cron-validator";
+import CronParser from "cronstrue";
+import styled from "styled-components";
+import DocsHelper from "components/DocsHelper";
+import DynamicLink from "components/DynamicLink";
+
+const CronInput: React.FC<CronField> = (props) => {
+  const { id, variable, label, placeholder, value } = props;
+
+  const { state, variables, setVars, setValidation, validation } = useFormField(
+    id,
+    {
+      initValidation: {
+        validated: hasSetValue(props) ? isValidCron(value[0]) : true,
+      },
+      initVars: {
+        [variable]: hasSetValue(props) ? value[0] : undefined,
+      },
+    }
+  );
+
+  if (!state || validation[id]?.validated === undefined) {
+    return null;
+  }
+
+  return (
+    <>
+      <InputRow
+        type="text"
+        label={label}
+        placeholder={placeholder}
+        value={variables[variable]}
+        setValue={(x: string) => {
+          setVars((vars) => {
+            return {
+              ...vars,
+              [variable]: x,
+            };
+          });
+          setValidation((prev) => {
+            return {
+              ...prev,
+              validated: isValidCron(x),
+            };
+          });
+        }}
+        width={"100%"}
+        hasError={!validation[id]?.validated}
+      />
+      <Label error={!validation[id]?.validated}>
+        {!validation[id]?.validated ? (
+          <>
+            The expresion is not valid, to learn more about cron jobs please
+            click{" "}
+            <DynamicLink
+              style={{ color: "red", textDecoration: "underline" }}
+              to="https://docs.porter.run/running-jobs/deploying-jobs#deploying-a-cron-job"
+            >
+              here
+            </DynamicLink>
+          </>
+        ) : (
+          <>
+            {CronParser.toString(variables[variable], {
+              throwExceptionOnParseError: false,
+              verbose: true,
+            })}
+          </>
+        )}
+      </Label>
+    </>
+  );
+};
+
+const Label = styled.label`
+  ${(props: { error: boolean }) => {
+    if (props.error) {
+      return "color: red;";
+    }
+  }}
+`;
+
+export default CronInput;

+ 128 - 0
dashboard/src/components/porter-form/field-components/TextAreaInput.tsx

@@ -0,0 +1,128 @@
+import { Tooltip } from "@material-ui/core";
+import React from "react";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import { StringInputFieldState, TextAreaField } from "../types";
+import { hasSetValue } from "../utils";
+
+const TextAreaInput: React.FC<TextAreaField> = (props) => {
+  const {
+    id,
+    variable,
+    label,
+    info,
+    placeholder,
+    required,
+    settings,
+    isReadOnly,
+    value,
+  } = props;
+
+  const { state, variables, setVars } = useFormField<StringInputFieldState>(
+    id,
+    {
+      initVars: {
+        [variable]: hasSetValue(props) ? value[0] : undefined,
+      },
+    }
+  );
+
+  if (!state) {
+    return null;
+  }
+
+  return (
+    <div>
+      {label || info ? (
+        <Label>
+          {label}
+          {info && (
+            <Tooltip
+              title={
+                <div
+                  style={{
+                    fontFamily: "Work Sans, sans-serif",
+                    fontSize: "12px",
+                    fontWeight: "normal",
+                    padding: "5px 6px",
+                  }}
+                >
+                  {info}
+                </div>
+              }
+              placement="top"
+            >
+              <StyledInfoTooltip>
+                <i className="material-icons">help_outline</i>
+              </StyledInfoTooltip>
+            </Tooltip>
+          )}
+          {required && <Required>{" *"}</Required>}
+        </Label>
+      ) : null}
+      <TextArea
+        maxLength={settings?.options?.maxCount}
+        minLength={settings?.options?.minCount}
+        disabled={isReadOnly}
+        value={variables[variable]}
+        placeholder={placeholder}
+        onChange={(e) => {
+          e?.persist();
+          setVars((prev) => {
+            return {
+              ...prev,
+              [variable]: e?.target?.value,
+            };
+          });
+        }}
+      ></TextArea>
+    </div>
+  );
+};
+
+export default TextAreaInput;
+
+const TextArea = styled.textarea`
+  width: 100%;
+  max-width: 100%;
+  min-height: 150px;
+  height: auto;
+  max-height: 300px;
+  background: #ffffff11;
+  color: #ffffff;
+  border-radius: 5px;
+  padding: 10px;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : ""};
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    font-size: 16px;
+    margin-left: 5px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;

+ 2 - 0
dashboard/src/components/porter-form/hooks/useFormField.tsx

@@ -9,6 +9,7 @@ import {
 interface FormFieldData<T> {
   state: T;
   variables: PorterFormVariableList;
+  validation: { [key: string]: PorterFormFieldValidationState };
   setState: (setFunc: (prev: T) => Partial<T>) => void;
   setVars: (
     setFunc: (vars: PorterFormVariableList) => PorterFormVariableList
@@ -89,6 +90,7 @@ const useFormField = <T extends PorterFormFieldFieldState>(
   return {
     state: formState.components[fieldId]?.state as T,
     variables: formState.variables,
+    validation: formState.validation,
     setState,
     setVars,
     setValidation,

+ 27 - 2
dashboard/src/components/porter-form/types.ts

@@ -85,7 +85,7 @@ export interface KeyValueArrayField extends GenericInputField {
   settings?: {
     options?: {
       enable_synced_env_groups: boolean;
-    },
+    };
     type: "env" | "normal";
   };
 }
@@ -119,6 +119,29 @@ export interface VariableField extends GenericInputField {
   };
 }
 
+export interface CronField extends GenericInputField {
+  type: "cron";
+  label: string;
+  placeholder: string;
+  settings: {
+    default: string;
+  };
+}
+
+export interface TextAreaField extends GenericInputField {
+  type: "text-area";
+  label: string;
+  placeholder: string;
+  info: string;
+  settings: {
+    default?: string;
+    options?: {
+      maxCount?: number;
+      minCount?: number;
+    };
+  };
+}
+
 export type FormField =
   | HeadingField
   | SubtitleField
@@ -130,7 +153,9 @@ export type FormField =
   | ServiceIPListField
   | ResourceListField
   | VeleroBackupField
-  | VariableField;
+  | VariableField
+  | CronField
+  | TextAreaField;
 
 export interface ShowIfAnd {
   and: ShowIf[];

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -467,6 +467,7 @@ class Home extends Component<PropsType, StateType> {
                 "/jobs",
                 "/env-groups",
                 "/databases",
+                "/preview-environments",
               ]}
               render={() => {
                 let { currentCluster } = this.context;

+ 13 - 19
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -38,6 +38,14 @@ const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
   fallback: <Loading />,
 });
 
+const LazyPreviewEnvironmentsRoutes = loadable(
+  // @ts-ignore
+  () => import("./preview-environments/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
 type PropsType = RouteComponentProps &
   WithAuthProps & {
     currentCluster: ClusterType;
@@ -177,6 +185,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
               sortType={this.state.sortType}
+              currentView={currentView}
             />
           </SortFilterWrapper>
         </ControlRow>
@@ -247,6 +256,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
               sortType={this.state.sortType}
+              currentView={currentView}
             />
           </SortFilterWrapper>
         </ControlRow>
@@ -270,24 +280,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
   };
 
-  // renderContents = () => {
-  //   let { currentCluster, setSidebar, currentView } = this.props;
-  //   if (currentView === "env-groups") {
-  //     return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
-  //   }
-
-  //   return (
-  //     <>
-  //       <DashboardHeader
-  //         image={currentView === "jobs" ? monojob : monoweb}
-  //         title={currentView}
-  //         description={this.getDescription(currentView)}
-  //       />
-  //       {this.renderBody()}
-  //     </>
-  //   );
-  // };
-
   render() {
     let { currentView } = this.props;
     let { setSidebar } = this.props;
@@ -335,9 +327,11 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {/* {this.renderContents()} */}
           <EnvGroupDashboard currentCluster={this.props.currentCluster} />
         </GuardedRoute>
+        <Route path={"/preview-environments"}>
+          <LazyPreviewEnvironmentsRoutes />
+        </Route>
         <Route path={"/databases"}>
           <LazyDatabasesRoutes />
         </Route>

+ 20 - 1
dashboard/src/main/home/cluster-dashboard/SortSelector.tsx

@@ -8,6 +8,7 @@ import Selector from "components/Selector";
 type PropsType = {
   setSortType: (x: string) => void;
   sortType: string;
+  currentView: string;
 };
 
 type StateType = {
@@ -21,9 +22,27 @@ export default class SortSelector extends Component<PropsType, StateType> {
       { label: "Newest", value: "Newest" },
       { label: "Oldest", value: "Oldest" },
       { label: "Alphabetical", value: "Alphabetical" },
+      { label: "Next Run", value: "Next Run" },
     ] as { label: string; value: string }[],
   };
 
+  getSortOptions() {
+    if (this.props.currentView === "jobs") {
+      return [
+        { label: "Newest", value: "Newest" },
+        { label: "Oldest", value: "Oldest" },
+        { label: "Alphabetical", value: "Alphabetical" },
+        { label: "Next Run", value: "Next Run" },
+      ];
+    }
+
+    return [
+      { label: "Newest", value: "Newest" },
+      { label: "Oldest", value: "Oldest" },
+      { label: "Alphabetical", value: "Alphabetical" },
+    ];
+  }
+
   render() {
     return (
       <StyledSortSelector>
@@ -33,7 +52,7 @@ export default class SortSelector extends Component<PropsType, StateType> {
         <Selector
           activeValue={this.props.sortType}
           setActiveValue={(sortType) => this.props.setSortType(sortType)}
-          options={this.state.sortOptions}
+          options={this.getSortOptions()}
           dropdownLabel="Sort By"
           width="150px"
           dropdownWidth="230px"

+ 82 - 0
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -13,6 +13,14 @@ import StatusIndicator from "components/StatusIndicator";
 import { pushFiltered } from "shared/routing";
 import api from "shared/api";
 import { readableDate } from "shared/string_utils";
+import { Tooltip, Zoom } from "@material-ui/core";
+import CronParser from "cron-parser";
+
+import {
+  createTheme,
+  MuiThemeProvider,
+  withStyles,
+} from "@material-ui/core/styles";
 
 type Props = {
   chart: ChartType;
@@ -22,6 +30,17 @@ type Props = {
   closeChartRedirectUrl?: string;
 };
 
+const theme = createTheme({
+  overrides: {
+    MuiTooltip: {
+      tooltip: {
+        backgroundColor: "#3E3F44",
+        border: "1px solid #ffffff33",
+      },
+    },
+  },
+});
+
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
@@ -31,6 +50,7 @@ const Chart: React.FunctionComponent<Props> = ({
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
+  const [showDescription, setShowDescription] = useState(false);
   const context = useContext(Context);
   const location = useLocation();
   const history = useHistory();
@@ -83,6 +103,21 @@ const Chart: React.FunctionComponent<Props> = ({
     return tmpControllers;
   }, [chartControllers, controllers]);
 
+  let interval = null;
+  if (chart?.config?.schedule?.enabled) {
+    interval = CronParser.parseExpression(chart?.config?.schedule.value, {
+      currentDate: new Date(),
+    });
+  }
+
+  // @ts-ignore
+  const rtf = new Intl.DateTimeFormat("en", {
+    localeMatcher: "best fit", // other values: "lookup"
+    // @ts-ignore
+    dateStyle: "full",
+    timeStyle: "long",
+  });
+
   return (
     <StyledChart
       onMouseEnter={() => setExpand(true)}
@@ -101,6 +136,33 @@ const Chart: React.FunctionComponent<Props> = ({
       <Title>
         <IconWrapper>{renderIcon()}</IconWrapper>
         {chart.name}
+        {chart?.config?.description && (
+          <>
+            <Dot style={{ marginLeft: "9px", color: "#ffffff88" }}>•</Dot>
+            <MuiThemeProvider theme={theme}>
+              <Tooltip
+                TransitionComponent={Zoom}
+                placement={"bottom-start"}
+                title={
+                  <div
+                    style={{
+                      fontFamily: "Work Sans, sans-serif",
+                      fontSize: "12px",
+                      fontWeight: "normal",
+                      padding: "5px 6px",
+                      color: "#ffffffdd",
+                      lineHeight: "16px",
+                    }}
+                  >
+                    {chart.config.description as string}
+                  </div>
+                }
+              >
+                <Description>{chart.config.description}</Description>
+              </Tooltip>
+            </MuiThemeProvider>
+          </>
+        )}
       </Title>
 
       <BottomWrapper>
@@ -129,6 +191,14 @@ const Chart: React.FunctionComponent<Props> = ({
                 </JobStatus>
               </>
             )}
+            {chart.config?.schedule?.enabled ? (
+              <>
+                <Dot style={{ marginLeft: "10px" }}>•</Dot>
+                <JobStatus>
+                  Next run {rtf.format(interval?.next().toDate() || new Date())}
+                </JobStatus>
+              </>
+            ) : null}
           </LastDeployed>
         </InfoWrapper>
 
@@ -147,6 +217,17 @@ const Chart: React.FunctionComponent<Props> = ({
 
 export default Chart;
 
+const Description = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  max-width: 80%;
+  color: #ffffff88;
+  position: relative;
+  font-size: 13px;
+  padding-top: 1px;
+`;
+
 const BottomWrapper = styled.div`
   display: flex;
   justify-content: space-between;
@@ -244,6 +325,7 @@ const IconWrapper = styled.div`
 `;
 
 const Title = styled.div`
+  display: flex;
   position: relative;
   text-decoration: none;
   padding: 12px 35px 12px 45px;

+ 36 - 0
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -16,6 +16,7 @@ import { PorterUrl } from "shared/routing";
 import Chart from "./Chart";
 import Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
+import CronParser from "cron-parser";
 
 type Props = {
   currentCluster: ClusterType;
@@ -360,6 +361,41 @@ const ChartList: React.FunctionComponent<Props> = ({
       );
     } else if (sortType == "Alphabetical") {
       result.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+    } else if (sortType == "Next Run" && currentView === "jobs") {
+      const cronJobs = result.filter(
+        (chart) => chart?.config?.schedule?.enabled
+      );
+      const nonCronJobs = result.filter(
+        (chart) => !chart?.config?.schedule?.enabled
+      );
+      cronJobs.sort((a: any, b: any) => {
+        let firstInterval = null;
+        if (a?.config?.schedule?.enabled) {
+          firstInterval = CronParser.parseExpression(
+            a?.config?.schedule.value,
+            {
+              currentDate: new Date(),
+            }
+          );
+        }
+
+        let secondInterval = null;
+        if (b?.config?.schedule?.enabled) {
+          secondInterval = CronParser.parseExpression(
+            b?.config?.schedule.value,
+            {
+              currentDate: new Date(),
+            }
+          );
+        }
+
+        return Date.parse(firstInterval.next().toISOString()) >
+          Date.parse(secondInterval.next().toISOString())
+          ? 1
+          : -1;
+      });
+
+      return [...cronJobs, ...nonCronJobs];
     }
 
     return result;

+ 2 - 22
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -11,24 +11,16 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
-import EnvironmentList from "./preview-environments/EnvironmentList";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
 import IncidentsTab from "./incidents/IncidentsTab";
 
-type TabEnum =
-  | "preview_environments"
-  | "nodes"
-  | "settings"
-  | "namespaces"
-  | "metrics"
-  | "incidents";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "incidents";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
-  { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
   { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
@@ -37,10 +29,7 @@ const tabOptions: {
 ];
 
 export const Dashboard: React.FunctionComponent = () => {
-  const { currentProject } = useContext(Context);
-  const [currentTab, setCurrentTab] = useState<TabEnum>(() =>
-    currentProject.preview_envs_enabled ? "preview_environments" : "nodes"
-  );
+  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [isAuthorized] = useAuth();
   const location = useLocation();
@@ -48,11 +37,6 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
-      case "preview_environments":
-        if (currentProject.preview_envs_enabled) {
-          return <EnvironmentList />;
-        }
-        return <NodeList />;
       case "incidents":
         return <IncidentsTab />;
       case "settings":
@@ -70,10 +54,6 @@ export const Dashboard: React.FunctionComponent = () => {
   useEffect(() => {
     setCurrentTabOptions(
       tabOptions.filter((option) => {
-        if (option.value === "preview_environments") {
-          return currentProject.preview_envs_enabled;
-        }
-
         if (option.value === "settings") {
           return isAuthorized("cluster", "", ["get", "delete"]);
         }

+ 5 - 82
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -6,25 +6,7 @@ import { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
 import useAuth from "shared/auth/useAuth";
 
-const OptionsDropdown: React.FC = ({ children }) => {
-  const [isOpen, setIsOpen] = useState(false);
-
-  const handleClick = (e: any) => {
-    e.stopPropagation();
-    setIsOpen(!isOpen);
-  };
-
-  const handleOnBlur = () => {
-    setIsOpen(false);
-  };
-
-  return (
-    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
-      <i className="material-icons">{isOpen ? "expand_less" : "expand_more"}</i>
-      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
-    </OptionsButton>
-  );
-};
+import OptionsDropdown from "components/OptionsDropdown";
 
 const useWebsocket = (
   currentProject: ProjectType,
@@ -173,12 +155,12 @@ export const NamespaceList: React.FunctionComponent = () => {
               {isAuthorized("namespace", "", ["get", "delete"]) &&
                 isAvailableForDeletion(namespace?.metadata?.name) &&
                 namespace?.status?.phase === "Active" && (
-                  <OptionsDropdown>
-                    <DropdownOption onClick={() => onDelete(namespace)}>
+                  <OptionsDropdown.Dropdown>
+                    <OptionsDropdown.Option onClick={() => onDelete(namespace)}>
                       <i className="material-icons-outlined">delete</i>
                       <span>Delete</span>
-                    </DropdownOption>
-                  </OptionsDropdown>
+                    </OptionsDropdown.Option>
+                  </OptionsDropdown.Dropdown>
                 )}
             </StyledCard>
           );
@@ -333,62 +315,3 @@ const ContentContainer = styled.div`
   justify-content: space-between;
   height: 100%;
 `;
-
-const OptionsButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  color: #ffffff44;
-  :hover {
-    background: #32343a;
-    cursor: pointer;
-  }
-`;
-
-const DropdownMenu = styled.div`
-  position: absolute;
-  right: 12px;
-  top: 30px;
-  overflow: hidden;
-  width: 120px;
-  height: auto;
-  background: #26282f;
-  box-shadow: 0 8px 20px 0px #00000088;
-  color: white;
-`;
-
-const DropdownOption = styled.div`
-  width: 100%;
-  height: 37px;
-  font-size: 13px;
-  cursor: pointer;
-  padding-left: 10px;
-  padding-right: 10px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  :hover {
-    background: #ffffff22;
-  }
-  :not(:first-child) {
-    border-top: 1px solid #00000000;
-  }
-
-  :not(:last-child) {
-    border-bottom: 1px solid #ffffff15;
-  }
-
-  > i {
-    margin-right: 5px;
-    font-size: 16px;
-  }
-`;

+ 0 - 10
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -4,7 +4,6 @@ import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
 import IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
-import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
 export const Routes = () => {
   const { url } = useRouteMatch();
@@ -18,15 +17,6 @@ export const Routes = () => {
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>
-        <Route
-          path={`${url}/pr-env-detail/:namespace`}
-          render={() => {
-            if (currentProject.preview_envs_enabled) {
-              return <EnvironmentDetail />;
-            }
-            return <Redirect to={`${url}/`} />;
-          }}
-        ></Route>
         <Route path={`${url}/`}>
           <Dashboard />
         </Route>

+ 0 - 495
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx

@@ -1,495 +0,0 @@
-import DynamicLink from "components/DynamicLink";
-import React, { useContext, useEffect, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { useHistory, useLocation, useRouteMatch } from "react-router";
-import { getQueryParam } from "shared/routing";
-import styled from "styled-components";
-import Selector from "components/Selector";
-
-import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
-import ConnectNewRepo from "./components/ConnectNewRepo";
-import Loading from "components/Loading";
-
-import _, { flatMapDepth } from "lodash";
-import EnvironmentCard from "./components/EnvironmentCard";
-
-export type PRDeployment = {
-  id: number;
-  created_at: string;
-  updated_at: string;
-  subdomain: string;
-  status: string;
-  environment_id: number;
-  pull_request_id: number;
-  namespace: string;
-  gh_pr_name: string;
-  gh_repo_owner: string;
-  gh_repo_name: string;
-  gh_commit_sha: string;
-};
-
-export type Environment = {
-  id: Number;
-  project_id: number;
-  cluster_id: number;
-  git_installation_id: number;
-  name: string;
-  git_repo_owner: string;
-  git_repo_name: string;
-};
-
-const EnvironmentList = () => {
-  const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
-  const [hasPermissions, setHasPermissions] = useState(false);
-  const [hasPermissionsLoaded, setHasPermissionsLoaded] = useState(false);
-  const [environmentList, setEnvironmentList] = useState<Environment[]>([]);
-  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
-  const [statusSelectorVal, setStatusSelectorVal] = useState<string>("active");
-
-  const [showConnectRepoFlow, setShowConnectRepoFlow] = useState(false);
-  const { currentProject, currentCluster, setCurrentModal } = useContext(
-    Context
-  );
-
-  const { url: currentUrl } = useRouteMatch();
-
-  const location = useLocation();
-  const history = useHistory();
-
-  const getPRDeploymentList = () => {
-    let status: string[] = [];
-
-    if (statusSelectorVal == "active") {
-      status = ["creating", "created", "failed"];
-    } else if (statusSelectorVal == "inactive") {
-      status = ["inactive"];
-    }
-
-    return api.getPRDeploymentList(
-      "<token>",
-      {
-        status: status,
-      },
-      {
-        project_id: currentProject.id,
-        cluster_id: currentCluster.id,
-      }
-    );
-  };
-
-  const checkGitRepoPermissions = async () => {
-    // Get all the connected repos ids
-    let gitRepos: number[] = null;
-    try {
-      gitRepos = await api
-        .getGitRepos("<token>", {}, { project_id: currentProject.id })
-        .then((res) => res.data);
-    } catch (error) {
-      console.error(error);
-    }
-
-    if (!gitRepos) {
-      return;
-    }
-
-    // Check if all repo has enough permissions
-    try {
-      const repoPermissionsRequests = gitRepos.map((id) =>
-        api
-          .getGitRepoPermission(
-            "<token>",
-            {},
-            { project_id: currentProject.id, git_repo_id: id }
-          )
-          .then((res) => res.data)
-      );
-
-      const permissions = await Promise.all(repoPermissionsRequests);
-      let hasPermission =
-        permissions.filter((val) => {
-          return val.preview_environments;
-        }).length >= 1;
-
-      setHasPermissions(hasPermission);
-      setHasPermissionsLoaded(true);
-    } catch (error) {
-      console.error(error);
-    }
-  };
-
-  useEffect(() => {
-    let isSubscribed = true;
-    api
-      .listEnvironments(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
-
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-        setEnvironmentList(data);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentProject, currentCluster, location.search]);
-
-  useEffect(() => {
-    setHasPermissionsLoaded(false);
-    checkGitRepoPermissions();
-  }, [currentProject, currentCluster]);
-
-  useEffect(() => {
-    let isSubscribed = true;
-    getPRDeploymentList()
-      .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
-
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-
-        setDeploymentList(data);
-        setIsLoading(false);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentCluster, currentProject, statusSelectorVal]);
-
-  useEffect(() => {
-    const action = getQueryParam({ location }, "action");
-    if (action === "connect-repo") {
-      setShowConnectRepoFlow(true);
-    } else {
-      setShowConnectRepoFlow(false);
-    }
-  }, [location.search, history]);
-
-  const handleRefresh = () => {
-    setIsLoading(true);
-    getPRDeploymentList()
-      .then(({ data }) => {
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-        setDeploymentList(data);
-      })
-      .catch((err) => {
-        setHasError(true);
-        console.error(err);
-      })
-      .finally(() => setIsLoading(false));
-  };
-
-  if (showConnectRepoFlow) {
-    return (
-      <Container>
-        <ConnectNewRepo />
-      </Container>
-    );
-  }
-
-  if (hasPermissionsLoaded && !hasPermissions) {
-    return (
-      <Placeholder>
-        Github App permissions are not up to date. Please review any pending
-        requests to update Github App permissions.
-      </Placeholder>
-    );
-  }
-
-  if (!hasPermissionsLoaded) {
-    return (
-      <Placeholder>
-        <Loading />
-      </Placeholder>
-    );
-  }
-
-  if (hasError) {
-    return <Placeholder>Error</Placeholder>;
-  }
-
-  if (!environmentList.length) {
-    return (
-      <Placeholder>
-        <Header>Preview environments are not enabled on this cluster</Header>
-        <Subheader>
-          In order to use preview environments, you must enable preview
-          environments on this cluster.
-        </Subheader>
-        <ButtonEnablePREnvironments />
-      </Placeholder>
-    );
-  }
-
-  let renderDeploymentList = () => {
-    if (isLoading) {
-      return (
-        <Placeholder>
-          <Loading />
-        </Placeholder>
-      );
-    }
-
-    if (!deploymentList.length) {
-      return (
-        <Placeholder>
-          No preview apps have been found. Open a PR to create a new preview
-          app.
-        </Placeholder>
-      );
-    }
-
-    return deploymentList.map((d) => {
-      const environment = environmentList?.find((e) => {
-        return e.id === d.environment_id;
-      });
-      return (
-        <EnvironmentCard
-          deployment={d}
-          environment={environment}
-          onDelete={handleRefresh}
-        />
-      );
-    });
-  };
-
-  return (
-    <Container>
-      <ControlRow>
-        <Button
-          to={`${currentUrl}?selected_tab=preview_environments&action=connect-repo`}
-          onClick={() => console.log("launch repo")}
-        >
-          <i className="material-icons">add</i> Add Repository
-        </Button>
-
-        <ActionsWrapper>
-          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
-            <i className="material-icons">refresh</i>
-          </RefreshButton>
-          <StyledStatusSelector>
-            <Selector
-              activeValue={statusSelectorVal}
-              setActiveValue={setStatusSelectorVal}
-              options={[
-                {
-                  value: "active",
-                  label: "Active",
-                },
-                {
-                  value: "inactive",
-                  label: "Inactive",
-                },
-              ]}
-              dropdownLabel="Status"
-              width="150px"
-              dropdownWidth="230px"
-              closeOverlay={true}
-            />
-          </StyledStatusSelector>
-
-          <SettingsButton
-            onClick={() => {
-              setCurrentModal("PreviewEnvSettingsModal", {});
-            }}
-          >
-            <i className="material-icons-outlined">settings</i>
-            Configure
-          </SettingsButton>
-        </ActionsWrapper>
-      </ControlRow>
-      <EventsGrid>{renderDeploymentList()}</EventsGrid>
-    </Container>
-  );
-};
-
-export default EnvironmentList;
-
-const ActionsWrapper = styled.div`
-  display: flex;
-`;
-
-const RefreshButton = styled.button`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: ${(props: { color: string }) => props.color};
-  cursor: pointer;
-  border: none;
-  background: none;
-  border-radius: 50%;
-  margin-right: 10px;
-  > i {
-    font-size: 20px;
-  }
-  :hover {
-    background-color: rgb(97 98 102 / 44%);
-    color: white;
-  }
-`;
-
-const SettingsButton = styled.div`
-  font-size: 12px;
-  padding: 8px 10px;
-  margin-left: 10px;
-  border-radius: 5px;
-  color: white;
-  display: flex;
-  align-items: center;
-  background: #ffffff08;
-  cursor: pointer;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: white;
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Container = styled.div`
-  margin-top: 33px;
-  padding-bottom: 120px;
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  margin-left: auto;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
-const Button = styled(DynamicLink)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 20px;
-  grid-template-columns: 1;
-`;
-
-const Label = styled.div`
-  display: flex;
-  align-items: center;
-  margin-right: 12px;
-
-  > i {
-    margin-right: 8px;
-    font-size: 18px;
-  }
-`;
-
-const StyledStatusSelector = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-`;
-
-const Header = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-  width: 50%;
-`;
-
-const Subheader = styled.div`
-  width: 50%;
-`;

+ 90 - 15
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -25,6 +25,8 @@ import { useChart } from "shared/hooks/useChart";
 import Modal from "main/home/modals/Modal";
 import ConnectToJobInstructionsModal from "./jobs/ConnectToJobInstructionsModal";
 import CommandLineIcon from "assets/command-line-icon";
+import CronParser from "cron-parser";
+import CronPrettifier from "cronstrue";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -131,10 +133,28 @@ export const ExpandedJobChartFC: React.FC<{
       );
     }
 
+    let interval = null;
+    if (chart?.config?.schedule.enabled) {
+      interval = CronParser.parseExpression(chart?.config?.schedule.value, {
+        currentDate: new Date(),
+      });
+    }
+    // @ts-ignore
+    const rtf = new Intl.DateTimeFormat("en", {
+      localeMatcher: "best fit", // other values: "lookup"
+      // @ts-ignore
+      dateStyle: "full",
+      timeStyle: "long",
+    });
+
     if (currentTab === "jobs") {
       return (
         <TabWrapper>
-          <ButtonWrapper>
+          <ButtonWrapper
+            style={{
+              marginBottom: chart?.config?.schedule?.enabled ? "0px" : "35px",
+            }}
+          >
             <SaveButton
               onClick={() => {
                 runJob();
@@ -158,20 +178,41 @@ export const ExpandedJobChartFC: React.FC<{
             </CLIModalIconWrapper>
           </ButtonWrapper>
 
+          {chart?.config?.schedule?.enabled ? (
+            <RunsDescription>
+              <i className="material-icons">access_time</i>
+              Runs{" "}
+              {CronPrettifier.toString(
+                chart?.config?.schedule.value
+              ).toLowerCase()}
+              <Dot
+                style={{
+                  color: "#ffffff88",
+                }}
+              >
+                •
+              </Dot>{" "}
+              Next run on
+              {" " + rtf.format(interval.next().toDate())}
+            </RunsDescription>
+          ) : null}
+
           {jobsStatus === "loading" ? (
             <Loading></Loading>
           ) : (
-            <JobList
-              jobs={jobs}
-              setJobs={() => {}}
-              expandJob={(job: any) => {
-                setSelectedJob(job);
-              }}
-              isDeployedFromGithub={!!chart?.git_action_config?.git_repo}
-              repositoryUrl={chart?.git_action_config?.git_repo}
-              currentChartVersion={Number(chart.version)}
-              latestChartVersion={Number(chart.latest_version)}
-            />
+            <>
+              <JobList
+                jobs={jobs}
+                setJobs={() => {}}
+                expandJob={(job: any) => {
+                  setSelectedJob(job);
+                }}
+                isDeployedFromGithub={!!chart?.git_action_config?.git_repo}
+                repositoryUrl={chart?.git_action_config?.git_repo}
+                currentChartVersion={Number(chart.version)}
+                latestChartVersion={Number(chart.latest_version)}
+              />
+            </>
           )}
         </TabWrapper>
       );
@@ -345,6 +386,9 @@ const ExpandedJobHeader: React.FC<{
         Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
       </TagWrapper>
     </TitleSection>
+    {chart?.config?.description ? (
+      <Description>{chart?.config?.description}</Description>
+    ) : null}
 
     <InfoWrapper>
       <LastDeployed>
@@ -377,6 +421,37 @@ const ExpandedJobHeader: React.FC<{
   </HeaderWrapper>
 );
 
+const RunsDescription = styled.div`
+  color: #ffffff;
+  font-size: 13px;
+  margin-top: 20px;
+  margin-bottom: 20px;
+  display: flex;
+  align-items: center;
+  padding: 14px 20px;
+  background: #2b2e36;
+  border: 1px solid #ffffff22;
+  color: #ffffffdd;
+  border-radius: 4px;
+  user-select: text;
+
+  > i {
+    font-size: 16px;
+    color: #ffffffdd;
+    margin-right: 10px;
+  }
+`;
+
+const Description = styled.div`
+  user-select: text;
+  font-size: 13px;
+  margin-left: 0;
+  display: flex;
+  align-items: center;
+  color: #ffffffdd;
+  line-height: 150%;
+`;
+
 const CLIModalIconWrapper = styled.div`
   height: 35px;
   font-size: 13px;
@@ -425,7 +500,7 @@ const LineBreak = styled.div`
 
 const ButtonWrapper = styled.div`
   display: flex;
-  margin: 5px 0 35px;
+  margin: 5px 0 0 0;
   justify-content: space-between;
 `;
 const BackButton = styled.div`
@@ -507,9 +582,9 @@ const Dot = styled.div`
 
 const InfoWrapper = styled.div`
   display: flex;
-  align-items: center;
+  flex-direction: column;
+  justify-content: center;
   margin: 24px 0px 17px 0px;
-  height: 20px;
 `;
 
 const LastDeployed = styled.div`

+ 197 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/PreviewEnvironmentsHome.tsx

@@ -0,0 +1,197 @@
+import Loading from "components/Loading";
+import TabSelector from "components/TabSelector";
+import React, { useContext, useEffect, useState } from "react";
+import { useHistory, useLocation } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
+import { PreviewEnvironmentsHeader } from "./components/PreviewEnvironmentsHeader";
+import DeploymentList from "./deployments/DeploymentList";
+import EnvironmentsList from "./environments/EnvironmentsList";
+import { environments } from "./mocks";
+
+const AvailableTabs = ["repositories", "pull_requests"];
+
+type TabEnum = typeof AvailableTabs[number];
+
+const PreviewEnvironmentsHome = () => {
+  const { currentCluster, currentProject } = useContext(Context);
+
+  const [isEnabled, setIsEnabled] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [environments, setEnvironments] = useState([]);
+
+  const [currentTab, setCurrentTab] = useState<TabEnum>("repositories");
+  const { getQueryParam, pushQueryParams } = useRouting();
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .listEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      )
+      // mockRequest()
+      .then(({ data }) => {
+        if (isSubscribed) {
+          setIsEnabled(true);
+        }
+
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+
+        setIsEnabled(!!data.length);
+        setEnvironments(data);
+      })
+
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setHasError(true);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  useEffect(() => {
+    const current_tab = getQueryParam("current_tab");
+
+    if (!AvailableTabs.includes(current_tab)) {
+      pushQueryParams({}, ["current_tab"]);
+      return;
+    }
+
+    if (current_tab !== currentTab) {
+      setCurrentTab(current_tab);
+    }
+  }, [location.search, history]);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (hasError) {
+    return <Placeholder>Something went wrong, please try again</Placeholder>;
+  }
+
+  if (!isEnabled) {
+    return (
+      <>
+        <PreviewEnvironmentsHeader />
+        <LineBreak />
+        <Placeholder>
+          <Title>Preview environments are not enabled on this cluster</Title>
+          <Subtitle>
+            In order to use preview environments, you must enable preview
+            environments on this cluster.
+          </Subtitle>
+          <ButtonEnablePREnvironments />
+        </Placeholder>
+      </>
+    );
+  }
+
+  const handleSetTab = (tab: TabEnum) => {
+    pushQueryParams({ current_tab: tab });
+    setCurrentTab(tab);
+  };
+
+  return (
+    <>
+      <PreviewEnvironmentsHeader />
+      <TabSelector
+        options={[
+          {
+            label: "Linked Repositories",
+            value: "repositories",
+            component: (
+              <EnvironmentsList
+                environments={environments}
+                setEnvironments={setEnvironments}
+              />
+            ),
+          },
+          {
+            label: "Pull requests",
+            value: "pull_requests",
+            component: <DeploymentList environments={environments} />,
+          },
+        ]}
+        currentTab={currentTab}
+        setCurrentTab={handleSetTab}
+      />
+    </>
+  );
+};
+
+export default PreviewEnvironmentsHome;
+
+const mockRequest = () =>
+  new Promise((res) => {
+    setTimeout(() => {
+      res({ data: environments });
+    }, 1000);
+  });
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const Title = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+  width: 50%;
+`;
+
+const Subtitle = styled.div`
+  width: 50%;
+`;

+ 58 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ActionButton.tsx

@@ -0,0 +1,58 @@
+import styled, { css, keyframes } from "styled-components";
+
+const Shake = keyframes`
+10%, 90% {
+  transform: translate3d(-0.5px, 0, 0);
+}
+
+20%, 80% {
+  transform: translate3d(1px, 0, 0);
+}
+
+30%, 50%, 70% {
+  transform: translate3d(-2px, 0, 0);
+}
+
+40%, 60% {
+  transform: translate3d(2px, 0, 0);
+}
+`;
+
+const ShakeAnimation = css`
+  animation: ${Shake} 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
+  transform: translate3d(0, 0, 0);
+  backface-visibility: hidden;
+  perspective: 1000px;
+`;
+
+export const ActionButton = styled.button`
+  font-size: 12px;
+  padding: 8px 10px;
+  margin-left: 10px;
+  border-radius: 5px;
+  color: #ffffff;
+  border: 1px solid
+    ${(props: { disabled: boolean; hasError: boolean }) =>
+      props.hasError ? "red" : "#aaaabb"};
+  display: flex;
+  align-items: center;
+  background: ${(props: { disabled: boolean; hasError: boolean }) =>
+    props.disabled ? "#ffffff22" : "#ffffff08"};
+  cursor: pointer;
+  min-height: 32px;
+  min-width: 220px;
+  :hover {
+    background: #ffffff22;
+  }
+
+  ${(props: { disabled: boolean; hasError: boolean }) => {
+    if (props.hasError) {
+      return ShakeAnimation;
+    }
+  }}
+
+  > i {
+    font-size: 14px;
+    margin-right: 8px;
+  }
+`;

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -69,8 +69,7 @@ const ButtonEnablePREnvironments = () => {
       };
     }
     return {
-      to:
-        "/cluster-dashboard?selected_tab=preview_environments&action=connect-repo",
+      to: "/preview-environments/connect-repo",
     };
   };
 

+ 26 - 8
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ConnectNewRepo.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/components/ConnectNewRepo.tsx

@@ -1,6 +1,5 @@
 import DynamicLink from "components/DynamicLink";
 import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
 import RepoList from "components/repo-selector/RepoList";
 import SaveButton from "components/SaveButton";
 import DocsHelper from "components/DocsHelper";
@@ -12,13 +11,18 @@ import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
-import { Environment } from "../EnvironmentList";
+import { Environment } from "../types";
+import { PreviewEnvironmentsHeader } from "./PreviewEnvironmentsHeader";
+import CheckboxRow from "components/form-components/CheckboxRow";
 
 const ConnectNewRepo: React.FC = () => {
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
   const [repo, setRepo] = useState(null);
+  const [enableAutomaticDeployments, setEnableAutomaticDeployments] = useState(
+    false
+  );
   const [filteredRepos, setFilteredRepos] = useState<string[]>([]);
 
   const [status, setStatus] = useState(null);
@@ -68,6 +72,7 @@ const ConnectNewRepo: React.FC = () => {
         "<token>",
         {
           name: "Preview",
+          mode: enableAutomaticDeployments ? "auto" : "manual",
         },
         {
           project_id: currentProject.id,
@@ -79,9 +84,7 @@ const ConnectNewRepo: React.FC = () => {
       )
       .then(() => {
         setStatus("successful");
-        pushFiltered(`${url}`, [], {
-          selected_tab: "preview_environments",
-        });
+        pushFiltered(`/preview-environments`, []);
       })
       .catch((err) => {
         err = JSON.stringify(err);
@@ -91,14 +94,22 @@ const ConnectNewRepo: React.FC = () => {
   };
 
   return (
-    <div>
+    <>
+      <PreviewEnvironmentsHeader />
+      <LineBreak />
       <ControlRow>
-        <BackButton to={`${url}?selected_tab=preview_environments`}>
+        <BackButton to={`/preview-environments`}>
           <i className="material-icons">close</i>
         </BackButton>
         <Title>Enable Preview Environments</Title>
       </ControlRow>
 
+      <CheckboxRow
+        label="Enable automatic deployments"
+        isRequired
+        checked={enableAutomaticDeployments}
+        toggle={() => setEnableAutomaticDeployments((prev) => !prev)}
+      />
       <Heading>Select a Repository</Heading>
       <br />
       <RepoList
@@ -130,12 +141,19 @@ const ConnectNewRepo: React.FC = () => {
           statusPosition={"left"}
         ></SaveButton>
       </ActionContainer>
-    </div>
+    </>
   );
 };
 
 export default ConnectNewRepo;
 
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
 const ControlRow = styled.div`
   display: flex;
   margin-left: auto;

+ 73 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -0,0 +1,73 @@
+import React from "react";
+import TitleSection from "components/TitleSection";
+import styled from "styled-components";
+
+export const PreviewEnvironmentsHeader = () => (
+  <>
+    <TitleSection>
+      <DashboardIcon>
+        <i className="material-icons">device_hub</i>
+      </DashboardIcon>
+      Preview environments
+    </TitleSection>
+    <InfoSection>
+      <TopRow>
+        <InfoLabel>
+          <i className="material-icons">info</i> Info
+        </InfoLabel>
+      </TopRow>
+      <Description>
+        Create preview environments for your pull requests
+      </Description>
+    </InfoSection>
+  </>
+);
+
+const DashboardIcon = styled.div`
+  height: 45px;
+  min-width: 45px;
+  width: 45px;
+  border-radius: 5px;
+  margin-right: 17px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #aaaabb;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7a838f;
+  font-size: 13px;
+  > i {
+    color: #8b949f;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 36px;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 0px;
+  margin-bottom: 35px;
+`;

+ 124 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx

@@ -0,0 +1,124 @@
+import Modal from "main/home/modals/Modal";
+import React from "react";
+import styled from "styled-components";
+
+type Props = {
+  hide: boolean;
+  isReEnable: boolean;
+  onClose: () => void;
+};
+
+const RecreateWorkflowFilesModal = (props: Props) => {
+  const createNewWorkflows = () => {};
+
+  if (props.hide) {
+    return null;
+  }
+
+  return (
+    <Modal title="Workflow files not found">
+      <div>
+        <div>
+          We couldn't find any workflow files to process the{" "}
+          {props.isReEnable
+            ? "re enabling of this preview environment"
+            : "creation of this preview environment"}
+          .
+          <HighlightText>
+            Do you want to create the workflow files? Or Remove the repository?
+          </HighlightText>
+          <Warning highlight>
+            ⚠️ If the workflow files don't exist, Porter will not be able to
+            create any preview environment for this repository.
+          </Warning>
+        </div>
+
+        <ActionWrapper>
+          <DeleteButton onClick={() => props.onClose()}>Close</DeleteButton>
+          <CancelButton onClick={() => createNewWorkflows()}>
+            Create new workflows
+          </CancelButton>
+        </ActionWrapper>
+      </div>
+    </Modal>
+  );
+};
+
+export default RecreateWorkflowFilesModal;
+
+const Button = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 10px;
+  color: white;
+  height: 35px;
+  padding: 10px 16px;
+  font-weight: 500;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: pointer;
+  border: none;
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+`;
+
+const DeleteButton = styled(Button)`
+  ${({ disabled }: { disabled?: boolean }) => {
+    if (disabled) {
+      return `
+      background: #aaaabbee;
+      :hover {
+        background: #aaaabbee;
+      }    
+      `;
+    }
+
+    return `
+      background: #dd4b4b;
+      :hover {
+        background: #b13d3d;
+      }`;
+  }}
+`;
+
+const CancelButton = styled(Button)`
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+`;
+
+const ActionWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  width: 100%;
+  margin-top: 10px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+`;
+
+const HighlightText = styled.div`
+  font-size: 16px;
+  font-weight: bold;
+  color: #ffffff;
+`;

+ 144 - 52
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -1,6 +1,6 @@
 import React, { useState } from "react";
-import styled, { keyframes } from "styled-components";
-import { Environment, PRDeployment } from "../EnvironmentList";
+import styled, { css, keyframes } from "styled-components";
+import { Environment, PRDeployment } from "../types";
 import pr_icon from "assets/pull_request_icon.svg";
 import { integrationList } from "shared/common";
 import { useRouteMatch } from "react-router";
@@ -9,15 +9,25 @@ import { capitalize, readableDate } from "shared/string_utils";
 import api from "shared/api";
 import { useContext } from "react";
 import { Context } from "shared/Context";
+import Loading from "components/Loading";
+import { ActionButton } from "../components/ActionButton";
 
-const EnvironmentCard: React.FC<{
+const DeploymentCard: React.FC<{
   deployment: PRDeployment;
-  environment: Environment;
   onDelete: () => void;
-}> = ({ deployment, environment, onDelete }) => {
-  const { setCurrentOverlay } = useContext(Context);
+  onReEnable: () => void;
+}> = ({ deployment, onDelete, onReEnable }) => {
+  const {
+    setCurrentOverlay,
+    currentProject,
+    currentCluster,
+    setCurrentError,
+  } = useContext(Context);
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isDeleting, setIsDeleting] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [hasErrorOnReEnabling, setHasErrorOnReEnabling] = useState(false);
+  const [showMergeInfoTooltip, setShowMergeInfoTooltip] = useState(false);
   const { url: currentUrl } = useRouteMatch();
 
   let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
@@ -28,15 +38,11 @@ const EnvironmentCard: React.FC<{
     api
       .deletePRDeployment(
         "<token>",
+        {},
         {
-          namespace: deployment.namespace,
-        },
-        {
-          cluster_id: environment.cluster_id,
-          project_id: environment.project_id,
-          git_installation_id: environment.git_installation_id,
-          git_repo_owner: environment.git_repo_owner,
-          git_repo_name: environment.git_repo_name,
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          deployment_id: deployment.id,
         }
       )
       .then(() => {
@@ -46,12 +52,61 @@ const EnvironmentCard: React.FC<{
       });
   };
 
+  const reEnablePreviewEnvironment = () => {
+    setIsLoading(true);
+
+    api
+      .reenablePreviewEnvironmentDeployment(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+          deployment_id: deployment.id,
+        }
+      )
+      .then(() => {
+        setIsLoading(false);
+        onReEnable();
+      })
+      .catch((err) => {
+        setHasErrorOnReEnabling(true);
+        setIsLoading(false);
+        setCurrentError(err);
+        setTimeout(() => {
+          setHasErrorOnReEnabling(false);
+        }, 500);
+      });
+  };
+
   return (
-    <EnvironmentCardWrapper key={deployment.id}>
+    <DeploymentCardWrapper>
       <DataContainer>
         <PRName>
           <PRIcon src={pr_icon} alt="pull request icon" />
-          {deployment.gh_pr_name}
+          <DynamicLink
+            to={`https://github.com/${deployment.gh_repo_owner}/${deployment.gh_repo_name}/pull/${deployment.pull_request_id}`}
+            target="_blank"
+          >
+            {deployment.gh_pr_name} #{deployment.pull_request_id}
+          </DynamicLink>
+          {deployment.gh_pr_branch_from && deployment.gh_pr_branch_into ? (
+            <MergeInfoWrapper>
+              <MergeInfo
+                onMouseOver={() => setShowMergeInfoTooltip(true)}
+                onMouseOut={() => setShowMergeInfoTooltip(false)}
+              >
+                From: {deployment.gh_pr_branch_from} Into:{" "}
+                {deployment.gh_pr_branch_into}
+              </MergeInfo>
+              {showMergeInfoTooltip && (
+                <Tooltip>
+                  From: {deployment.gh_pr_branch_from} Into:{" "}
+                  {deployment.gh_pr_branch_into}
+                </Tooltip>
+              )}
+            </MergeInfoWrapper>
+          ) : null}
         </PRName>
 
         <Flex>
@@ -85,39 +140,57 @@ const EnvironmentCard: React.FC<{
       <Flex>
         {!isDeleting ? (
           <>
-            {deployment.status !== "creating" && (
-              <>
-                <RowButton
-                  to={`${currentUrl}/pr-env-detail/${deployment.namespace}?environment_id=${deployment.environment_id}`}
-                  key={deployment.id}
-                >
-                  <i className="material-icons-outlined">info</i>
-                  Details
-                </RowButton>
-                <RowButton
-                  to={deployment.subdomain}
-                  key={deployment.subdomain}
-                  target="_blank"
-                >
-                  <i className="material-icons">open_in_new</i>
-                  View Live
-                </RowButton>
-              </>
+            {deployment.status !== "creating" &&
+              deployment.status !== "inactive" && (
+                <>
+                  <RowButton
+                    to={`/details/${deployment.namespace}?environment_id=${deployment.environment_id}`}
+                    key={deployment.id}
+                  >
+                    <i className="material-icons-outlined">info</i>
+                    Details
+                  </RowButton>
+                  <RowButton
+                    to={deployment.subdomain}
+                    key={deployment.subdomain}
+                    target="_blank"
+                  >
+                    <i className="material-icons">open_in_new</i>
+                    View Live
+                  </RowButton>
+                </>
+              )}
+            {deployment.status === "inactive" ? (
+              <ActionButton
+                onClick={reEnablePreviewEnvironment}
+                disabled={isLoading}
+                hasError={hasErrorOnReEnabling}
+              >
+                {isLoading ? (
+                  <Loading width="198px" height="14px" />
+                ) : (
+                  <>
+                    <i className="material-icons">play_arrow</i>
+                    Activate preview environment
+                  </>
+                )}
+              </ActionButton>
+            ) : (
+              <RowButton
+                to={"#"}
+                key={deployment.subdomain}
+                onClick={() =>
+                  setCurrentOverlay({
+                    message: `Are you sure you want to delete this deployment?`,
+                    onYes: deleteDeployment,
+                    onNo: () => setCurrentOverlay(null),
+                  })
+                }
+              >
+                <i className="material-icons">delete</i>
+                Delete
+              </RowButton>
             )}
-            <RowButton
-              to={"#"}
-              key={deployment.subdomain}
-              onClick={() =>
-                setCurrentOverlay({
-                  message: `Are you sure you want to delete this deployment?`,
-                  onYes: deleteDeployment,
-                  onNo: () => setCurrentOverlay(null),
-                })
-              }
-            >
-              <i className="material-icons">delete</i>
-              Delete
-            </RowButton>
           </>
         ) : (
           <DeleteMessage>
@@ -128,11 +201,11 @@ const EnvironmentCard: React.FC<{
           </DeleteMessage>
         )}
       </Flex>
-    </EnvironmentCardWrapper>
+    </DeploymentCardWrapper>
   );
 };
 
-export default EnvironmentCard;
+export default DeploymentCard;
 
 const DeleteMessage = styled.div`
   display: flex;
@@ -188,7 +261,7 @@ const PRName = styled.div`
   margin-bottom: 10px;
 `;
 
-const EnvironmentCardWrapper = styled.div`
+const DeploymentCardWrapper = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
@@ -264,7 +337,7 @@ const Status = styled.span`
 const StatusDot = styled.div`
   width: 8px;
   height: 8px;
-  margin-right: 15px;
+  margin-right: 10px;
   background: ${(props: { status: string }) =>
     props.status === "created"
       ? "#4797ff"
@@ -351,3 +424,22 @@ const LastDeployed = styled.div`
   align-items: center;
   color: #aaaabb66;
 `;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  margin-left: 14px;
+  margin-top: -1px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+`;

+ 6 - 15
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentDetail.tsx → dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -5,7 +5,7 @@ import TitleSection from "components/TitleSection";
 import pr_icon from "assets/pull_request_icon.svg";
 import { useRouteMatch, useLocation } from "react-router";
 import DynamicLink from "components/DynamicLink";
-import { PRDeployment, Environment } from "./EnvironmentList";
+import { PRDeployment } from "../types";
 import Loading from "components/Loading";
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -14,17 +14,13 @@ import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
 
-const EnvironmentDetail = () => {
+const DeploymentDetail = () => {
   const { params } = useRouteMatch<{ namespace: string }>();
   const context = useContext(Context);
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
-  const [hasError, setHasError] = useState(false);
-  const [isLoading, setIsLoading] = useState(false);
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
 
-  const { currentProject, currentCluster, setCurrentError } = useContext(
-    Context
-  );
+  const { currentProject, currentCluster } = useContext(Context);
 
   const { search } = useLocation();
   let searchParams = new URLSearchParams(search);
@@ -55,14 +51,8 @@ const EnvironmentDetail = () => {
       .catch((err) => {
         console.error(err);
         if (isSubscribed) {
-          setHasError(true);
           setPRDeployment(null);
         }
-      })
-      .finally(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
       });
   }, [params]);
 
@@ -137,7 +127,7 @@ const EnvironmentDetail = () => {
   );
 };
 
-export default EnvironmentDetail;
+export default DeploymentDetail;
 
 const Flex = styled.div`
   display: flex;
@@ -163,7 +153,8 @@ const GHALink = styled(DynamicLink)`
     margin-right: 9px;
     margin-left: 5px;
 
-    :text-decoration: none;
+    text-decoration: none;
+
     :hover {
       text-decoration: underline;
       color: white;

+ 435 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -0,0 +1,435 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Selector from "components/Selector";
+
+import Loading from "components/Loading";
+
+import _ from "lodash";
+import DeploymentCard from "./DeploymentCard";
+import { Environment, PRDeployment, PullRequest } from "../types";
+import { useRouting } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
+import { deployments, pull_requests } from "../mocks";
+import PullRequestCard from "./PullRequestCard";
+
+const AvailableStatusFilters = [
+  "all",
+  "creating",
+  "failed",
+  "active",
+  "inactive",
+  "not_deployed",
+];
+
+type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
+
+const DeploymentList = ({ environments }: { environments: Environment[] }) => {
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
+  const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
+
+  const [
+    statusSelectorVal,
+    setStatusSelectorVal,
+  ] = useState<AvailableStatusFiltersType>("all");
+  const [selectedRepo, setSelectedRepo] = useState("all");
+
+  const { currentProject, currentCluster } = useContext(Context);
+  const { getQueryParam, pushQueryParams } = useRouting();
+  const location = useLocation();
+  const history = useHistory();
+
+  const getPRDeploymentList = () => {
+    return api.getPRDeploymentList(
+      "<token>",
+      {},
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
+      }
+    );
+    // return mockRequest();
+  };
+
+  useEffect(() => {
+    const selected_repo = getQueryParam("repository");
+
+    const repo = environments.find(
+      (env) => `${env.git_repo_owner}/${env.git_repo_name}` === selected_repo
+    );
+
+    if (!repo) {
+      pushQueryParams({}, ["repository"]);
+      return;
+    }
+
+    if (selected_repo !== selectedRepo) {
+      setSelectedRepo(`${repo.git_repo_owner}/${repo.git_repo_name}`);
+    }
+  }, [location.search, history]);
+
+  useEffect(() => {
+    const status_filter = getQueryParam("status_filter");
+
+    if (!AvailableStatusFilters.includes(status_filter)) {
+      pushQueryParams({}, ["status_filter"]);
+      return;
+    }
+
+    if (status_filter !== statusSelectorVal) {
+      setStatusSelectorVal(status_filter);
+    }
+  }, [location.search, history]);
+
+  useEffect(() => {
+    pushQueryParams({}, ["status_filter", "repository"]);
+  }, []);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    getPRDeploymentList()
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setDeploymentList(data.deployments || []);
+        setPullRequests(data.pull_requests || []);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setHasError(true);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject, statusSelectorVal]);
+
+  const handleRefresh = () => {
+    setIsLoading(true);
+    getPRDeploymentList()
+      .then(({ data }) => {
+        setDeploymentList(data.deployments || []);
+        setPullRequests(data.pull_requests || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        console.error(err);
+      })
+      .finally(() => setIsLoading(false));
+  };
+
+  const handlePreviewEnvironmentManualCreation = (pullRequest: PullRequest) => {
+    setPullRequests((prev) => {
+      return prev.filter((pr) => {
+        return (
+          pr.pr_title === pullRequest.pr_title &&
+          `${pr.repo_owner}/${pr.repo_name}` ===
+            `${pullRequest.repo_owner}/${pullRequest.repo_name}`
+        );
+      });
+    });
+    handleRefresh();
+  };
+
+  const filteredDeployments = useMemo(() => {
+    if (statusSelectorVal === "not_deployed") {
+      return [];
+    }
+
+    if (statusSelectorVal === "all" && selectedRepo === "all") {
+      return deploymentList;
+    }
+
+    let tmpDeploymentList = [...deploymentList];
+
+    if (selectedRepo !== "all") {
+      tmpDeploymentList = tmpDeploymentList.filter((deployment) => {
+        return (
+          `${deployment.gh_repo_owner}/${deployment.gh_repo_name}` ===
+          selectedRepo
+        );
+      });
+    }
+
+    if (statusSelectorVal !== "all") {
+      tmpDeploymentList = tmpDeploymentList.filter((d) => {
+        return d.status === statusSelectorVal;
+      });
+    }
+
+    return tmpDeploymentList;
+  }, [selectedRepo, statusSelectorVal, deploymentList]);
+
+  const filteredPullRequests = useMemo(() => {
+    if (selectedRepo === "all") {
+      return pullRequests;
+    }
+
+    return pullRequests.filter((pr) => {
+      return `${pr.repo_owner}/${pr.repo_name}` === selectedRepo;
+    });
+  }, [selectedRepo, pullRequests]);
+
+  if (hasError) {
+    return <Placeholder>Error</Placeholder>;
+  }
+
+  const renderDeploymentList = () => {
+    if (isLoading) {
+      return (
+        <Placeholder>
+          <Loading />
+        </Placeholder>
+      );
+    }
+
+    if (!deploymentList.length && !pullRequests.length) {
+      return (
+        <Placeholder>
+          No preview apps have been found. Open a PR to create a new preview
+          app.
+        </Placeholder>
+      );
+    }
+
+    if (!filteredDeployments.length && !filteredPullRequests.length) {
+      return (
+        <Placeholder>
+          No preview apps have been found with the given filter.
+        </Placeholder>
+      );
+    }
+
+    return (
+      <>
+        {filteredPullRequests.map((pr) => {
+          return (
+            <PullRequestCard
+              key={pr.pr_title}
+              pullRequest={pr}
+              onCreation={handlePreviewEnvironmentManualCreation}
+            />
+          );
+        })}
+        {filteredDeployments.map((d) => {
+          return (
+            <DeploymentCard
+              key={d.id}
+              deployment={d}
+              onDelete={handleRefresh}
+              onReEnable={handleRefresh}
+            />
+          );
+        })}
+      </>
+    );
+  };
+
+  const repoOptions = environments.map((env) => ({
+    label: `${env.git_repo_owner}/${env.git_repo_name}`,
+    value: `${env.git_repo_owner}/${env.git_repo_name}`,
+  }));
+
+  const handleStatusFilterChange = (value: string) => {
+    pushQueryParams({ status_filter: value });
+    setStatusSelectorVal(value);
+  };
+
+  const handleRepoFilterChange = (value: string) => {
+    pushQueryParams({ repository: value });
+    setSelectedRepo(value);
+  };
+
+  return (
+    <Container>
+      <ControlRow>
+        <ActionsWrapper>
+          <StyledStatusSelector>
+            <Label>
+              <i className="material-icons">filter_alt</i>
+              Status
+            </Label>
+            <Selector
+              activeValue={statusSelectorVal}
+              setActiveValue={handleStatusFilterChange}
+              options={[
+                {
+                  value: "all",
+                  label: "All",
+                },
+                {
+                  value: "creating",
+                  label: "Creating",
+                },
+                {
+                  value: "failed",
+                  label: "Failed",
+                },
+                {
+                  value: "active",
+                  label: "Active",
+                },
+                {
+                  value: "inactive",
+                  label: "Inactive",
+                },
+                {
+                  value: "not_deployed",
+                  label: "Not deployed",
+                },
+              ]}
+              dropdownLabel="Status"
+              width="150px"
+              dropdownWidth="230px"
+              closeOverlay={true}
+            />
+          </StyledStatusSelector>
+          <StyledStatusSelector>
+            <Label>
+              <i className="material-icons">filter_alt</i>
+              Repository
+            </Label>
+            <Selector
+              activeValue={selectedRepo}
+              setActiveValue={handleRepoFilterChange}
+              options={[
+                {
+                  label: "All",
+                  value: "all",
+                },
+                ...repoOptions,
+              ]}
+              dropdownLabel="Repository"
+              width="200px"
+              dropdownWidth="300px"
+              closeOverlay
+            />
+          </StyledStatusSelector>
+
+          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
+            <i className="material-icons">refresh</i>
+          </RefreshButton>
+        </ActionsWrapper>
+      </ControlRow>
+      <EventsGrid>{renderDeploymentList()}</EventsGrid>
+    </Container>
+  );
+};
+
+export default DeploymentList;
+
+const mockRequest = () =>
+  new Promise((res) => {
+    setTimeout(
+      () =>
+        res({
+          data: { deployments: deployments, pull_requests: pull_requests },
+        }),
+      1000
+    );
+  });
+
+const ActionsWrapper = styled.div`
+  display: flex;
+`;
+
+const RefreshButton = styled.button`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: ${(props: { color: string }) => props.color};
+  cursor: pointer;
+  border: none;
+  background: none;
+  border-radius: 50%;
+  margin-left: 10px;
+  > i {
+    font-size: 20px;
+  }
+  :hover {
+    background-color: rgb(97 98 102 / 44%);
+    color: white;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const Container = styled.div`
+  margin-top: 33px;
+  padding-bottom: 120px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 20px;
+  grid-template-columns: 1;
+`;
+
+const StyledStatusSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  :not(:first-child) {
+    margin-left: 15px;
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+  width: 50%;
+`;
+
+const Subheader = styled.div`
+  width: 50%;
+`;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;

+ 282 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/PullRequestCard.tsx

@@ -0,0 +1,282 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+import pr_icon from "assets/pull_request_icon.svg";
+import { PullRequest } from "../types";
+import { integrationList } from "shared/common";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ActionButton } from "../components/ActionButton";
+import Loading from "components/Loading";
+import DynamicLink from "components/DynamicLink";
+import RecreateWorkflowFilesModal from "../components/RecreateWorkflowFilesModal";
+
+const PullRequestCard = ({
+  pullRequest,
+  onCreation,
+}: {
+  pullRequest: PullRequest;
+  onCreation: (pullRequest: PullRequest) => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [showMergeInfoTooltip, setShowMergeInfoTooltip] = useState(false);
+  const [
+    openRecreateWorkflowFilesModal,
+    setOpenRecreateWorkflowFilesModal,
+  ] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [hasError, setHasError] = useState(false);
+
+  const repository = `${pullRequest.repo_owner}/${pullRequest.repo_name}`;
+
+  const createPreviewEnvironment = async () => {
+    setIsLoading(true);
+    try {
+      await api.createPreviewEnvironmentDeployment("<token>", pullRequest, {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+      });
+      onCreation(pullRequest);
+    } catch (error) {
+      debugger;
+      setCurrentError(error);
+      setHasError(true);
+      setTimeout(() => {
+        setHasError(false);
+      }, 500);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return (
+    <>
+      <RecreateWorkflowFilesModal
+        hide={!openRecreateWorkflowFilesModal}
+        onClose={() => setOpenRecreateWorkflowFilesModal(false)}
+        isReEnable={false}
+      />
+      <DeploymentCardWrapper>
+        <DataContainer>
+          <PRName>
+            <PRIcon src={pr_icon} alt="pull request icon" />
+            <DynamicLink
+              to={`https://github.com/${pullRequest.repo_owner}/${pullRequest.repo_name}/pull/${pullRequest.pr_number}`}
+              target="_blank"
+            >
+              {pullRequest.pr_title}
+            </DynamicLink>
+
+            <InfoWrapper>
+              <MergeInfo
+                onMouseOver={() => setShowMergeInfoTooltip(true)}
+                onMouseOut={() => setShowMergeInfoTooltip(false)}
+              >
+                From: {pullRequest.branch_from} Into: {pullRequest.branch_into}
+              </MergeInfo>
+              {showMergeInfoTooltip && (
+                <Tooltip>
+                  From: {pullRequest.branch_from} Into:{" "}
+                  {pullRequest.branch_into}
+                </Tooltip>
+              )}
+            </InfoWrapper>
+          </PRName>
+
+          <Flex>
+            <StatusContainer>
+              <Status>
+                <StatusDot />
+                Not deployed
+              </Status>
+            </StatusContainer>
+            <DeploymentImageContainer>
+              <DeploymentTypeIcon src={integrationList.repo.icon} />
+              <RepositoryName
+                onMouseOver={() => {
+                  setShowRepoTooltip(true);
+                }}
+                onMouseOut={() => {
+                  setShowRepoTooltip(false);
+                }}
+              >
+                {repository}
+              </RepositoryName>
+              {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+            </DeploymentImageContainer>
+          </Flex>
+        </DataContainer>
+        <Flex>
+          <ActionButton
+            onClick={createPreviewEnvironment}
+            disabled={isLoading}
+            hasError={hasError}
+          >
+            {isLoading ? (
+              <Loading width="198px" height="14px" />
+            ) : (
+              <>
+                <i className="material-icons">add</i>
+                Create Preview environment
+              </>
+            )}
+          </ActionButton>
+        </Flex>
+      </DeploymentCardWrapper>
+    </>
+  );
+};
+
+export default PullRequestCard;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const DeploymentCardWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff44;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  height: 80px;
+  font-size: 13px;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  height: 100%;
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 17px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  margin-right: 10px;
+  background: #ffffff88;
+  border-radius: 20px;
+  margin-left: 3px;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  margin-left: 15px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 5px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;
+
+const RepositoryName = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 390px;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 14px;
+  top: 20px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  margin-left: 14px;
+  margin-top: -1px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+`;

+ 297 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -0,0 +1,297 @@
+import React, {
+  FormEvent,
+  FormEventHandler,
+  useContext,
+  useState,
+} from "react";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { Environment } from "../types";
+import Options from "components/OptionsDropdown";
+import { useRouting } from "shared/routing";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import Modal from "main/home/modals/Modal";
+import InputRow from "components/form-components/InputRow";
+import DynamicLink from "components/DynamicLink";
+
+type Props = {
+  environment: Environment;
+  onDelete: (env: Environment) => void;
+};
+
+const EnvironmentCard = ({ environment, onDelete }: Props) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+
+  const [showDeleteModal, setShowDeleteModal] = useState(false);
+  const [deleteConfirmationRepoName, setDeleteConfirmationRepoName] = useState(
+    ""
+  );
+
+  const {
+    id,
+    name,
+    deployment_count,
+    git_repo_owner,
+    git_repo_name,
+    git_installation_id,
+    last_deployment_status,
+  } = environment;
+
+  const showOpenPrs = () => {
+    pushFiltered("/preview-environments", [], {
+      current_tab: "pull_requests",
+      repository: `${git_repo_owner}/${git_repo_name}`,
+    });
+  };
+
+  const handleDelete = () => {
+    if (!canDelete()) {
+      return;
+    }
+    api
+      .deleteEnvironment(
+        "<token>",
+        {
+          name: name,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: git_installation_id,
+          git_repo_owner: git_repo_owner,
+          git_repo_name: git_repo_name,
+        }
+      )
+      .then(() => {
+        onDelete(environment);
+        closeForm();
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+      });
+  };
+
+  const closeForm = () => {
+    setShowDeleteModal(false);
+    setDeleteConfirmationRepoName("");
+  };
+
+  const canDelete = () => {
+    const repoName = deleteConfirmationRepoName;
+    return repoName === `${git_repo_owner}/${git_repo_name}`;
+  };
+
+  return (
+    <>
+      {showDeleteModal ? (
+        <Modal
+          title={`Are you sure you wanna remove preview environments for ${git_repo_owner}/${git_repo_name}`}
+          width="800px"
+          height="260px"
+          onRequestClose={closeForm}
+        >
+          <Warning highlight>
+            ⚠️ Removing this repository from preview environments will delete
+            all the deployments associated. Meaning you will not be able to
+            access those preview environments no more.
+          </Warning>
+          <InputRow
+            type="text"
+            label="Please write down the repo name before proceeding"
+            value={deleteConfirmationRepoName}
+            setValue={(x: string) => setDeleteConfirmationRepoName(x)}
+            width={"300px"}
+          />
+          <ActionWrapper>
+            <CancelButton onClick={closeForm}>Cancel</CancelButton>
+            <DeleteButton
+              onClick={() => handleDelete()}
+              disabled={!canDelete()}
+            >
+              Delete
+            </DeleteButton>
+          </ActionWrapper>
+        </Modal>
+      ) : null}
+      <EnvironmentCardWrapper>
+        <DataContainer>
+          <RepoName>
+            <Icon
+              src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png"
+              alt="git repository icon"
+            />
+            <DynamicLink
+              to={`https://github.com/${git_repo_owner}/${git_repo_name}`}
+              target="_blank"
+            >
+              {git_repo_owner}/{git_repo_name}
+            </DynamicLink>
+          </RepoName>
+          <Status>
+            <StatusDot status={last_deployment_status} />
+            {capitalize(last_deployment_status || "")}
+
+            <Dot>•</Dot>
+            <span>
+              Pull {deployment_count > 1 ? "requests" : "request"} deployed:{" "}
+              {deployment_count}
+            </span>
+          </Status>
+        </DataContainer>
+        <Options.Dropdown expandIcon="more_vert" shrinkIcon="more_vert">
+          <Options.Option onClick={showOpenPrs}>View opened PRs</Options.Option>
+          <Options.Option onClick={() => setShowDeleteModal(true)}>
+            Delete
+          </Options.Option>
+        </Options.Dropdown>
+      </EnvironmentCardWrapper>
+    </>
+  );
+};
+
+export default EnvironmentCard;
+
+const EnvironmentCardWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff44;
+  background: #ffffff08;
+  border-radius: 10px;
+  padding: 14px;
+  min-height: 80px;
+  font-size: 13px;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+const RepoName = styled.div`
+  display: flex;
+  font-size: 16px;
+  align-items: center;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  min-height: 17px;
+  color: #a7a6bb;
+  margin-top: 10px;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  margin-right: 15px;
+  background: ${(props: { status: string }) =>
+    props.status === "created"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+`;
+
+const Icon = styled.img`
+  width: 20px;
+  height: 20px;
+  margin-right: 5px;
+`;
+
+const Button = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 10px;
+  color: white;
+  height: 35px;
+  padding: 10px 16px;
+  font-weight: 500;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: pointer;
+  border: none;
+  :not(:last-child) {
+    margin-right: 10px;
+  }
+`;
+
+const DeleteButton = styled(Button)`
+  ${({ disabled }: { disabled: boolean }) => {
+    if (disabled) {
+      return `
+      background: #aaaabbee;
+      :hover {
+        background: #aaaabbee;
+      }    
+      `;
+    }
+
+    return `
+      background: #dd4b4b;
+      :hover {
+        background: #b13d3d;
+      }`;
+  }}
+`;
+
+const CancelButton = styled(Button)`
+  background: #616feecc;
+  :hover {
+    background: #505edddd;
+  }
+`;
+
+const ActionWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  border-radius: 3px;
+  width: calc(100%);
+  margin-top: 10px;
+  margin-left: 2px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+  margin-left: 9px;
+`;

+ 105 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -0,0 +1,105 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { deployments, environments } from "../mocks";
+import { Environment } from "../types";
+import EnvironmentCard from "./EnvironmentCard";
+
+type Props = {
+  environments: Environment[];
+  setEnvironments: (
+    setFunction: (prev: Environment[]) => Environment[]
+  ) => void;
+};
+
+const EnvironmentsList = ({ environments, setEnvironments }: Props) => {
+  const removeEnvironmentFromList = (deletedEnv: Environment) => {
+    setEnvironments((prev) => {
+      return prev.filter((env) => env.id !== deletedEnv.id);
+    });
+  };
+
+  return (
+    <>
+      <ControlRow>
+        <Button to={`/preview-environments/connect-repo`}>
+          <i className="material-icons">add</i> Add Repository
+        </Button>
+      </ControlRow>
+      <EnvironmentsGrid>
+        {environments.map((env) => (
+          <EnvironmentCard
+            key={env.id}
+            environment={env}
+            onDelete={removeEnvironmentFromList}
+          />
+        ))}
+      </EnvironmentsGrid>
+    </>
+  );
+};
+
+export default EnvironmentsList;
+
+const EnvironmentsGrid = styled.div`
+  margin-top: 32px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-row-gap: 25px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin: 35px 0;
+  padding-left: 0px;
+`;
+
+const Button = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 163 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/mocks.ts

@@ -0,0 +1,163 @@
+export const environments = [
+  {
+    id: 29,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "porter-docs",
+    name: "Preview",
+  },
+  {
+    id: 36,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "angular-todo-app",
+    name: "Preview",
+  },
+  {
+    id: 37,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "porter-docs",
+    name: "Preview",
+  },
+  {
+    id: 38,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "porter-docs",
+    name: "Preview",
+  },
+  {
+    id: 39,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 21704327,
+    git_repo_owner: "jnfrati",
+    git_repo_name: "multi-tenant-blog",
+    name: "Preview",
+  },
+  {
+    id: 40,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 18424822,
+    git_repo_owner: "sunguroku",
+    git_repo_name: "node",
+    name: "Preview",
+  },
+  {
+    id: 41,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 18424822,
+    git_repo_owner: "sunguroku",
+    git_repo_name: "code-server",
+    name: "Preview",
+  },
+  {
+    id: 42,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "preview-env",
+    name: "Preview",
+  },
+  {
+    id: 43,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "preview",
+    name: "Preview",
+  },
+  {
+    id: 44,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "preview-env-test",
+    name: "Preview",
+  },
+  {
+    id: 45,
+    project_id: 3,
+    cluster_id: 34,
+    git_installation_id: 22158312,
+    git_repo_owner: "porter-dev",
+    git_repo_name: "ptrtr",
+    name: "Preview",
+  },
+];
+
+export const deployments = [
+  {
+    gh_deployment_id: 534980099,
+    gh_pr_name: "Update porter.yaml",
+    gh_repo_name: "preview",
+    gh_repo_owner: "porter-dev",
+    gh_commit_sha: "74a1191",
+    id: 43,
+    created_at: "2022-03-28T19:28:11.012729Z",
+    updated_at: "2022-03-28T19:31:53.871666Z",
+    git_installation_id: 0,
+    environment_id: 43,
+    namespace: "pr-3-preview",
+    status: "failed",
+    subdomain: "",
+    pull_request_id: 3,
+  },
+  {
+    gh_deployment_id: 532608734,
+    gh_pr_name: "Testing pr preview",
+    gh_repo_name: "porter-docs",
+    gh_repo_owner: "jnfrati",
+    gh_commit_sha: "6a4b67e",
+    id: 41,
+    created_at: "2022-03-24T20:24:17.103471Z",
+    updated_at: "2022-03-24T20:45:06.684096Z",
+    git_installation_id: 0,
+    environment_id: 37,
+    namespace: "pr-1-porter-docs",
+    status: "inactive",
+    subdomain: "https://docs-web-7b93751b98e68139.staging-onporter.run",
+    pull_request_id: 1,
+  },
+  {
+    gh_deployment_id: 514002155,
+    gh_pr_name: "Testing PR with job run",
+    gh_repo_name: "porter-docs",
+    gh_repo_owner: "porter-dev",
+    gh_commit_sha: "443d930",
+    id: 32,
+    created_at: "2022-01-30T11:04:14.496147Z",
+    updated_at: "2022-02-24T22:02:27.17928Z",
+    git_installation_id: 0,
+    environment_id: 29,
+    namespace: "pr-20-porter-docs",
+    status: "created",
+    subdomain: "https://docs-web-78a048205ac7869b.staging-onporter.run",
+    pull_request_id: 20,
+  },
+];
+
+export const pull_requests = [
+  {
+    pr_title: "Testing PR with job run",
+    pr_number: 1,
+    repo_owner: "porter-docs",
+    repo_name: "porter-dev",
+    branch_from: "some_branch",
+    branch_into: "main",
+  },
+];

+ 33 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/routes.tsx

@@ -0,0 +1,33 @@
+import React, { useContext } from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import { Context } from "shared/Context";
+import ConnectNewRepo from "./components/ConnectNewRepo";
+import DeploymentDetail from "./deployments/DeploymentDetail";
+import PreviewEnvironmentsHome from "./PreviewEnvironmentsHome";
+
+export const Routes = () => {
+  const { url } = useRouteMatch();
+  const { currentProject } = useContext(Context);
+
+  // if (!currentProject?.preview_envs_enabled) {
+  //   return <Redirect to={`/`} />;
+  // }
+
+  return (
+    <>
+      <Switch>
+        <Route path={`${url}/connect-repo`}>
+          <ConnectNewRepo />
+        </Route>
+        <Route path={`${url}/details/:namespace`}>
+          <DeploymentDetail />
+        </Route>
+        <Route path={`${url}/:selected_tab?`}>
+          <PreviewEnvironmentsHome />
+        </Route>
+      </Switch>
+    </>
+  );
+};
+
+export default Routes;

+ 38 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/types.ts

@@ -0,0 +1,38 @@
+export type PRDeployment = {
+  id: number;
+  created_at: string;
+  updated_at: string;
+  subdomain: string;
+  status: "creating" | "failed" | "created" | "inactive";
+  environment_id: number;
+  pull_request_id: number;
+  namespace: string;
+  gh_pr_name: string;
+  gh_repo_owner: string;
+  gh_repo_name: string;
+  gh_commit_sha: string;
+  gh_pr_branch_from?: string;
+  gh_pr_branch_into?: string;
+};
+
+export type Environment = {
+  id: number;
+  project_id: number;
+  cluster_id: number;
+  git_installation_id: number;
+  name: string;
+  git_repo_owner: string;
+  git_repo_name: string;
+  last_deployment_status: "failed" | "created" | "inactive" | "disabled";
+  deployment_count: number;
+  mode: "manual" | "auto";
+};
+
+export type PullRequest = {
+  pr_title: string;
+  pr_number: number;
+  repo_owner: string;
+  repo_name: string;
+  branch_from: string;
+  branch_into: string;
+};

+ 15 - 0
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -7,6 +7,7 @@ import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
 import sliders from "assets/sliders.svg";
+import CodeBranchIcon from "assets/code-branch-icon";
 
 import { Context } from "shared/Context";
 
@@ -172,6 +173,10 @@ class Sidebar extends Component<PropsType, StateType> {
                 Databases
               </NavButton>
             )}
+          <NavButton to="/preview-environments">
+            <StyledCodeBranchIcon />
+            Preview environments
+          </NavButton>
         </>
       );
     }
@@ -353,6 +358,16 @@ const Img = styled.img<{ enlarge?: boolean }>`
   margin-right: 10px;
 `;
 
+const StyledCodeBranchIcon = styled(CodeBranchIcon)`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+
+  > path {
+    fill: #ffffff99;
+  }
+`;
+
 const SidebarBg = styled.div`
   position: absolute;
   top: 0;

+ 50 - 26
dashboard/src/shared/api.tsx

@@ -1,3 +1,4 @@
+import { PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
 import { release } from "process";
 import { baseApi } from "./baseApi";
 
@@ -88,6 +89,7 @@ const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
 const createEnvironment = baseApi<
   {
     name: string;
+    mode: "auto" | "manual";
   },
   {
     project_id: number;
@@ -129,6 +131,28 @@ const deleteEnvironment = baseApi<
   return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/environment`;
 });
 
+const createPreviewEnvironmentDeployment = baseApi<
+  PullRequest,
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/deployments/pull_request`
+);
+
+const reenablePreviewEnvironmentDeployment = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    deployment_id: number;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, deployment_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}/reenable`
+);
+
 const listEnvironments = baseApi<
   {},
   {
@@ -339,25 +363,15 @@ const getPRDeployment = baseApi<
 });
 
 const deletePRDeployment = baseApi<
-  {
-    namespace: string;
-  },
+  {},
   {
     cluster_id: number;
     project_id: number;
-    git_installation_id: number;
-    git_repo_owner: string;
-    git_repo_name: string;
+    deployment_id: number;
   }
 >("DELETE", (pathParams) => {
-  const {
-    cluster_id,
-    project_id,
-    git_installation_id,
-    git_repo_owner,
-    git_repo_name,
-  } = pathParams;
-  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
+  const { cluster_id, project_id, deployment_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}`;
 });
 
 const getNotificationConfig = baseApi<
@@ -445,9 +459,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -463,9 +479,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -481,9 +499,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -1215,9 +1235,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<
@@ -1680,6 +1702,8 @@ export default {
   createEmailVerification,
   createEnvironment,
   deleteEnvironment,
+  createPreviewEnvironmentDeployment,
+  reenablePreviewEnvironmentDeployment,
   listEnvironments,
   createGCPIntegration,
   createInvite,

+ 0 - 6
dashboard/src/shared/baseApi.ts

@@ -25,15 +25,10 @@ const buildAxiosConfig: BuildAxiosConfigFunction = (
     url: typeof endpoint === "function" ? endpoint(pathParams) : endpoint,
   };
 
-  const AuthHeaders = {
-    Authorization: `Bearer ${token}`,
-  };
-
   if (method.toUpperCase() === "POST") {
     return {
       ...config,
       data: params,
-      headers: AuthHeaders,
     };
   }
 
@@ -41,7 +36,6 @@ const buildAxiosConfig: BuildAxiosConfigFunction = (
     return {
       ...config,
       data: params,
-      headers: AuthHeaders,
     };
   }
 

+ 0 - 38
dashboard/src/shared/baseApi.tsx

@@ -1,38 +0,0 @@
-import axios from "axios";
-import qs from "qs";
-
-// axios.defaults.timeout = 10000;
-
-// Partial function that accepts a generic params type and returns an api method
-export const baseApi = <T extends {}, S = {}>(
-  requestType: string,
-  endpoint: ((pathParams: S) => string) | string
-) => {
-  return (token: string, params: T, pathParams: S) => {
-    // Generate endpoint literal
-    let endpointString: ((pathParams: S) => string) | string;
-    if (typeof endpoint === "string") {
-      endpointString = endpoint;
-    } else {
-      endpointString = endpoint(pathParams);
-    }
-
-    // Handle request type (can refactor)
-    if (requestType === "POST") {
-      return axios.post(endpointString, params, {});
-    } else if (requestType === "PUT") {
-      return axios.put(endpointString, params, {});
-    } else if (requestType === "DELETE") {
-      return axios.delete(
-        endpointString + "?" + qs.stringify(params, { arrayFormat: "repeat" })
-      );
-    } else {
-      return axios.get(endpointString, {
-        params,
-        paramsSerializer: function (params) {
-          return qs.stringify(params, { arrayFormat: "repeat" });
-        },
-      });
-    }
-  };
-};

+ 3 - 1
dashboard/src/shared/routing.tsx

@@ -12,7 +12,8 @@ export type PorterUrl =
   | "env-groups"
   | "jobs"
   | "onboarding"
-  | "databases";
+  | "databases"
+  | "preview-environments";
 
 export const PorterUrls = [
   "dashboard",
@@ -27,6 +28,7 @@ export const PorterUrls = [
   "jobs",
   "onboarding",
   "databases",
+  "preview-environments",
 ];
 
 // TODO: consolidate with pushFiltered

+ 4 - 0
dashboard/src/shared/types.tsx

@@ -109,6 +109,10 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
     showStartCommand: boolean;
     statefulset: { enabled: boolean };
     terminationGracePeriodSeconds: number;
+    schedule: {
+      enabled: boolean;
+      value: string;
+    };
   };
 }
 

+ 20 - 14
go.mod

@@ -8,11 +8,11 @@ require (
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation/v2 v2.0.3
-	github.com/buildpacks/pack v0.19.0
+	github.com/buildpacks/pack v0.24.1
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/digitalocean/godo v1.75.0
-	github.com/docker/cli v20.10.11+incompatible
+	github.com/docker/cli v20.10.12+incompatible
 	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v20.10.12+incompatible
 	github.com/docker/docker-credential-helpers v0.6.4
@@ -36,7 +36,7 @@ require (
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/mitchellh/mapstructure v1.4.3
 	github.com/moby/moby v20.10.6+incompatible
-	github.com/moby/term v0.0.0-20210610120745-9d4ed1856297
+	github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
 	github.com/opencontainers/image-spec v1.0.2
 	github.com/pkg/errors v0.9.1
 	github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb
@@ -57,7 +57,7 @@ require (
 	gorm.io/gorm v1.22.3
 	helm.sh/helm/v3 v3.8.0
 	k8s.io/api v0.23.1
-	k8s.io/apimachinery v0.23.1
+	k8s.io/apimachinery v0.23.5
 	k8s.io/cli-runtime v0.23.1
 	k8s.io/client-go v0.23.1
 	k8s.io/helm v2.17.0+incompatible
@@ -81,7 +81,7 @@ require (
 	github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
 	github.com/Azure/go-autorest/logger v0.2.1 // indirect
 	github.com/Azure/go-autorest/tracing v0.6.0 // indirect
-	github.com/BurntSushi/toml v0.4.1 // indirect
+	github.com/BurntSushi/toml v1.0.0 // indirect
 	github.com/MakeNowJust/heredoc v1.0.0 // indirect
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver v1.5.0 // indirect
@@ -95,8 +95,8 @@ require (
 	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bits-and-blooms/bitset v1.2.0 // indirect
-	github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 // indirect
-	github.com/buildpacks/lifecycle v0.11.3 // indirect
+	github.com/buildpacks/imgutil v0.0.0-20211203200417-76206845baac // indirect
+	github.com/buildpacks/lifecycle v0.13.3 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
 	github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 // indirect
@@ -105,18 +105,21 @@ require (
 	github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
 	github.com/containerd/cgroups v1.0.2 // indirect
 	github.com/containerd/containerd v1.5.9 // indirect
-	github.com/containerd/stargz-snapshotter/estargz v0.4.1 // indirect
+	github.com/containerd/stargz-snapshotter/estargz v0.10.1 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/docker/go-units v0.4.0 // indirect
+	github.com/dustin/go-humanize v1.0.0 // indirect
 	github.com/emirpasic/gods v1.12.0 // indirect
 	github.com/envoyproxy/go-control-plane v0.10.1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
 	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
+	github.com/gdamore/encoding v1.0.0 // indirect
+	github.com/gdamore/tcell/v2 v2.4.0 // indirect
 	github.com/ghodss/yaml v1.0.0 // indirect
 	github.com/go-errors/errors v1.0.1 // indirect
 	github.com/go-logr/logr v1.2.0 // indirect
@@ -130,8 +133,8 @@ require (
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/btree v1.0.1 // indirect
-	github.com/google/go-cmp v0.5.6 // indirect
-	github.com/google/go-containerregistry v0.5.1 // indirect
+	github.com/google/go-cmp v0.5.7 // indirect
+	github.com/google/go-containerregistry v0.8.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.1.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
@@ -172,6 +175,7 @@ require (
 	github.com/leodido/go-urn v1.2.0 // indirect
 	github.com/lib/pq v1.10.4 // indirect
 	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mailru/easyjson v0.7.6 // indirect
 	github.com/mattn/go-colorable v0.1.12 // indirect
@@ -194,7 +198,7 @@ require (
 	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
 	github.com/morikuni/aec v1.0.0 // indirect
 	github.com/onsi/ginkgo v1.16.4 // indirect
-	github.com/onsi/gomega v1.16.0 // indirect
+	github.com/onsi/gomega v1.18.1 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/runc v1.0.2 // indirect
 	github.com/opencontainers/selinux v1.8.2 // indirect
@@ -205,6 +209,7 @@ require (
 	github.com/prometheus/client_model v0.2.0 // indirect
 	github.com/prometheus/common v0.28.0 // indirect
 	github.com/prometheus/procfs v0.6.0 // indirect
+	github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc // indirect
 	github.com/russross/blackfriday v1.5.2 // indirect
@@ -219,6 +224,7 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/src-d/gcfg v1.4.0 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
+	github.com/vbatts/tar-split v0.11.2 // indirect
 	github.com/xanzy/ssh-agent v0.3.0 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
@@ -227,7 +233,7 @@ require (
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
 	go.opencensus.io v0.23.0 // indirect
 	go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
-	golang.org/x/mod v0.5.0 // indirect
+	golang.org/x/mod v0.5.1 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
 	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
@@ -246,10 +252,10 @@ require (
 	k8s.io/component-base v0.23.1 // indirect
 	k8s.io/klog/v2 v2.30.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect
-	k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b // indirect
+	k8s.io/utils v0.0.0-20211116205334-6203023598ed // indirect
 	oras.land/oras-go v1.1.0 // indirect
 	sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect
 	sigs.k8s.io/kustomize/api v0.10.1 // indirect
 	sigs.k8s.io/kustomize/kyaml v0.13.0 // indirect
-	sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect
+	sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect
 )

+ 66 - 0
go.sum

@@ -91,6 +91,8 @@ github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBp
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
 github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
+github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
 github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
@@ -231,10 +233,18 @@ github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXe
 github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
 github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 h1:SzI5Uwnus3g/HQCFri+svWNiht4y8+jE2+QR8kzLPps=
 github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918/go.mod h1:ZQdcfsoyeqJvSdnUcCiS3Njhj0SZgBllJBnx5ojmgaQ=
+github.com/buildpacks/imgutil v0.0.0-20211203200417-76206845baac h1:XrKr6axRUBHEQdyyo7uffYDwWurOdeyH8MpNRJuBdIw=
+github.com/buildpacks/imgutil v0.0.0-20211203200417-76206845baac/go.mod h1:YZReWjuSxwyvuN92Vlcul+WgaCXylpecgFn7T3rNang=
 github.com/buildpacks/lifecycle v0.11.3 h1:FyvtzNxNjnBAdujzUiSpiCap3x+NzrqokGj69PiYvGk=
 github.com/buildpacks/lifecycle v0.11.3/go.mod h1:4anPUHYqREC3oh3qqKZwt7wqWR866E7BvtIxRE8xGLE=
+github.com/buildpacks/lifecycle v0.13.3 h1:vV2DGTPVQOELtrCSYpop8W9OF0m+l5gwxWDPmL9ZcOw=
+github.com/buildpacks/lifecycle v0.13.3/go.mod h1:4Kv6HljeDJ1ibUcRijvvC1/AHXMCpNddIqH2KYnboks=
 github.com/buildpacks/pack v0.19.0 h1:somWkTDEkR7LW0ZSGnO4WQw7Y3qTqqErzz57MlJPgRg=
 github.com/buildpacks/pack v0.19.0/go.mod h1:ITfkOnEmfIQW3TEXvze9sdE0Jk+AzQviQX022/EBj4o=
+github.com/buildpacks/pack v0.24.0 h1:Oeq7DImb7PLX5z/11h5kWJC/YZtgCAxJiEBTU/XsnNo=
+github.com/buildpacks/pack v0.24.0/go.mod h1:3BMdtlXEXTHUGAv31eeuPAebXq+JYZhFrAd7tEi6m0g=
+github.com/buildpacks/pack v0.24.1 h1:CkrdFCWCk/I71E3noNmKtcPha1s+1F9j8ykhbxHLV04=
+github.com/buildpacks/pack v0.24.1/go.mod h1:3BMdtlXEXTHUGAv31eeuPAebXq+JYZhFrAd7tEi6m0g=
 github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
@@ -324,6 +334,7 @@ github.com/containerd/containerd v1.5.0-beta.4/go.mod h1:GmdgZd2zA2GYIBZ0w09Zvgq
 github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoTJseu1FGOKuoA4nNb2s=
 github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g=
 github.com/containerd/containerd v1.5.7/go.mod h1:gyvv6+ugqY25TiXxcZC3L5yOeYgEw0QMhscqVp1AR9c=
+github.com/containerd/containerd v1.5.8/go.mod h1:YdFSv5bTFLpG2HIYmfqDpSYYTDX+mc5qtSuYx1YUb/s=
 github.com/containerd/containerd v1.5.9 h1:rs6Xg1gtIxaeyG+Smsb/0xaSDu1VgFhOCKBXxMxbsF4=
 github.com/containerd/containerd v1.5.9/go.mod h1:fvQqCfadDGga5HZyn3j4+dx56qj2I9YwBrlSdalvJYQ=
 github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
@@ -357,6 +368,9 @@ github.com/containerd/nri v0.0.0-20210316161719-dbaa18c31c14/go.mod h1:lmxnXF6oM
 github.com/containerd/nri v0.1.0/go.mod h1:lmxnXF6oMkbqs39FiCt1s0R2HSMhcLel9vNL3m4AaeY=
 github.com/containerd/stargz-snapshotter/estargz v0.4.1 h1:5e7heayhB7CcgdTkqfZqrNaNv15gABwr3Q2jBTbLlt4=
 github.com/containerd/stargz-snapshotter/estargz v0.4.1/go.mod h1:x7Q9dg9QYb4+ELgxmo4gBUeJB0tl5dqH1Sdz0nJU1QM=
+github.com/containerd/stargz-snapshotter/estargz v0.10.0/go.mod h1:aE5PCyhFMwR8sbrErO5eM2GcvkyXTTJremG883D4qF0=
+github.com/containerd/stargz-snapshotter/estargz v0.10.1 h1:hd1EoVjI2Ax8Cr64tdYqnJ4i4pZU49FkEf5kU8KxQng=
+github.com/containerd/stargz-snapshotter/estargz v0.10.1/go.mod h1:aE5PCyhFMwR8sbrErO5eM2GcvkyXTTJremG883D4qF0=
 github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
 github.com/containerd/ttrpc v0.0.0-20190828172938-92c8520ef9f8/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
 github.com/containerd/ttrpc v0.0.0-20191028202541-4f1b8fe65a5c/go.mod h1:LPm1u0xBw8r8NOKoOdNMeVHSawSsltak+Ihv+etqsE8=
@@ -439,14 +453,18 @@ github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v0.0.0-20200312141509-ef2f64abbd37/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v20.10.10+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v20.10.11+incompatible h1:tXU1ezXcruZQRrMP8RN2z9N91h+6egZTS1gsPsKantc=
 github.com/docker/cli v20.10.11+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v20.10.12+incompatible h1:lZlz0uzG+GH+c0plStMUdF/qk3ppmgnswpR5EbqzVGA=
+github.com/docker/cli v20.10.12+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
 github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
 github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v20.10.0-beta1.0.20201110211921-af34b94a78a1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v20.10.10+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v20.10.11+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v20.10.12+incompatible h1:CEeNmFM0QZIsJCZKMkZx0ZcahTiewkrgiwfYD+dfl1U=
 github.com/docker/docker v20.10.12+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
@@ -468,6 +486,7 @@ github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNE
 github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=
 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
 github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
@@ -520,6 +539,11 @@ github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui72
 github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To=
 github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
 github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
+github.com/gdamore/tcell/v2 v2.3.3/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
+github.com/gdamore/tcell/v2 v2.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM=
+github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
 github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=
 github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
 github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8=
@@ -683,9 +707,14 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
 github.com/google/go-containerregistry v0.4.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
 github.com/google/go-containerregistry v0.5.1 h1:/+mFTs4AlwsJ/mJe8NDtKb7BxLtbZFpcn8vDsneEkwQ=
 github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
+github.com/google/go-containerregistry v0.7.0/go.mod h1:2zaoelrL0d08gGbpdP3LqyUuBmhWbpD6IOe2s9nLS2k=
+github.com/google/go-containerregistry v0.8.0 h1:mtR24eN6rapCN+shds82qFEIWWmg64NPMuyCNT7/Ogc=
+github.com/google/go-containerregistry v0.8.0/go.mod h1:wW5v71NHGnQyb4k+gSshjxidrC7lN33MdWEn+Mz9TsI=
 github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
 github.com/google/go-github/v39 v39.0.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
 github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
@@ -714,6 +743,7 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
@@ -994,6 +1024,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn
 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
 github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo=
 github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
+github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
@@ -1102,8 +1134,11 @@ github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdx
 github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGqsZeMYowQ=
 github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
 github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
+github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
 github.com/moby/term v0.0.0-20210610120745-9d4ed1856297 h1:yH0SvLzcbZxcJXho2yh7CqdENGMQe73Cw3woZBpPli0=
 github.com/moby/term v0.0.0-20210610120745-9d4ed1856297/go.mod h1:vgPCkQMyxTZ7IDy8SXRufE172gr8+K/JE/7hHFxHW3A=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -1152,6 +1187,7 @@ github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISq
 github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
 github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
 github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
+github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
 github.com/onsi/gomega v0.0.0-20151007035656-2152b45fa28a/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
 github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
 github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -1164,6 +1200,9 @@ github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7
 github.com/onsi/gomega v1.12.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
 github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
 github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
+github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
 github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
@@ -1172,6 +1211,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/image-spec v1.0.2-0.20210730191737-8e42a01fb1b7/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/image-spec v1.0.2-0.20211117181255-693428a734f5/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
 github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
@@ -1264,6 +1305,8 @@ github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3x
 github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
 github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
+github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 h1:I5N0WNMgPSq5NKUFspB4jMJ6n2P0ipz5FlOlB4BXviQ=
+github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2/go.mod h1:IxQujbYMAh4trWr0Dwa8jfciForjVmxyHpskZX6aydQ=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -1407,11 +1450,14 @@ github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/
 github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
 github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
+github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
 github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
 github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
+github.com/vbatts/tar-split v0.11.2 h1:Via6XqJr0hceW4wff3QRzD5gAk/tatMw/4ZA7cTlIME=
+github.com/vbatts/tar-split v0.11.2/go.mod h1:vV3ZuO2yWSVsz+pfFzDG/upWH1JhjOiEaWq6kXyQ3VI=
 github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
 github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
 github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
@@ -1447,6 +1493,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
 github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
@@ -1588,6 +1635,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.5.0 h1:UG21uOlmZabA4fW5i7ZX6bjw1xELEGg/ZLgZq9auk/Q=
 golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
+golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
+golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1650,8 +1699,12 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211111160137-58aab5ef257a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211203184738-4852103109b8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
@@ -1780,6 +1833,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1803,13 +1857,17 @@ golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
@@ -1915,6 +1973,7 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.6-0.20210820212750-d4cc65f0b2ff/go.mod h1:YD9qOF0M9xpSpdWTBbzEl5e/RnCefISl8E5Noe10jFM=
 golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
+golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2035,6 +2094,7 @@ google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEc
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211111162719-482062a4217b/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
@@ -2187,6 +2247,8 @@ k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRp
 k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc=
 k8s.io/apimachinery v0.23.1 h1:sfBjlDFwj2onG0Ijx5C+SrAoeUscPrmghm7wHP+uXlo=
 k8s.io/apimachinery v0.23.1/go.mod h1:SADt2Kl8/sttJ62RRsi9MIV4o8f5S3coArm0Iu3fBno=
+k8s.io/apimachinery v0.23.5 h1:Va7dwhp8wgkUPWsEXk6XglXWU4IKYLKNlv8VkX7SDM0=
+k8s.io/apimachinery v0.23.5/go.mod h1:BEuFMMBaIbcOqVIJqNZJXGFTP4W6AycEpb5+m/97hrM=
 k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q=
@@ -2246,6 +2308,8 @@ k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/
 k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b h1:wxEMGetGMur3J1xuGLQY7GEQYg9bZxKn3tKo5k/eYcs=
 k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed h1:ck1fRPWPJWsMd8ZRFsWc6mh/zHp5fZ/shhbrgPUxDAE=
+k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=
 modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw=
 modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk=
 modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k=
@@ -2275,6 +2339,8 @@ sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK
 sigs.k8s.io/structured-merge-diff/v4 v4.0.3/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw=
 sigs.k8s.io/structured-merge-diff/v4 v4.1.2 h1:Hr/htKFmJEbtMgS/UD0N+gtgctAqz81t3nu+sPzynno=
 sigs.k8s.io/structured-merge-diff/v4 v4.1.2/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.1 h1:bKCqE9GvQ5tiVHn5rfn1r+yao3aLQEaLzkkmAkf+A6Y=
+sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4=
 sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
 sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
 sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=