Просмотр исходного кода

Merge branch 'master' of github.com:porter-dev/porter into nico/preview-envs-frontend-improvements

jnfrati 4 лет назад
Родитель
Сommit
dce2024c1f
65 измененных файлов с 3905 добавлено и 199 удалено
  1. 0 1
      .github/workflows/production.yaml
  2. 0 1
      .github/workflows/staging.yaml
  3. 17 4
      api/server/handlers/cluster/detect_agent_installed.go
  4. 64 0
      api/server/handlers/cluster/get_incident_event_logs.go
  5. 94 0
      api/server/handlers/cluster/get_incidents.go
  6. 89 0
      api/server/handlers/cluster/notify_new_incident.go
  7. 89 0
      api/server/handlers/cluster/notify_resolved_incident.go
  8. 81 0
      api/server/handlers/cluster/upgrade_agent.go
  9. 16 2
      api/server/handlers/namespace/get_ingress.go
  10. 1 1
      api/server/handlers/registry/create_repository.go
  11. 146 1
      api/server/router/cluster.go
  12. 5 0
      api/types/agent.go
  13. 14 0
      api/types/cluster.go
  14. 1 1
      cli/cmd/apply.go
  15. 23 0
      cli/cmd/deploy.go
  16. 5 15
      cli/cmd/deploy/build.go
  17. 8 8
      cli/cmd/deploy/deploy.go
  18. 1 0
      cli/cmd/deploy/shared.go
  19. 16 1
      cli/cmd/docker.go
  20. 15 15
      cli/cmd/docker/agent.go
  21. 2 1
      cli/cmd/docker/builder.go
  22. 1 1
      cli/cmd/docker/config.go
  23. 12 12
      cli/cmd/docker/porter.go
  24. 10 3
      cli/cmd/pack/logger.go
  25. 13 6
      cli/cmd/pack/pack.go
  26. 1 1
      cli/cmd/version.go
  27. 23 0
      dashboard/package-lock.json
  28. 3 0
      dashboard/package.json
  29. 98 16
      dashboard/src/components/Table.tsx
  30. 17 5
      dashboard/src/components/expanded-object/Header.tsx
  31. 4 2
      dashboard/src/components/form-components/InputRow.tsx
  32. 8 0
      dashboard/src/components/porter-form/PorterForm.tsx
  33. 88 0
      dashboard/src/components/porter-form/field-components/CronInput.tsx
  34. 128 0
      dashboard/src/components/porter-form/field-components/TextAreaInput.tsx
  35. 2 0
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  36. 27 2
      dashboard/src/components/porter-form/types.ts
  37. 2 0
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  38. 20 1
      dashboard/src/main/home/cluster-dashboard/SortSelector.tsx
  39. 82 0
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  40. 36 0
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  41. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  42. 5 5
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  43. 4 0
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  44. 233 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx
  45. 62 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx
  46. 524 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx
  47. 215 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  48. 209 0
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx
  49. 9 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  50. 90 15
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  51. 217 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.tsx
  52. 217 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx
  53. 1 1
      dashboard/src/main/home/project-settings/InviteList.tsx
  54. 82 20
      dashboard/src/shared/api.tsx
  55. 4 0
      dashboard/src/shared/types.tsx
  56. 21 15
      go.mod
  57. 66 4
      go.sum
  58. 73 0
      internal/helm/agent.go
  59. 152 0
      internal/helm/postrenderer.go
  60. 135 0
      internal/integrations/slack/incidents_notifier.go
  61. 35 1
      internal/kubernetes/agent.go
  62. 132 0
      internal/kubernetes/porter_agent/v2/agent_server.go
  63. 55 0
      internal/kubernetes/porter_agent/v2/models.go
  64. 99 26
      internal/kubernetes/prometheus/metrics.go
  65. 2 6
      provisioner/server/handlers/state/delete_resource.go

+ 0 - 1
.github/workflows/production.yaml

@@ -40,7 +40,6 @@ jobs:
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
           SENTRY_ENV=production
-          DISABLE_BILLING=true
           EOL
       - name: Build
         run: |

+ 0 - 1
.github/workflows/staging.yaml

@@ -39,7 +39,6 @@ jobs:
           ENABLE_SENTRY=true
           SENTRY_DSN=${{secrets.SENTRY_DSN}}
           SENTRY_ENV=staging
-          DISABLE_BILLING=true
           EOL
       - name: Build
         run: |

+ 17 - 4
api/server/handlers/cluster/detect_agent_installed.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
@@ -14,15 +15,16 @@ import (
 )
 
 type DetectAgentInstalledHandler struct {
-	handlers.PorterHandler
+	handlers.PorterHandlerWriter
 	authz.KubernetesAgentGetter
 }
 
 func NewDetectAgentInstalledHandler(
 	config *config.Config,
+	writer shared.ResultWriter,
 ) *DetectAgentInstalledHandler {
 	return &DetectAgentInstalledHandler{
-		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
 		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
 	}
 }
@@ -37,7 +39,7 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	_, err = agent.GetPorterAgent()
+	depl, err := agent.GetPorterAgent()
 
 	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
 		http.NotFound(w, r)
@@ -47,5 +49,16 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	w.WriteHeader(http.StatusOK)
+	// detect the version of the agent which is installed
+	res := &types.GetAgentResponse{}
+
+	versionAnn, ok := depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
+
+	if !ok {
+		res.Version = "v1"
+	} else {
+		res.Version = versionAnn
+	}
+
+	c.WriteResult(w, r, res)
 }

+ 64 - 0
api/server/handlers/cluster/get_incident_event_logs.go

@@ -0,0 +1,64 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetIncidentEventLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetIncidentEventLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetIncidentEventLogsHandler {
+	return &GetIncidentEventLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetIncidentEventLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetIncidentEventLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	logs, err := porter_agent.GetLogs(agent.Clientset, agentSvc, request.LogID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, logs)
+}

+ 94 - 0
api/server/handlers/cluster/get_incidents.go

@@ -0,0 +1,94 @@
+package cluster
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetIncidentsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetIncidentsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetIncidentsHandler {
+	return &GetIncidentsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetIncidentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.GetIncidentsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	incidentID := request.IncidentID
+	releaseName := request.ReleaseName
+	namespace := request.Namespace
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if incidentID != "" {
+		events, err := porter_agent.GetIncidentEventsByID(agent.Clientset, agentSvc, incidentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		c.WriteResult(w, r, events)
+		return
+	} else if releaseName != "" {
+		if namespace == "" {
+			namespace = "default"
+		}
+
+		incidents, err := porter_agent.GetIncidentsByReleaseNamespace(agent.Clientset, agentSvc, releaseName, namespace)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		c.WriteResult(w, r, incidents)
+		return
+	}
+
+	incidents, err := porter_agent.GetAllIncidents(agent.Clientset, agentSvc)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, incidents)
+}

+ 89 - 0
api/server/handlers/cluster/notify_new_incident.go

@@ -0,0 +1,89 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotifyNewIncidentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewNotifyNewIncidentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *NotifyNewIncidentHandler {
+	return &NotifyNewIncidentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *NotifyNewIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &porter_agent.Incident{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// FIXME: better error detection for correct incident ID
+	segments := strings.Split(request.ID, ":")
+	if len(segments) != 4 || (len(segments) > 0 && segments[0] != "incident") {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid incident ID: %s", request.ID)))
+		return
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, segments[1], segments[2])
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var notifConf *types.NotificationConfig
+
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+
+	if !cluster.NotificationsDisabled {
+		err := notifier.NotifyNew(
+			request, fmt.Sprintf(
+				"%s/cluster-dashboard/incidents/%s?namespace=%s",
+				c.Config().ServerConf.ServerURL,
+				request.ID,
+				segments[2],
+			),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 89 - 0
api/server/handlers/cluster/notify_resolved_incident.go

@@ -0,0 +1,89 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type NotifyResolvedIncidentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewNotifyResolvedIncidentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *NotifyResolvedIncidentHandler {
+	return &NotifyResolvedIncidentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *NotifyResolvedIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &porter_agent.Incident{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// FIXME: better error detection for correct incident ID
+	segments := strings.Split(request.ID, ":")
+	if len(segments) != 4 || (len(segments) > 0 && segments[0] != "incident") {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("invalid incident ID: %s", request.ID)))
+		return
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, err := c.Repo().Release().ReadRelease(cluster.ID, segments[1], segments[2])
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var notifConf *types.NotificationConfig
+
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewIncidentsNotifier(notifConf, slackInts...)
+
+	if !cluster.NotificationsDisabled {
+		err := notifier.NotifyResolved(
+			request, fmt.Sprintf(
+				"%s/cluster-dashboard/incidents/%s?namespace=%s",
+				c.Config().ServerConf.ServerURL,
+				request.ID,
+				segments[2],
+			),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 81 - 0
api/server/handlers/cluster/upgrade_agent.go

@@ -0,0 +1,81 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UpgradeAgentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpgradeAgentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpgradeAgentHandler {
+	return &UpgradeAgentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpgradeAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmAgent, err := c.GetHelmAgent(r, cluster, "porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	currRelease, err := helmAgent.GetRelease("porter-agent", 0, false)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	chart, err := loader.LoadChartPublic(c.Config().ServerConf.DefaultAddonHelmRepoURL, "porter-agent", "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newValues := currRelease.Config
+
+	// TODO: update values
+	// newValues["redis"] =
+
+	_, err = helmAgent.UpgradeReleaseByValues(&helm.UpgradeReleaseConfig{
+		Chart:      chart,
+		Name:       "porter-agent",
+		Values:     newValues,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: []*models.Registry{},
+	}, c.Config().DOConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error upgrading the chart: %s", err.Error()),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 16 - 2
api/server/handlers/namespace/get_ingress.go

@@ -42,7 +42,21 @@ func (c *GetIngressHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	ingress, err := agent.GetIngress(namespace, name)
+	ingress1, err := agent.GetExtensionsV1Beta1Ingress(namespace, name)
+
+	if err == nil && ingress1 != nil {
+		c.WriteResult(w, r, ingress1)
+		return
+	}
+
+	ingress2, err := agent.GetNetworkingV1Beta1Ingress(namespace, name)
+
+	if err == nil && ingress2 != nil {
+		c.WriteResult(w, r, ingress2)
+		return
+	}
+
+	ingress3, err := agent.GetNetworkingV1Ingress(namespace, name)
 
 	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
@@ -56,5 +70,5 @@ func (c *GetIngressHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	c.WriteResult(w, r, ingress)
+	c.WriteResult(w, r, ingress3)
 }

+ 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)
 

+ 146 - 1
api/server/router/cluster.go

@@ -585,7 +585,7 @@ func getClusterRoutes(
 		},
 	)
 
-	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config)
+	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config, factory.GetResultWriter())
 
 	routes = append(routes, &Route{
 		Endpoint: detectAgentInstalledEndpoint,
@@ -622,6 +622,35 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/agent/upgrade -> cluster.NewInstallAgentHandler
+	upgradeAgentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/upgrade",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	upgradeAgentHandler := cluster.NewUpgradeAgentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: upgradeAgentEndpoint,
+		Handler:  upgradeAgentHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
 	listKubeEventsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -917,5 +946,121 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents -> cluster.NewGetIncidentsHandler
+	getIncidentsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getIncidentsHandler := cluster.NewGetIncidentsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getIncidentsEndpoint,
+		Handler:  getIncidentsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/incidents/logs -> cluster.NewGetIncidentsHandler
+	getIncidentEventLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/logs",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getIncidentEventLogsHandler := cluster.NewGetIncidentEventLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getIncidentEventLogsEndpoint,
+		Handler:  getIncidentEventLogsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/incidents/notify_new -> cluster.NewNotifyNewIncidentHandler
+	notifyNewIncidentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/notify_new",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	notifyNewIncidentHandler := cluster.NewNotifyNewIncidentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: notifyNewIncidentEndpoint,
+		Handler:  notifyNewIncidentHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/incidents/notify_resolved -> cluster.NewNotifyResolvedIncidentHandler
+	notifyResolvedIncidentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/incidents/notify_resolved",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	notifyResolvedIncidentHandler := cluster.NewNotifyResolvedIncidentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: notifyResolvedIncidentEndpoint,
+		Handler:  notifyResolvedIncidentHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 5 - 0
api/types/agent.go

@@ -0,0 +1,5 @@
+package types
+
+type GetAgentResponse struct {
+	Version string `json:"version"`
+}

+ 14 - 0
api/types/cluster.go

@@ -247,3 +247,17 @@ type ListClusterResponse []*Cluster
 type CreateClusterCandidateResponse []*ClusterCandidate
 
 type ListClusterCandidateResponse []*ClusterCandidate
+
+type GetIncidentsRequest struct {
+	IncidentID  string `schema:"incident_id"`
+	ReleaseName string `schema:"release_name"`
+	Namespace   string `schema:"namespace"`
+}
+
+type GetIncidentEventLogsRequest struct {
+	LogID string `schema:"log_id"`
+}
+
+type IncidentNotifyRequest struct {
+	IncidentID string `json:"incident_id" form:"required"`
+}

+ 1 - 1
cli/cmd/apply.go

@@ -424,7 +424,7 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 
 	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
 		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
-			repoSuffix = fmt.Sprintf("%s-%s", repoOwner, repoName)
+			repoSuffix = strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, 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",

+ 98 - 16
dashboard/src/components/Table.tsx

@@ -9,8 +9,13 @@ import {
 } from "react-table";
 import Loading from "components/Loading";
 import Selector from "./Selector";
+import loading from "assets/loading.gif";
 
-const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
+const GlobalFilter: React.FunctionComponent<any> = ({
+  setGlobalFilter,
+  onRefresh,
+  isRefreshing,
+}) => {
   const [value, setValue] = React.useState("");
   const onChange = (value: string) => {
     setValue(value);
@@ -18,16 +23,29 @@ const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
   };
 
   return (
-    <SearchRow>
-      <i className="material-icons">search</i>
-      <SearchInput
-        value={value}
-        onChange={(e: any) => {
-          onChange(e.target.value);
-        }}
-        placeholder="Search"
-      />
-    </SearchRow>
+    <SearchRowWrapper>
+      <SearchRow>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={value}
+          onChange={(e: any) => {
+            onChange(e.target.value);
+          }}
+          placeholder="Search"
+        />
+      </SearchRow>
+      {typeof onRefresh === "function" && (
+        <RefreshButton onClick={onRefresh} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+      )}
+    </SearchRowWrapper>
   );
 };
 
@@ -39,6 +57,10 @@ export type TableProps = {
   disableGlobalFilter?: boolean;
   disableHover?: boolean;
   enablePagination?: boolean;
+  hasError?: boolean;
+  errorMessage?: string;
+  onRefresh?: () => void;
+  isRefreshing?: boolean;
 };
 
 const MIN_PAGE_SIZE = 1;
@@ -51,6 +73,10 @@ const Table: React.FC<TableProps> = ({
   disableGlobalFilter = false,
   disableHover,
   enablePagination,
+  hasError,
+  errorMessage = "An unexpected error occurred, please try again.",
+  onRefresh,
+  isRefreshing = false,
 }) => {
   const {
     getTableProps,
@@ -87,10 +113,20 @@ const Table: React.FC<TableProps> = ({
   }, [data, enablePagination]);
 
   const renderRows = () => {
+    if (hasError) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            {errorMessage}
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
     if (isLoading) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>
+          <StyledTd colSpan={visibleColumns.length} height="150px">
             <Loading />
           </StyledTd>
         </StyledTr>
@@ -100,7 +136,9 @@ const Table: React.FC<TableProps> = ({
     if (!page.length) {
       return (
         <StyledTr disableHover={true} selected={false}>
-          <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
+          <StyledTd colSpan={visibleColumns.length} align="center">
+            No data available
+          </StyledTd>
         </StyledTr>
       );
     }
@@ -140,7 +178,11 @@ const Table: React.FC<TableProps> = ({
   return (
     <TableWrapper>
       {!disableGlobalFilter && (
-        <GlobalFilter setGlobalFilter={setGlobalFilter} />
+        <GlobalFilter
+          setGlobalFilter={setGlobalFilter}
+          onRefresh={onRefresh}
+          isRefreshing={isRefreshing}
+        />
       )}
       <StyledTable {...getTableProps()}>
         <StyledTHead>
@@ -281,6 +323,12 @@ export const StyledTd = styled.td`
     padding-right: 10px;
   }
   user-select: text;
+
+  ${(props: { align?: "center" | "left" }) => {
+    if (props.align) {
+      return `text-align:${props.align};`;
+    }
+  }}
 `;
 
 export const StyledTHead = styled.thead`
@@ -332,8 +380,7 @@ const SearchRow = styled.div`
   min-width: 300px;
   max-width: min-content;
   background: #ffffff11;
-  margin-bottom: 15px;
-  margin-top: 0px;
+
   i {
     width: 18px;
     height: 18px;
@@ -342,3 +389,38 @@ const SearchRow = styled.div`
     font-size: 20px;
   }
 `;
+
+const SearchRowWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  margin-top: 0px;
+`;
+
+const RefreshButton = styled.button`
+  justify-self: flex-end;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;

+ 17 - 5
dashboard/src/components/expanded-object/Header.tsx

@@ -10,24 +10,36 @@ type Props = {
   name: string;
   icon: string;
   inline_title_items?: React.ReactNodeArray;
+  sub_title_items?: React.ReactNodeArray;
+  materialIconClass?: string;
 };
 
 const Header: React.FunctionComponent<Props> = (props) => {
-  const { last_updated, back_link, icon, name, inline_title_items } = props;
+  const {
+    last_updated,
+    back_link,
+    icon,
+    name,
+    inline_title_items,
+    sub_title_items,
+    materialIconClass,
+  } = props;
 
   return (
     <HeaderWrapper>
       <BackButton to={back_link}>
         <BackButtonImg src={backArrow} />
       </BackButton>
-      <Title icon={icon} iconWidth="25px">
+      <Title icon={icon} iconWidth="25px" materialIconClass={materialIconClass}>
         {name}
         <Flex>{inline_title_items}</Flex>
       </Title>
 
-      <InfoWrapper>
-        <InfoText>Last updated {last_updated}</InfoText>
-      </InfoWrapper>
+      {sub_title_items || (
+        <InfoWrapper>
+          <InfoText>Last updated {last_updated}</InfoText>
+        </InfoWrapper>
+      )}
     </HeaderWrapper>
   );
 };

+ 4 - 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}>
           <Input
             readOnly={this.state.readOnly}
             onFocus={() => this.setState({ readOnly: false })}
@@ -103,7 +104,8 @@ const InputWrapper = styled.div`
   display: flex;
   margin-bottom: -1px;
   align-items: center;
-  border: 1px solid #ffffff55;
+  border: 1px solid
+    ${(props: { hasError: boolean }) => (props.hasError ? "red" : "#ffffff55")};
   border-radius: 3px;
 `;
 

+ 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[];

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -185,6 +185,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
               sortType={this.state.sortType}
+              currentView={currentView}
             />
           </SortFilterWrapper>
         </ControlRow>
@@ -255,6 +256,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             <SortSelector
               setSortType={(sortType) => this.setState({ sortType })}
               sortType={this.state.sortType}
+              currentView={currentView}
             />
           </SortFilterWrapper>
         </ControlRow>

+ 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;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -15,7 +15,7 @@ type Props = {
   sortType: "Newest" | "Oldest" | "Alphabetical";
 };
 
-const dateFormatter = (date: string) => {
+export const dateFormatter = (date: string | number) => {
   if (!date) {
     return "N/A";
   }

+ 5 - 5
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -11,18 +11,18 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
-import EventsTab from "./events/EventsTab";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
+import IncidentsTab from "./incidents/IncidentsTab";
 
-type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "events";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "incidents";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
   { label: "Nodes", value: "nodes" },
-  { label: "Events", value: "events" },
+  { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
@@ -37,8 +37,8 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
-      case "events":
-        return <EventsTab />;
+      case "incidents":
+        return <IncidentsTab />;
       case "settings":
         return <ClusterSettings />;
       case "metrics":

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

@@ -2,6 +2,7 @@ import React, { useContext } from "react";
 import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
+import IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
 
 export const Routes = () => {
@@ -10,6 +11,9 @@ export const Routes = () => {
   return (
     <>
       <Switch>
+        <Route path={`${url}/incidents/:incident_id`}>
+          <IncidentPage />
+        </Route>
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>

+ 233 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/EventDrawer.tsx

@@ -0,0 +1,233 @@
+import Description from "components/Description";
+import useLastSeenPodStatus from "components/events/useLastSeenPodStatus";
+import Heading from "components/form-components/Heading";
+import Loading from "components/Loading";
+import { isEmpty } from "lodash";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import ExpandedContainer from "./ExpandedContainer";
+import { IncidentContainerEvent, IncidentEvent } from "./IncidentPage";
+
+const EventDrawer: React.FC<{
+  event: IncidentEvent;
+  closeDrawer: () => void;
+}> = ({ event, closeDrawer }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [containerLogs, setContainerLogs] = useState<{ [key: string]: string }>(
+    null
+  );
+
+  const {
+    status,
+    hasError: hasPodStatusErrored,
+    isLoading: isPodStatusLoading,
+  } = useLastSeenPodStatus({
+    podName: event?.pod_name,
+    namespace: event?.namespace,
+    resource_type: "pod",
+  });
+
+  const containers: IncidentContainerEvent[] = useMemo(() => {
+    if (isEmpty(event?.container_events)) {
+      return [];
+    }
+
+    return Object.values(event?.container_events || {});
+  }, [event]);
+
+  useEffect(() => {
+    if (!event) {
+      return () => {};
+    }
+
+    let isSubscribed = true;
+
+    const containersWithLogs = containers.filter(
+      (container) => container.log_id
+    );
+
+    const promises = containersWithLogs.map((container) => {
+      return api
+        .getIncidentLogsByLogId<{ contents: string }>(
+          "<token>",
+          {
+            log_id: container.log_id,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => ({
+          contents: res.data?.contents,
+          container_name: container.container_name,
+        }));
+    });
+
+    Promise.all(promises)
+      .then((data) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        const tmpContainerLogs = data.reduce<{ [key: string]: string }>(
+          (acc, c) => {
+            acc[c.container_name] = c.contents;
+            return acc;
+          },
+          {}
+        );
+
+        setContainerLogs(tmpContainerLogs);
+      })
+      .catch(() => console.log("nope"));
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [containers]);
+
+  if (!event) {
+    return null;
+  }
+
+  if (!containerLogs) {
+    return <Loading />;
+  }
+
+  return (
+    <EventDrawerContainer>
+      <EventDrawerTitleContainer>
+        <EventDrawerTitle>Pod: {event?.pod_name}</EventDrawerTitle>
+        <BackButton onClick={closeDrawer}>
+          <i className="material-icons">close</i>
+        </BackButton>
+      </EventDrawerTitleContainer>
+
+      <StyledHelper>
+        {hasPodStatusErrored ? (
+          "We couldn't retrieve last pod status, please try again later"
+        ) : (
+          <>
+            {isPodStatusLoading ? (
+              <Loading />
+            ) : (
+              <>
+                Latest pod status: {capitalize(status)}{" "}
+                <StatusColor status={status?.toLowerCase()}></StatusColor>
+              </>
+            )}
+          </>
+        )}
+      </StyledHelper>
+      <MetadataContainer>
+        <Heading>Overview</Heading>
+        <Description>
+          Event reported on{" "}
+          {Intl.DateTimeFormat([], {
+            // @ts-ignore
+            dateStyle: "full",
+            timeStyle: "long",
+          }).format(new Date(event?.timestamp))}
+        </Description>
+        <Description>{event?.message}</Description>
+        <Br />
+      </MetadataContainer>
+      {containers.map((container) => (
+        <ExpandedContainer
+          container={container}
+          logs={containerLogs[container.container_name]}
+        />
+      ))}
+    </EventDrawerContainer>
+  );
+};
+
+export default EventDrawer;
+
+const EventDrawerContainer = styled.div`
+  position: relative;
+  color: #ffffff;
+  padding: 25px 30px;
+`;
+
+const EventDrawerTitle = styled.span`
+  display: block;
+  font-size: 24px;
+  font-weight: bold;
+  color: #ffffff;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 20px;
+`;
+
+const MetadataContainer = styled.div`
+  border-radius: 6px;
+  background: #2e3135;
+  padding: 0 20px;
+  overflow-y: auto;
+  min-height: 100px;
+  font-size: 13px;
+  margin: 12px 0;
+`;
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+  margin-top: 6px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+  color: #ffffffaa;
+
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusColor = styled.div`
+  display: inline-block;
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed" || props.status === "deleted"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const EventDrawerTitleContainer = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+`;

+ 62 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/ExpandedContainer.tsx

@@ -0,0 +1,62 @@
+import Description from "components/Description";
+import Heading from "components/form-components/Heading";
+import React from "react";
+import styled from "styled-components";
+import { IncidentContainerEvent } from "./IncidentPage";
+
+type Props = {
+  container: IncidentContainerEvent;
+  logs: string;
+};
+
+const ExpandedContainer: React.FC<Props> = ({ container, logs }) => {
+  return (
+    <StyledCard>
+      <MetadataContainer>
+        <Heading>Container: {container.container_name}</Heading>
+        <Description>
+          Container exited with code {container.exit_code}, {container.message}
+        </Description>
+        <Description>
+          The following are the container logs from this application instance:
+        </Description>
+        <LogContainer>
+          {logs ? <>{logs}</> : <>No logs available for this container.</>}
+        </LogContainer>
+      </MetadataContainer>
+    </StyledCard>
+  );
+};
+
+export default ExpandedContainer;
+
+const StyledCard = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;
+
+const MetadataContainer = styled.div`
+  margin-bottom: 3px;
+  border-radius: 6px;
+  background: #2e3135;
+  padding: 0 20px;
+  overflow-y: auto;
+  min-height: 100px;
+  font-size: 13px;
+  margin: 12px 0;
+`;
+
+const LogContainer = styled.div`
+  padding: 14px;
+  font-size: 13px;
+  background: #121318;
+  user-select: text;
+  overflow-wrap: break-word;
+  overflow-y: auto;
+  min-height: 55px;
+  color: #aaaabb;
+  height: 400px;
+  border-radius: 4px;
+  margin: 12px 0 24px 0;
+`;

+ 524 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentPage.tsx

@@ -0,0 +1,524 @@
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useParams } from "react-router";
+import styled from "styled-components";
+
+import loading from "assets/loading.gif";
+import { Drawer, withStyles } from "@material-ui/core";
+import EventDrawer from "./EventDrawer";
+import { useRouting } from "shared/routing";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import DynamicLink from "components/DynamicLink";
+import Header from "components/expanded-object/Header";
+import { capitalize } from "shared/string_utils";
+import Description from "components/Description";
+import { dateFormatter } from "../../chart/JobRunTable";
+
+type IncidentPageParams = {
+  incident_id: string;
+};
+
+const IncidentPage = () => {
+  const { incident_id } = useParams<IncidentPageParams>();
+
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [incident, setIncident] = useState<Incident>(null);
+
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  const [selectedEvent, setSelectedEvent] = useState<IncidentEvent>(null);
+
+  const { getQueryParam, pushFiltered } = useRouting();
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    setIncident(null);
+
+    api
+      .getIncidentById<Incident>(
+        "<token>",
+        { incident_id },
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        let incident = res.data;
+
+        incident.events = convertEventsTimestampsToMilliseconds(
+          incident.events
+        );
+
+        setIncident(incident);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [incident_id]);
+
+  const refreshIncident = async () => {
+    setIsRefreshing(true);
+    try {
+      let incident = await api
+        .getIncidentById<Incident>(
+          "<token>",
+          { incident_id },
+          {
+            cluster_id: currentCluster.id,
+            project_id: currentProject.id,
+          }
+        )
+        .then((res) => res.data);
+
+      incident.events = convertEventsTimestampsToMilliseconds(incident.events);
+
+      setIncident(incident);
+    } catch (error) {
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const events = useMemo(() => {
+    return groupEventsByDate(incident?.events);
+  }, [incident]);
+
+  if (incident === null) {
+    return <Loading />;
+  }
+
+  const getBackLink = () => {
+    return (
+      getQueryParam("redirect_url") ||
+      "/cluster-dashboard?selected_tab=incidents"
+    );
+  };
+
+  const getResourceLink = () => {
+    let chartName = incident?.chart_name.split("-")[0] || "web";
+    let namespace = incident?.incident_id.split(":")[2] || "default";
+
+    if (chartName == "job") {
+      return `/jobs/${currentCluster.name}/${namespace}/${incident?.release_name}`;
+    }
+
+    return `/applications/${currentCluster.name}/${namespace}/${incident?.release_name}`;
+  };
+
+  return (
+    <StyledExpandedNodeView>
+      <HeaderWrapper>
+        <Header
+          last_updated={dateFormatter(incident.updated_at * 1000)}
+          back_link={getBackLink()}
+          name={"Incident for " + incident.release_name}
+          icon={"error"}
+          materialIconClass="material-icons"
+          inline_title_items={[
+            <ResourceLink
+              key="resource_link"
+              to={getResourceLink()}
+              target="_blank"
+              onClick={(e) => e.stopPropagation()}
+            >
+              {incident.release_name}
+              <i className="material-icons">open_in_new</i>
+            </ResourceLink>,
+          ]}
+          sub_title_items={[
+            <StatusContainer>
+              <Status>
+                <StatusDot status={incident.latest_state} />
+                {capitalize(incident.latest_state)}
+              </Status>
+              <StatusText>
+                - started {dateFormatter(incident.created_at * 1000)}, last
+                updated {dateFormatter(incident.updated_at * 1000)}
+              </StatusText>
+              <Description></Description>
+            </StatusContainer>,
+          ]}
+        />
+      </HeaderWrapper>
+      <LineBreak />
+      <BodyWrapper>
+        <RefreshButton onClick={refreshIncident} disabled={isRefreshing}>
+          {isRefreshing ? (
+            <>
+              <img src={loading} alt="loading icon" />
+            </>
+          ) : (
+            <i className="material-icons">refresh</i>
+          )}
+        </RefreshButton>
+        {Object.entries(events).map(([date, events_list]) => (
+          <React.Fragment key={date}>
+            <StyledDate>{date}</StyledDate>
+
+            {events_list.map((event) => {
+              return (
+                <StyledCard
+                  key={event.event_id}
+                  onClick={() => setSelectedEvent(event)}
+                  active={selectedEvent?.event_id === event.event_id}
+                >
+                  <ContentContainer>
+                    <Icon status={"normal"} className="material-icons-outlined">
+                      info
+                    </Icon>
+                    <EventInformation>
+                      <EventName>
+                        <Helper>Pod:</Helper>
+                        {event.pod_name}
+                      </EventName>
+                      <EventReason>{event.message}</EventReason>
+                    </EventInformation>
+                  </ContentContainer>
+                  <ActionContainer>
+                    <TimestampContainer>
+                      <TimestampIcon className="material-icons-outlined">
+                        access_time
+                      </TimestampIcon>
+                      <span>
+                        {Intl.DateTimeFormat([], {
+                          // @ts-ignore
+                          dateStyle: "full",
+                          timeStyle: "long",
+                        }).format(new Date(event.timestamp))}
+                      </span>
+                    </TimestampContainer>
+                  </ActionContainer>
+                </StyledCard>
+              );
+            })}
+          </React.Fragment>
+        ))}
+      </BodyWrapper>
+      <StyledDrawer
+        anchor="right"
+        open={!!selectedEvent}
+        onClose={() => setSelectedEvent(null)}
+      >
+        <EventDrawer
+          event={selectedEvent}
+          closeDrawer={() => setSelectedEvent(null)}
+        />
+      </StyledDrawer>
+    </StyledExpandedNodeView>
+  );
+};
+
+export default IncidentPage;
+
+const convertEventsTimestampsToMilliseconds = (events: IncidentEvent[]) => {
+  return events.map((e) => {
+    let newEvent = e;
+
+    newEvent.timestamp = newEvent.timestamp * 1000;
+
+    return newEvent;
+  });
+};
+
+const groupEventsByDate = (
+  events: IncidentEvent[]
+): { [key: string]: IncidentEvent[] } => {
+  if (!events?.length) {
+    return {};
+  }
+
+  return events.reduce<{ [key: string]: IncidentEvent[] }>(
+    (accumulator, current) => {
+      // @ts-ignore
+      const date = Intl.DateTimeFormat([], { dateStyle: "full" }).format(
+        new Date(current.timestamp)
+      );
+
+      if (accumulator[date]?.length) {
+        accumulator[date].push(current);
+      } else {
+        accumulator[date] = [current];
+      }
+
+      return accumulator;
+    },
+    {}
+  );
+};
+
+export type IncidentContainerEvent = {
+  container_name: string;
+  reason: string;
+  message: string;
+  exit_code: number;
+  log_id: string;
+};
+
+export type IncidentEvent = {
+  event_id: string;
+  pod_name: string;
+  cluster: string;
+  namespace: string;
+  release_name: string;
+  release_type: string;
+  timestamp: number;
+  pod_phase: string;
+  pod_status: string;
+  reason: string;
+  message: string;
+  container_events: {
+    [key: string]: IncidentContainerEvent;
+  };
+};
+
+export type Incident = {
+  incident_id: string;
+  release_name: string; // eg: "sample-web"
+  latest_state: string; // "ONGOING" or "RESOLVED"
+  latest_reason: string; // eg: "Out of memory",
+  latest_message: string; // eg: "Application crash due to out of memory issue"
+  events: IncidentEvent[];
+  created_at: number;
+  updated_at: number;
+  chart_name: string;
+};
+
+const RefreshButton = styled.button`
+  position: absolute;
+  right: 0px;
+  top: 20px;
+  border: 1px solid #ffffff00;
+  border-radius: 50%;
+  background: inherit;
+  color: #ffffff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 35px;
+  height: 35px;
+
+  > i {
+    font-size: 20px;
+  }
+  > img {
+    width: 20px;
+    height: 20px;
+  }
+
+  :hover {
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const BodyWrapper = styled.div`
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const StyledExpandedNodeView = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  overflow-y: auto;
+  padding-bottom: 120px;
+  flex-direction: column;
+  overflow: visible;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StyledDate = styled.div`
+  font-size: 18px;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 20px;
+  margin-top: 20px;
+  :first-child {
+    margin-top: 0px;
+  }
+`;
+
+const StyledCard = styled.div<{ active: boolean }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  overflow: hidden;
+  height: 80px;
+  font-size: 13px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid ${({ active }) => (active ? "#819bfd" : "#ffffff66")};
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+  :not(:last-child) {
+    margin-bottom: 15px;
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  white-space: nowrap;
+  align-items: center;
+  justify-self: flex-end;
+  color: #ffffff55;
+  margin-right: 10px;
+  font-size: 13px;
+  min-width: 130px;
+  justify-content: space-between;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 7px;
+  font-size: 18px;
+`;
+
+const StyledDrawer = withStyles({
+  paperAnchorRight: {
+    background: "#202227",
+    minWidth: "700px",
+  },
+})(Drawer);
+
+const ResourceLink = styled(DynamicLink)`
+  font-size: 13px;
+  font-weight: 400;
+  margin-left: 20px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+
+  :hover {
+    text-decoration: underline;
+    color: white;
+  }
+
+  > i {
+    margin-left: 7px;
+    font-size: 17px;
+  }
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+  margin-right: 6px;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #aaaabb;
+  width: 100%;
+`;
+
+const StatusText = styled.div`
+  width: 100%;
+`;

+ 215 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx

@@ -0,0 +1,215 @@
+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 IncidentsTable from "./IncidentsTable";
+
+export type DetectAgentResponse = {
+  version: string;
+};
+
+const IncidentsTab = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
+  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .detectPorterAgent<DetectAgentResponse>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => res.data)
+      .then((data) => {
+        if (data.version === "v1") {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(true);
+        } else {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(false);
+        }
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, []);
+
+  const upgradeAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    try {
+      await api.upgradePorterAgent(
+        "<token>",
+        {},
+        {
+          project_id,
+          cluster_id,
+        }
+      );
+      setIsAgentOutdated(false);
+    } catch (err) {
+      setIsAgentOutdated(true);
+    }
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setIsAgentInstalled(true);
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      });
+  };
+
+  const triggerInstall = () => {
+    if (isAgentOutdated) {
+      upgradeAgent();
+      return;
+    }
+
+    installAgent();
+  };
+
+  if (isLoading) {
+    return (
+      <StyledCard>
+        <Loading height="200px" />
+      </StyledCard>
+    );
+  }
+
+  if (!isAgentInstalled || isAgentOutdated) {
+    return (
+      <Placeholder>
+        <AgentButtonContainer>
+          <Header>Incident detection is not enabled on this cluster.</Header>
+          <Subheader>
+            In order to view incidents, you must enable incident detection on
+            this cluster.
+          </Subheader>
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Enable Incident Detection
+          </InstallPorterAgentButton>
+        </AgentButtonContainer>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <StyledCard>
+      <IncidentsTable />
+    </StyledCard>
+  );
+};
+
+export default IncidentsTab;
+
+const StyledCard = styled.div`
+  margin-top: 35px;
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  width: 200px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  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" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > 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 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;
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const AgentButtonContainer = styled.div`
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+  width: 500px;
+  margin: 0 auto;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Subheader = styled.div``;

+ 209 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTable.tsx

@@ -0,0 +1,209 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { useRouting } from "shared/routing";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { dateFormatter } from "../../chart/JobRunTable";
+import { Incident } from "./IncidentPage";
+
+export type IncidentsWithoutEvents = Omit<
+  Incident,
+  "events" | "incident_id"
+> & {
+  id: string;
+};
+
+const IncidentsTable = () => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidents<{ incidents: IncidentsWithoutEvents[] }>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data?.incidents || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const refreshIncidents = async () => {
+    setIsRefreshing(true);
+    try {
+      const incidents = await api
+        .getIncidents<{ incidents: IncidentsWithoutEvents[] }>(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data?.incidents || []);
+
+      setIncidents(incidents);
+    } catch (err) {
+      setHasError(true);
+      setCurrentError(err);
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Release",
+        accessor: "release_name",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          let chartName = original?.chart_name.split("-")[0] || "web";
+
+          return (
+            <KindContainer>
+              <Icon src={hardcodedIcons[chartName] || hardcodedIcons["web"]} />
+              <Kind>{original.release_name}</Kind>
+            </KindContainer>
+          );
+        },
+      },
+      {
+        Header: "Status",
+        accessor: "latest_state",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return (
+            <Status>
+              <StatusDot status={original.latest_state} />
+              {capitalize(original.latest_state)}
+            </Status>
+          );
+        },
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return <Message>{original.latest_message}</Message>;
+        },
+      },
+      {
+        Header: "Started",
+        accessor: "created_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.created_at * 1000);
+        },
+      },
+      {
+        Header: "Last Updated",
+        accessor: "updated_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.updated_at * 1000);
+        },
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={incidents === null}
+      onRowClick={(row: any) => {
+        pushFiltered(`/cluster-dashboard/incidents/${row?.original?.id}`, []);
+      }}
+      hasError={hasError}
+      onRefresh={refreshIncidents}
+      isRefreshing={isRefreshing}
+    />
+  );
+};
+
+export default IncidentsTable;
+
+const KindContainer = styled.div`
+  display: flex;
+  align-items: center;
+  min-width: 200px;
+`;
+
+const Kind = styled.div`
+  margin-left: 8px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const Message = styled.div`
+  white-space: nowrap;
+  overflow-x: hidden;
+  text-overflow: ellipsis;
+  max-width: 500px;
+`;

+ 9 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -28,9 +28,8 @@ import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
-import EventsTab from "./events/EventsTab";
-import { PopulatedEnvGroup } from "components/porter-form/types";
 import { onlyInLeft } from "shared/array_utils";
+import IncidentsTab from "./incidents/IncidentsTab";
 
 type Props = {
   namespace: string;
@@ -426,8 +425,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
-      case "events":
-        return <EventsTab controllers={controllers} />;
+      case "incidents":
+        return (
+          <IncidentsTab
+            releaseName={chart?.name}
+            namespace={chart?.namespace}
+          />
+        );
       case "status":
         if (isLoadingChartData) {
           return (
@@ -520,7 +524,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
-    leftTabOptions.push({ label: "Events", value: "events" });
+    leftTabOptions.push({ label: "Incidents", value: "incidents" });
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });

+ 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`

+ 217 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTab.tsx

@@ -0,0 +1,217 @@
+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 IncidentsTable from "./IncidentsTable";
+
+export type DetectAgentResponse = {
+  version: string;
+};
+
+const IncidentsTab = (props: {
+  releaseName: string;
+  namespace: string;
+}): JSX.Element => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAgentInstalled, setIsAgentInstalled] = useState(false);
+  const [isAgentOutdated, setIsAgentOutdated] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .detectPorterAgent<DetectAgentResponse>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => res.data)
+      .then((data) => {
+        if (data.version === "v1") {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(true);
+        } else {
+          setIsAgentInstalled(true);
+          setIsAgentOutdated(false);
+        }
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, []);
+
+  const upgradeAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    try {
+      await api.upgradePorterAgent(
+        "<token>",
+        {},
+        {
+          project_id,
+          cluster_id,
+        }
+      );
+      setIsAgentOutdated(false);
+    } catch (err) {
+      setIsAgentOutdated(true);
+    }
+  };
+
+  const installAgent = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setIsAgentInstalled(true);
+      })
+      .catch(() => {
+        setIsAgentInstalled(false);
+      });
+  };
+
+  const triggerInstall = () => {
+    if (isAgentOutdated) {
+      upgradeAgent();
+      return;
+    }
+
+    installAgent();
+  };
+
+  if (isLoading) {
+    return (
+      <StyledCard>
+        <Loading height="200px" />
+      </StyledCard>
+    );
+  }
+
+  if (!isAgentInstalled || isAgentOutdated) {
+    return (
+      <Placeholder>
+        <AgentButtonContainer>
+          <Header>Incident detection is not enabled on this cluster.</Header>
+          <Subheader>
+            In order to view incidents, you must enable incident detection on
+            this cluster.
+          </Subheader>
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Enable Incident Detection
+          </InstallPorterAgentButton>
+        </AgentButtonContainer>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <StyledCard>
+      <IncidentsTable {...props} />
+    </StyledCard>
+  );
+};
+
+export default IncidentsTab;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  width: 200px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  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" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > 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 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;
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const AgentButtonContainer = styled.div`
+  display: flex;
+  align-items: left;
+  justify-content: center;
+  flex-direction: column;
+  width: 500px;
+  margin: 0 auto;
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Subheader = styled.div``;

+ 217 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/IncidentsTable.tsx

@@ -0,0 +1,217 @@
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useLocation } from "react-router";
+import { Column } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import { useRouting } from "shared/routing";
+import { capitalize } from "shared/string_utils";
+import styled from "styled-components";
+import { dateFormatter } from "../../chart/JobRunTable";
+import { IncidentsWithoutEvents } from "../../dashboard/incidents/IncidentsTable";
+
+const IncidentsTable = ({
+  releaseName,
+  namespace,
+}: {
+  releaseName: string;
+  namespace: string;
+}) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const { pushFiltered } = useRouting();
+  const location = useLocation();
+
+  const [incidents, setIncidents] = useState<IncidentsWithoutEvents[]>(null);
+  const [hasError, setHasError] = useState(false);
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIncidents(null);
+    setHasError(false);
+
+    api
+      .getIncidentsByReleaseName<{ incidents: IncidentsWithoutEvents[] }>(
+        "<token>",
+        {
+          namespace: namespace,
+          release_name: releaseName,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setIncidents(res.data?.incidents || []);
+      })
+      .catch((err) => {
+        setHasError(true);
+        setCurrentError(err);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentCluster, currentProject]);
+
+  const refreshIncidents = async () => {
+    setIsRefreshing(true);
+    try {
+      const incidents = await api
+        .getIncidentsByReleaseName<{ incidents: IncidentsWithoutEvents[] }>(
+          "<token>",
+          {
+            namespace: namespace,
+            release_name: releaseName,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        )
+        .then((res) => res.data?.incidents || []);
+
+      setIncidents(incidents);
+    } catch (err) {
+      setHasError(true);
+      setCurrentError(err);
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  const columns = useMemo(() => {
+    return [
+      {
+        Header: "Status",
+        accessor: "latest_state",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return (
+            <Status>
+              <StatusDot status={original.latest_state} />
+              {capitalize(original.latest_state)}
+            </Status>
+          );
+        },
+      },
+      {
+        Header: "Message",
+        accessor: "latest_message",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return <Message>{original.latest_message}</Message>;
+        },
+      },
+      {
+        Header: "Started",
+        accessor: "created_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.created_at * 1000);
+        },
+      },
+      {
+        Header: "Last Updated",
+        accessor: "updated_at",
+        Cell: ({ row }) => {
+          let original = row.original;
+
+          return dateFormatter(original.updated_at * 1000);
+        },
+      },
+    ] as Column<IncidentsWithoutEvents>[];
+  }, []);
+
+  const data = useMemo(() => {
+    if (!incidents) {
+      return [];
+    }
+    return incidents;
+  }, [incidents]);
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={incidents === null}
+      onRowClick={(row: any) => {
+        pushFiltered(`/cluster-dashboard/incidents/${row?.original?.id}/`, [], {
+          redirect_url: location.pathname,
+        });
+      }}
+      hasError={hasError}
+      onRefresh={refreshIncidents}
+      isRefreshing={isRefreshing}
+    />
+  );
+};
+
+export default IncidentsTable;
+
+const TableWrapper = styled.div``;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const KindContainer = styled.div`
+  display: flex;
+  align-items: center;
+  min-width: 200px;
+`;
+
+const Kind = styled.div`
+  margin-left: 8px;
+`;
+
+const Icon = styled.img`
+  height: 20px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "ONGOING" ? "#ed5f85" : "#4797ff"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const Message = styled.div`
+  white-space: nowrap;
+  overflow-x: hidden;
+  text-overflow: ellipsis;
+  max-width: 500px;
+`;

+ 1 - 1
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -252,7 +252,7 @@ const InvitePage: React.FunctionComponent<Props> = ({}) => {
                 onClick={() =>
                   replaceInvite(
                     row.values.email,
-                    row.values.id,
+                    row.original.id,
                     row.values.kind
                   )
                 }

+ 82 - 20
dashboard/src/shared/api.tsx

@@ -1,3 +1,4 @@
+import { release } from "process";
 import { baseApi } from "./baseApi";
 
 import { FullActionConfigType, StorageType } from "./types";
@@ -444,11 +445,9 @@ 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<
@@ -464,11 +463,9 @@ 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<
@@ -484,11 +481,9 @@ 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<
@@ -1220,11 +1215,9 @@ 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<
@@ -1610,6 +1603,70 @@ const getPreviousLogsForContainer = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pod/${name}/previous_logs`
 );
 
+const getIncidents = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentsByReleaseName = baseApi<
+  {
+    namespace: string;
+    release_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentById = baseApi<
+  {
+    incident_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents`
+);
+
+const getIncidentLogsByLogId = baseApi<
+  {
+    log_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/incidents/logs`
+);
+
+const upgradePorterAgent = baseApi<
+  {},
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/agent/upgrade`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1763,5 +1820,10 @@ export default {
   provisionDatabase,
   getDatabases,
   getPreviousLogsForContainer,
+  getIncidents,
+  getIncidentsByReleaseName,
+  getIncidentById,
+  getIncidentLogsByLogId,
+  upgradePorterAgent,
   deletePRDeployment,
 };

+ 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;
+    };
   };
 }
 

+ 21 - 15
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
@@ -67,6 +67,7 @@ require (
 )
 
 require (
+	github.com/briandowns/spinner v1.18.1
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
 	gopkg.in/yaml.v2 v2.4.0
 	gorm.io/driver/postgres v1.2.3
@@ -80,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
@@ -94,9 +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/briandowns/spinner v1.18.1 // 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 - 4
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=
@@ -431,8 +445,6 @@ github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUn
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/digitalocean/godo v1.56.0 h1:wXqWJyywrDO3YO2T4Kh8TwbCPOa+OI2vC8qh0/Ngmjk=
-github.com/digitalocean/godo v1.56.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
 github.com/digitalocean/godo v1.75.0 h1:UijUv60I095CqJqGKdjY2RTPnnIa4iFddmq+1wfyS4Y=
 github.com/digitalocean/godo v1.75.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs=
 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684 h1:DBZ2sN7CK6dgvHVpQsQj4sRMCbWTmd17l+5SUCjnQSY=
@@ -441,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=
@@ -470,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=
@@ -522,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=
@@ -685,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=
@@ -716,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=
@@ -996,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=
@@ -1104,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=
@@ -1154,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=
@@ -1166,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=
@@ -1174,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=
@@ -1219,8 +1258,6 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ
 github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034 h1:qzxRAL/HPfadofm5CX3zG3aPXOH77W3KwiW/zctUF7c=
-github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb h1:aNRIZcKkDkFhyROzmc5FCHgK6+ZbmzfTGudioPdtgmU=
 github.com/porter-dev/switchboard v0.0.0-20220209153113-9d257b8e0dfb/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
@@ -1268,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=
@@ -1411,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=
@@ -1451,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=
@@ -1592,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=
@@ -1654,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=
@@ -1784,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=
@@ -1807,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=
@@ -1919,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=
@@ -2039,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=
@@ -2191,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=
@@ -2250,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=
@@ -2279,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=

+ 73 - 0
internal/helm/agent.go

@@ -1,6 +1,7 @@
 package helm
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"strconv"
@@ -259,6 +260,78 @@ func (a *Agent) UpgradeReleaseByValues(
 					return nil, fmt.Errorf("another operation (install/upgrade/rollback) is in progress. If this error persists, please wait for 60 seconds to force an upgrade")
 				}
 			}
+		} else if strings.Contains(err.Error(), "current release manifest contains removed kubernetes api(s)") {
+			// ref: https://helm.sh/docs/topics/kubernetes_apis/#updating-api-versions-of-a-release-manifest
+			// in this case, we manually update the secret containing the new manifests
+			secretList, err := a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace).List(
+				context.Background(),
+				v1.ListOptions{
+					LabelSelector: fmt.Sprintf("owner=helm,name=%s", rel.Name),
+				},
+			)
+
+			if err != nil {
+				return nil, fmt.Errorf("Upgrade failed: %w", err)
+			}
+
+			if len(secretList.Items) > 0 {
+				mostRecentSecret := secretList.Items[0]
+
+				for i := 1; i < len(secretList.Items); i += 1 {
+					oldVersion, _ := strconv.Atoi(mostRecentSecret.Labels["version"])
+					newVersion, _ := strconv.Atoi(secretList.Items[i].Labels["version"])
+
+					if oldVersion < newVersion {
+						mostRecentSecret = secretList.Items[i]
+					}
+				}
+
+				// run the equivalent of `helm template` to get the manifest string for the new release
+				installCmd := action.NewInstall(a.ActionConfig)
+
+				installCmd.ReleaseName = conf.Name
+				installCmd.Namespace = rel.Namespace
+				installCmd.DryRun = true
+				installCmd.Replace = true
+
+				installCmd.ClientOnly = false
+				installCmd.IncludeCRDs = true
+
+				newRelDryRun, err := installCmd.Run(ch, conf.Values)
+
+				if err != nil {
+					return nil, err
+				}
+
+				oldManifestBuffer := bytes.NewBufferString(rel.Manifest)
+				newManifestBuffer := bytes.NewBufferString(newRelDryRun.Manifest)
+
+				versionMapper := &DeprecatedAPIVersionMapper{}
+
+				updatedManifestBuffer, err := versionMapper.Run(oldManifestBuffer, newManifestBuffer)
+
+				if err != nil {
+					return nil, err
+				}
+
+				rel.Manifest = updatedManifestBuffer.String()
+
+				helmSecrets := driver.NewSecrets(a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace))
+
+				err = helmSecrets.Update(mostRecentSecret.GetName(), rel)
+
+				if err != nil {
+					return nil, fmt.Errorf("Upgrade failed: %w", err)
+				}
+
+				res, err := cmd.Run(conf.Name, ch, conf.Values)
+
+				if err != nil {
+					return nil, fmt.Errorf("Upgrade failed: %w", err)
+				}
+
+				return res, nil
+			}
 		}
 
 		return nil, fmt.Errorf("Upgrade failed: %w", err)

+ 152 - 0
internal/helm/postrenderer.go

@@ -823,3 +823,155 @@ func getRegNameFromImageRef(image string) (string, error) {
 
 	return regName, nil
 }
+
+type DeprecatedAPIVersionMapper struct {
+}
+
+type APIVersionKind struct {
+	oldAPIVersion, newAPIVersion, oldKind, newKind string
+}
+
+func (d *DeprecatedAPIVersionMapper) Run(
+	oldRenderedManifests *bytes.Buffer,
+	newRenderedManifests *bytes.Buffer,
+) (modifiedManifests *bytes.Buffer, err error) {
+	oldResources, err := decodeRenderedManifests(oldRenderedManifests)
+
+	if err != nil {
+		return nil, err
+	}
+
+	newResources, err := decodeRenderedManifests(newRenderedManifests)
+
+	if err != nil {
+		return nil, err
+	}
+
+	newNameResourceMap := make(map[string]resource)
+
+	for _, newRes := range newResources {
+		name, ok := getResourceName(newRes)
+
+		if !ok {
+			continue
+		}
+
+		newKind, _, ok := getKindAndAPIVersion(newRes)
+
+		if !ok {
+			continue
+		}
+
+		uniqueName := fmt.Sprintf("%s-%s", strings.ToLower(newKind), name)
+
+		newNameResourceMap[uniqueName] = newRes
+	}
+
+	nameMap := make(map[string]APIVersionKind)
+
+	for _, oldRes := range oldResources {
+		oldName, ok := getResourceName(oldRes)
+
+		if !ok {
+			continue
+		}
+
+		oldKind, oldAPIVersion, ok := getKindAndAPIVersion(oldRes)
+
+		if !ok {
+			continue
+		}
+
+		uniqueName := fmt.Sprintf("%s-%s", strings.ToLower(oldKind), oldName)
+
+		newRes, exists := newNameResourceMap[uniqueName]
+
+		if !exists {
+			continue
+		}
+
+		newKind, newAPIVersion, ok := getKindAndAPIVersion(newRes)
+
+		if !ok {
+			continue
+		}
+
+		nameMap[oldName] = APIVersionKind{
+			oldAPIVersion: oldAPIVersion,
+			newAPIVersion: newAPIVersion,
+			oldKind:       oldKind,
+			newKind:       newKind,
+		}
+
+		// if the API versions don't match, update the old api version to the new api version
+		if oldAPIVersion != newAPIVersion {
+			oldRes["apiVersion"] = newAPIVersion
+		}
+	}
+
+	modifiedManifests = bytes.NewBuffer([]byte{})
+	encoder := yaml.NewEncoder(modifiedManifests)
+	defer encoder.Close()
+
+	for _, resource := range oldResources {
+		err = encoder.Encode(resource)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return modifiedManifests, nil
+}
+
+func getResourceName(res resource) (string, bool) {
+	metadataVal, hasMetadataVal := res["metadata"]
+
+	if !hasMetadataVal {
+		return "", false
+	}
+
+	metadata, ok := metadataVal.(resource)
+
+	if !ok {
+		return "", false
+	}
+
+	nameVal, ok := metadata["name"]
+
+	if !ok {
+		return "", false
+	}
+
+	name, ok := nameVal.(string)
+
+	return name, ok
+}
+
+func getKindAndAPIVersion(res resource) (kind string, apiVersion string, ok bool) {
+	kindVal, hasKindVal := res["kind"]
+
+	if !hasKindVal {
+		return "", "", false
+	}
+
+	kind, ok = kindVal.(string)
+
+	if !ok {
+		return "", "", false
+	}
+
+	apiVersionVal, hasAPIVersionVal := res["apiVersion"]
+
+	if !hasAPIVersionVal {
+		return "", "", false
+	}
+
+	apiVersion, ok = apiVersionVal.(string)
+
+	if !ok {
+		return "", "", false
+	}
+
+	return kind, apiVersion, true
+}

+ 135 - 0
internal/integrations/slack/incidents_notifier.go

@@ -0,0 +1,135 @@
+package slack
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	porter_agent "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type IncidentsNotifier struct {
+	slackInts []*integrations.SlackIntegration
+	Config    *types.NotificationConfig
+}
+
+func NewIncidentsNotifier(conf *types.NotificationConfig, slackInts ...*integrations.SlackIntegration) *IncidentsNotifier {
+	return &IncidentsNotifier{
+		slackInts: slackInts,
+		Config:    conf,
+	}
+}
+
+func (s *IncidentsNotifier) NotifyNew(incident *porter_agent.Incident, url string) error {
+	res := []*SlackBlock{}
+
+	topSectionMarkdwn := fmt.Sprintf(
+		":warning: Your application %s crashed on Porter. <%s|View the incident.>",
+		"`"+incident.ReleaseName+"`",
+		url,
+	)
+
+	namespace := strings.Split(incident.ID, ":")[2]
+	createdAt := time.Unix(incident.CreatedAt, 0).UTC()
+
+	res = append(
+		res,
+		getMarkdownBlock(topSectionMarkdwn),
+		getDividerBlock(),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+namespace+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+incident.ReleaseName+"`")),
+		getMarkdownBlock(fmt.Sprintf(
+			"*Created at:* <!date^%d^ {date_num} {time_secs}| %s>",
+			createdAt.Unix(),
+			createdAt.Format("2006-01-02 15:04:05 UTC"),
+		)),
+		getMarkdownBlock(fmt.Sprintf("```\n%s\n```", incident.LatestMessage)),
+	)
+
+	slackPayload := &SlackPayload{
+		Blocks: res,
+	}
+
+	payload, err := json.Marshal(slackPayload)
+
+	if err != nil {
+		return err
+	}
+
+	reqBody := bytes.NewReader(payload)
+	client := &http.Client{
+		Timeout: time.Second * 5,
+	}
+
+	for _, slackInt := range s.slackInts {
+		_, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (s *IncidentsNotifier) NotifyResolved(incident *porter_agent.Incident, url string) error {
+	res := []*SlackBlock{}
+
+	namespace := strings.Split(incident.ID, ":")[2]
+	createdAt := time.Unix(incident.CreatedAt, 0).UTC()
+	resolvedAt := time.Unix(incident.UpdatedAt, 0).UTC()
+
+	topSectionMarkdwn := fmt.Sprintf(
+		":white_check_mark: The incident for application %s has been resolved. <%s|View the incident.>",
+		"`"+incident.ReleaseName+"`",
+		url,
+	)
+
+	res = append(
+		res,
+		getMarkdownBlock(topSectionMarkdwn),
+		getDividerBlock(),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+namespace+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+incident.ReleaseName+"`")),
+		getMarkdownBlock(fmt.Sprintf(
+			"*Created at:* <!date^%d^ {date_num} {time_secs}| %s>",
+			createdAt.Unix(),
+			createdAt.Format("2006-01-02 15:04:05 UTC"),
+		)),
+		getMarkdownBlock(fmt.Sprintf(
+			"*Resolved at:* <!date^%d^ {date_num} {time_secs}| %s>",
+			resolvedAt.Unix(),
+			resolvedAt.Format("2006-01-02 15:04:05 UTC"),
+		)),
+	)
+
+	slackPayload := &SlackPayload{
+		Blocks: res,
+	}
+
+	payload, err := json.Marshal(slackPayload)
+
+	if err != nil {
+		return err
+	}
+
+	reqBody := bytes.NewReader(payload)
+	client := &http.Client{
+		Timeout: time.Second * 5,
+	}
+
+	for _, slackInt := range s.slackInts {
+		_, err := client.Post(string(slackInt.Webhook), "application/json", reqBody)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 35 - 1
internal/kubernetes/agent.go

@@ -31,6 +31,8 @@ import (
 	batchv1beta1 "k8s.io/api/batch/v1beta1"
 	v1 "k8s.io/api/core/v1"
 	v1beta1 "k8s.io/api/extensions/v1beta1"
+	netv1 "k8s.io/api/networking/v1"
+	netv1beta1 "k8s.io/api/networking/v1beta1"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/fields"
@@ -829,7 +831,7 @@ func (a *Agent) GetJobPods(namespace, jobName string) ([]v1.Pod, error) {
 }
 
 // GetIngress gets ingress given the name and namespace
-func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
+func (a *Agent) GetExtensionsV1Beta1Ingress(namespace string, name string) (*v1beta1.Ingress, error) {
 	resp, err := a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
 		context.TODO(),
 		name,
@@ -845,6 +847,38 @@ func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, err
 	return resp, nil
 }
 
+func (a *Agent) GetNetworkingV1Ingress(namespace string, name string) (*netv1.Ingress, error) {
+	resp, err := a.Clientset.NetworkingV1().Ingresses(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil && errors.IsNotFound(err) {
+		return nil, IsNotFoundError
+	} else if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
+func (a *Agent) GetNetworkingV1Beta1Ingress(namespace string, name string) (*netv1beta1.Ingress, error) {
+	resp, err := a.Clientset.NetworkingV1beta1().Ingresses(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil && errors.IsNotFound(err) {
+		return nil, IsNotFoundError
+	} else if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
 var IsNotFoundError = fmt.Errorf("not found")
 
 type BadRequestError struct {

+ 132 - 0
internal/kubernetes/porter_agent/v2/agent_server.go

@@ -0,0 +1,132 @@
+package v2
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// returns the agent service
+func GetAgentService(clientset kubernetes.Interface) (*v1.Service, error) {
+	return clientset.CoreV1().Services("porter-agent-system").Get(
+		context.TODO(),
+		"porter-agent-controller-manager",
+		metav1.GetOptions{},
+	)
+}
+
+func GetAllIncidents(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+) (*IncidentsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/incidents",
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	incidentsResp := &IncidentsResponse{}
+
+	err = json.Unmarshal(rawQuery, incidentsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return incidentsResp, nil
+}
+
+func GetIncidentEventsByID(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	incidentID string,
+) (*EventsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/incidents/%s", incidentID),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	eventsResp := &EventsResponse{}
+
+	err = json.Unmarshal(rawQuery, eventsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return eventsResp, nil
+}
+
+func GetIncidentsByReleaseNamespace(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	releaseName, namespace string,
+) (*IncidentsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/incidents/namespaces/%s/releases/%s", namespace, releaseName),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	incidentsResp := &IncidentsResponse{}
+
+	err = json.Unmarshal(rawQuery, incidentsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return incidentsResp, nil
+}
+
+func GetLogs(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	logID string,
+) (*LogsResponse, error) {
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/incidents/logs/%s", logID),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.Background())
+	if err != nil {
+		return nil, err
+	}
+
+	logsResp := &LogsResponse{}
+
+	err = json.Unmarshal(rawQuery, logsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	return logsResp, nil
+}

+ 55 - 0
internal/kubernetes/porter_agent/v2/models.go

@@ -0,0 +1,55 @@
+package v2
+
+type ContainerEvent struct {
+	Name     string `json:"container_name"`
+	Reason   string `json:"reason"`
+	Message  string `json:"message"`
+	LogID    string `json:"log_id"`
+	ExitCode int32  `json:"exit_code"`
+}
+
+type PodEvent struct {
+	EventID         string                     `json:"event_id"`
+	PodName         string                     `json:"pod_name"`
+	Namespace       string                     `json:"namespace"`
+	Cluster         string                     `json:"cluster"`
+	OwnerName       string                     `json:"release_name"`
+	OwnerType       string                     `json:"release_type"`
+	Timestamp       int64                      `json:"timestamp"`
+	Phase           string                     `json:"pod_phase"`
+	Status          string                     `json:"pod_status"`
+	Reason          string                     `json:"reason"`
+	Message         string                     `json:"message"`
+	ContainerEvents map[string]*ContainerEvent `json:"container_events"`
+}
+
+type Incident struct {
+	ID            string `json:"id" form:"required"`
+	ReleaseName   string `json:"release_name" form:"required"`
+	ChartName     string `json:"chart_name"`
+	CreatedAt     int64  `json:"created_at" form:"required"`
+	UpdatedAt     int64  `json:"updated_at" form:"required"`
+	LatestState   string `json:"latest_state" form:"required"`
+	LatestReason  string `json:"latest_reason" form:"required"`
+	LatestMessage string `json:"latest_message" form:"required"`
+}
+
+type IncidentsResponse struct {
+	Incidents []*Incident `json:"incidents" form:"required"`
+}
+
+type EventsResponse struct {
+	IncidentID    string      `json:"incident_id" form:"required"`
+	ChartName     string      `json:"chart_name"`
+	ReleaseName   string      `json:"release_name"`
+	CreatedAt     int64       `json:"created_at"`
+	UpdatedAt     int64       `json:"updated_at"`
+	LatestState   string      `json:"latest_state"`
+	LatestReason  string      `json:"latest_reason"`
+	LatestMessage string      `json:"latest_message"`
+	Events        []*PodEvent `json:"events" form:"required"`
+}
+
+type LogsResponse struct {
+	Contents string `json:"contents" form:"required"`
+}

+ 99 - 26
internal/kubernetes/prometheus/metrics.go

@@ -295,109 +295,182 @@ func getSelectionRegex(kind, name string) (string, error) {
 }
 
 func createHPAAbsoluteCPUThresholdQuery(cpuMetricName, metricName, podSelectionRegex, hpaName, namespace, appLabel, hpaMetricName string) string {
-	kubeMetricsPodSelector := getKubeMetricsPodSelector(podSelectionRegex, namespace)
+	kubeMetricsPodSelectorOne := getKubeMetricsPodSelector(podSelectionRegex, namespace, "namespace")
+	kubeMetricsPodSelectorTwo := getKubeMetricsPodSelector(podSelectionRegex, namespace, "exported_namespace")
 
-	kubeMetricsHPASelector := fmt.Sprintf(
+	kubeMetricsHPASelectorOne := fmt.Sprintf(
 		`%s="%s",namespace="%s",metric_name="cpu",metric_target_type="utilization"`,
 		hpaMetricName,
 		hpaName,
 		namespace,
 	)
 
+	kubeMetricsHPASelectorTwo := fmt.Sprintf(
+		`%s="%s",exported_namespace="%s",metric_name="cpu",metric_target_type="utilization"`,
+		hpaMetricName,
+		hpaName,
+		namespace,
+	)
+
 	if cpuMetricName == "kube_pod_container_resource_requests" {
-		kubeMetricsPodSelector += `,resource="cpu",unit="core"`
+		kubeMetricsPodSelectorOne += `,resource="cpu",unit="core"`
+		kubeMetricsPodSelectorTwo += `,resource="cpu",unit="core"`
 	}
 
 	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
 	// as well
 	if appLabel != "" {
-		kubeMetricsPodSelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
-		kubeMetricsHPASelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsPodSelectorOne += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsPodSelectorTwo += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelectorOne += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelectorTwo += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
 	}
 
-	requestCPU := fmt.Sprintf(
+	requestCPUOne := fmt.Sprintf(
+		`sum by (%s) (label_replace(%s{%s},"%s", "%s", "", ""))`,
+		hpaMetricName,
+		cpuMetricName,
+		kubeMetricsPodSelectorOne,
+		hpaMetricName,
+		hpaName,
+	)
+
+	targetCPUUtilThresholdOne := fmt.Sprintf(
+		`%s{%s} / 100`,
+		metricName,
+		kubeMetricsHPASelectorOne,
+	)
+
+	requestCPUTwo := fmt.Sprintf(
 		`sum by (%s) (label_replace(%s{%s},"%s", "%s", "", ""))`,
 		hpaMetricName,
 		cpuMetricName,
-		kubeMetricsPodSelector,
+		kubeMetricsPodSelectorTwo,
 		hpaMetricName,
 		hpaName,
 	)
 
-	targetCPUUtilThreshold := fmt.Sprintf(
+	targetCPUUtilThresholdTwo := fmt.Sprintf(
 		`%s{%s} / 100`,
 		metricName,
-		kubeMetricsHPASelector,
+		kubeMetricsHPASelectorTwo,
 	)
 
-	return fmt.Sprintf(`%s * on(%s) %s`, requestCPU, hpaMetricName, targetCPUUtilThreshold)
+	return fmt.Sprintf(
+		`(%s * on(%s) %s) or (%s * on(%s) %s)`,
+		requestCPUOne, hpaMetricName, targetCPUUtilThresholdOne,
+		requestCPUTwo, hpaMetricName, targetCPUUtilThresholdTwo,
+	)
 }
 
 func createHPAAbsoluteMemoryThresholdQuery(memMetricName, metricName, podSelectionRegex, hpaName, namespace, appLabel, hpaMetricName string) string {
-	kubeMetricsPodSelector := getKubeMetricsPodSelector(podSelectionRegex, namespace)
+	kubeMetricsPodSelectorOne := getKubeMetricsPodSelector(podSelectionRegex, namespace, "namespace")
+	kubeMetricsPodSelectorTwo := getKubeMetricsPodSelector(podSelectionRegex, namespace, "exported_namespace")
 
-	kubeMetricsHPASelector := fmt.Sprintf(
+	kubeMetricsHPASelectorOne := fmt.Sprintf(
 		`%s="%s",namespace="%s",metric_name="memory",metric_target_type="utilization"`,
 		hpaMetricName,
 		hpaName,
 		namespace,
 	)
 
+	kubeMetricsHPASelectorTwo := fmt.Sprintf(
+		`%s="%s",exported_namespace="%s",metric_name="memory",metric_target_type="utilization"`,
+		hpaMetricName,
+		hpaName,
+		namespace,
+	)
+
 	if memMetricName == "kube_pod_container_resource_requests" {
-		kubeMetricsPodSelector += `,resource="memory",unit="byte"`
+		kubeMetricsPodSelectorOne += `,resource="memory",unit="byte"`
+		kubeMetricsPodSelectorTwo += `,resource="memory",unit="byte"`
 	}
 
 	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
 	// as well
 	if appLabel != "" {
-		kubeMetricsPodSelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
-		kubeMetricsHPASelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsPodSelectorOne += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsPodSelectorTwo += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelectorOne += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelectorTwo += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
 	}
 
-	requestMem := fmt.Sprintf(
+	requestMemOne := fmt.Sprintf(
 		`sum by (%s) (label_replace(%s{%s},"%s", "%s", "", ""))`,
 		hpaMetricName,
 		memMetricName,
-		kubeMetricsPodSelector,
+		kubeMetricsPodSelectorOne,
 		hpaMetricName,
 		hpaName,
 	)
 
-	targetMemUtilThreshold := fmt.Sprintf(
+	targetMemUtilThresholdOne := fmt.Sprintf(
 		`%s{%s} / 100`,
 		metricName,
-		kubeMetricsHPASelector,
+		kubeMetricsHPASelectorOne,
 	)
 
-	return fmt.Sprintf(`%s * on(%s) %s`, requestMem, hpaMetricName, targetMemUtilThreshold)
+	requestMemTwo := fmt.Sprintf(
+		`sum by (%s) (label_replace(%s{%s},"%s", "%s", "", ""))`,
+		hpaMetricName,
+		memMetricName,
+		kubeMetricsPodSelectorTwo,
+		hpaMetricName,
+		hpaName,
+	)
+
+	targetMemUtilThresholdTwo := fmt.Sprintf(
+		`%s{%s} / 100`,
+		metricName,
+		kubeMetricsHPASelectorTwo,
+	)
+
+	fmt.Println("query is:")
+
+	return fmt.Sprintf(
+		`(%s * on(%s) %s) or (%s * on(%s) %s)`,
+		requestMemOne, hpaMetricName, targetMemUtilThresholdOne,
+		requestMemTwo, hpaMetricName, targetMemUtilThresholdTwo,
+	)
 }
 
-func getKubeMetricsPodSelector(podSelectionRegex, namespace string) string {
+func getKubeMetricsPodSelector(podSelectionRegex, namespace, namespaceLabel string) string {
 	return fmt.Sprintf(
-		`pod=~"%s",namespace="%s",container!="POD",container!=""`,
+		`pod=~"%s",%s="%s",container!="POD",container!=""`,
 		podSelectionRegex,
+		namespaceLabel,
 		namespace,
 	)
 }
 
 func createHPACurrentReplicasQuery(metricName, hpaName, namespace, appLabel, hpaMetricName string) string {
-	kubeMetricsHPASelector := fmt.Sprintf(
+	kubeMetricsHPASelectorOne := fmt.Sprintf(
 		`%s="%s",namespace="%s"`,
 		hpaMetricName,
 		hpaName,
 		namespace,
 	)
 
+	kubeMetricsHPASelectorTwo := fmt.Sprintf(
+		`%s="%s",exported_namespace="%s"`,
+		hpaMetricName,
+		hpaName,
+		namespace,
+	)
+
 	// the kube-state-metrics queries are less prone to error if the field app_kubernetes_io_instance is matched
 	// as well
 	if appLabel != "" {
-		kubeMetricsHPASelector += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelectorOne += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
+		kubeMetricsHPASelectorTwo += fmt.Sprintf(`,app_kubernetes_io_instance="%s"`, appLabel)
 	}
 
 	return fmt.Sprintf(
-		`%s{%s}`,
+		`(%s{%s}) or (%s{%s})`,
+		metricName,
+		kubeMetricsHPASelectorOne,
 		metricName,
-		kubeMetricsHPASelector,
+		kubeMetricsHPASelectorTwo,
 	)
 }
 

+ 2 - 6
provisioner/server/handlers/state/delete_resource.go

@@ -68,13 +68,9 @@ func (c *DeleteResourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	// switch on the kind of resource and write the corresponding objects to the database
 	switch infra.Kind {
-	case types.InfraECR:
-	case types.InfraGCR:
-	case types.InfraDOCR:
+	case types.InfraECR, types.InfraGCR, types.InfraDOCR:
 		_, err = deleteRegistry(c.Config, infra, operation)
-	case types.InfraEKS:
-	case types.InfraDOKS:
-	case types.InfraGKE:
+	case types.InfraEKS, types.InfraDOKS, types.InfraGKE:
 		_, err = deleteCluster(c.Config, infra, operation)
 	case types.InfraRDS:
 		_, err = deleteDatabase(c.Config, infra, operation)