Explorar o código

Merge branch 'master' into sms/helm-convert

Stefan McShane %!s(int64=2) %!d(string=hai) anos
pai
achega
d50cf4f5b9
Modificáronse 40 ficheiros con 2689 adicións e 316 borrados
  1. 110 44
      api/server/handlers/porter_app/create_and_update_events.go
  2. 1 1
      api/server/handlers/project_integration/preflight_check.go
  3. 5 2
      api/types/porter_app.go
  4. 13 13
      dashboard/package-lock.json
  5. 1 1
      dashboard/package.json
  6. BIN=BIN
      dashboard/src/assets/user-icon.png
  7. 2 39
      dashboard/src/components/GCPCredentialsForm.tsx
  8. 129 97
      dashboard/src/components/GCPProvisionerSettings.tsx
  9. 361 38
      dashboard/src/components/PreflightChecks.tsx
  10. 0 8
      dashboard/src/components/ProvisionerForm.tsx
  11. 127 37
      dashboard/src/components/ProvisionerSettings.tsx
  12. 1 0
      dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx
  13. 5 2
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  14. 20 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx
  15. 288 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/ActivityFeed.tsx
  16. 103 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx
  17. 117 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/BuildEventCard.tsx
  18. 230 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx
  19. 55 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx
  20. 99 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx
  21. 137 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/ServiceStatusDetail.tsx
  22. 278 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx
  23. 71 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/DeployEventFocusView.tsx
  24. 129 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx
  25. 70 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx
  26. 89 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts
  27. 104 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts
  28. 1 0
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  29. 1 0
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/AreaChart.tsx
  30. 12 6
      dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsSection.tsx
  31. 1 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/CustomDomains.tsx
  32. 4 13
      dashboard/src/main/home/navbar/Help.tsx
  33. 37 9
      dashboard/src/main/home/navbar/Navbar.tsx
  34. 17 1
      dashboard/src/shared/util.ts
  35. 1 1
      go.mod
  36. 2 2
      go.sum
  37. 5 2
      internal/models/porter_app_event.go
  38. 49 0
      internal/repository/gorm/porter_app_event.go
  39. 4 0
      internal/repository/porter_app_event.go
  40. 10 0
      internal/repository/test/porter_app_event.go

+ 110 - 44
api/server/handlers/porter_app/create_and_update_events.go

@@ -61,6 +61,7 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 		telemetry.AttributeKV{Key: "porter-app-event-status", Value: request.Status},
 		telemetry.AttributeKV{Key: "porter-app-event-external-source", Value: request.TypeExternalSource},
 		telemetry.AttributeKV{Key: "porter-app-event-id", Value: request.ID},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
 	)
 
 	if request.Type == types.PorterAppEventType_Build {
@@ -68,7 +69,7 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 	}
 
 	if request.ID == "" {
-		event, err := p.createNewAppEvent(ctx, *cluster, appName, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
+		event, err := p.createNewAppEvent(ctx, *cluster, appName, request.DeploymentTargetID, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
 		if err != nil {
 			e := telemetry.Error(ctx, span, err, "error creating new app event")
 			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
@@ -116,7 +117,7 @@ func reportBuildStatus(ctx context.Context, request *types.CreateOrUpdatePorterA
 }
 
 // createNewAppEvent will create a new app event for the given porter app name. If the app event is an agent event, then it will be created only if there is no existing event which has the agent ID. In the case that an existing event is found, that will be returned instead
-func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, status types.PorterAppEventStatus, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
+func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, deploymentTargetID string, status types.PorterAppEventStatus, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
 	ctx, span := telemetry.NewSpan(ctx, "create-porter-app-event")
 	defer span.End()
 
@@ -138,13 +139,29 @@ func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Contex
 		// Agent has no way to know what the porter app event id is, so if we must dedup here
 		// TODO: create a filter to filter by only agent events. Not an issue now as app events are deduped per hour on the agent side
 		if agentEventID, ok := requestMetadata["agent_event_id"]; ok {
-			existingEvents, _, err := p.Repo().PorterAppEvent().ListEventsByPorterAppID(ctx, app.ID)
-			if err != nil {
-				return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error listing porter app events for event type")
+			var existingEvents []*models.PorterAppEvent
+			if deploymentTargetID == "" {
+				existingEvents, _, err = p.Repo().PorterAppEvent().ListEventsByPorterAppID(ctx, app.ID)
+				if err != nil {
+					return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error listing porter app events for event type")
+				}
+			} else {
+				deploymentTargetUUID, err := uuid.Parse(deploymentTargetID)
+				if err != nil {
+					return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error parsing deployment target id")
+				}
+				if deploymentTargetUUID == uuid.Nil {
+					return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "deployment target id cannot be nil")
+				}
+
+				existingEvents, _, err = p.Repo().PorterAppEvent().ListEventsByPorterAppIDAndDeploymentTargetID(ctx, app.ID, deploymentTargetUUID)
+				if err != nil {
+					return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error listing porter app events for event type with deployment target id")
+				}
 			}
 
 			for _, existingEvent := range existingEvents {
-				if existingEvent.Type == eventType {
+				if existingEvent != nil && existingEvent.Type == eventType {
 					existingAgentEventID, ok := existingEvent.Metadata["agent_event_id"]
 					if !ok {
 						continue
@@ -163,7 +180,7 @@ func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Contex
 	if eventType == string(types.PorterAppEventType_Deploy) {
 		// Agent has no way to know what the porter app event id is, so update the deploy event if it exists
 		if _, ok := requestMetadata["deploy_status"]; ok {
-			return p.updateDeployEvent(ctx, porterAppName, app.ID, requestMetadata), nil
+			return p.updateDeployEvent(ctx, porterAppName, app.ID, deploymentTargetID, requestMetadata), nil
 		}
 	}
 
@@ -176,6 +193,17 @@ func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Contex
 		Metadata:           make(map[string]any),
 	}
 
+	if deploymentTargetID != "" {
+		deploymentTargetUUID, err := uuid.Parse(deploymentTargetID)
+		if err != nil {
+			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error parsing deployment target id")
+		}
+		if deploymentTargetUUID == uuid.Nil {
+			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "deployment target id cannot be nil")
+		}
+		event.DeploymentTargetID = deploymentTargetUUID
+	}
+
 	for k, v := range requestMetadata {
 		event.Metadata[k] = v
 	}
@@ -246,40 +274,86 @@ func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.C
 // 4. the services specified in the updatedStatusMetadata match the services in the deploy event metadata
 // 5. some of the above services are still in the PROGRESSING state
 // if one of these conditions is not met, then an empty event is returned and no update is made; otherwise, the matched event is returned
-func (p *CreateUpdatePorterAppEventHandler) updateDeployEvent(ctx context.Context, appName string, appID uint, updatedStatusMetadata map[string]any) types.PorterAppEvent {
+func (p *CreateUpdatePorterAppEventHandler) updateDeployEvent(ctx context.Context, appName string, appID uint, deploymentTargetID string, updatedStatusMetadata map[string]any) types.PorterAppEvent {
 	ctx, span := telemetry.NewSpan(ctx, "update-deploy-event")
 	defer span.End()
 
-	revision, ok := updatedStatusMetadata["revision"]
-	if !ok {
-		_ = telemetry.Error(ctx, span, nil, "revision not found in request metadata")
-		return types.PorterAppEvent{}
-	}
-	revisionFloat64, ok := revision.(float64)
-	if !ok {
-		_ = telemetry.Error(ctx, span, nil, "revision not a float64")
-		return types.PorterAppEvent{}
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision", Value: revisionFloat64})
+	var serviceName string
+	var matchEvent models.PorterAppEvent
 
-	podName, ok := updatedStatusMetadata["pod_name"]
-	if !ok {
-		_ = telemetry.Error(ctx, span, nil, "pod name not found in request metadata")
-		return types.PorterAppEvent{}
-	}
-	podNameStr, ok := podName.(string)
-	if !ok {
-		_ = telemetry.Error(ctx, span, nil, "pod name not a string")
-		return types.PorterAppEvent{}
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: podNameStr})
+	if deploymentTargetID != "" {
+		appRevisionIDField, ok := updatedStatusMetadata["app_revision_id"]
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "app_revision_id not found in request metadata")
+			return types.PorterAppEvent{}
+		}
+		appRevisionID, ok := appRevisionIDField.(string)
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "appRevisionID is not a string")
+			return types.PorterAppEvent{}
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: appRevisionID})
 
-	serviceName := getServiceNameFromPodName(podNameStr, appName)
-	if serviceName == "" {
-		_ = telemetry.Error(ctx, span, nil, "service name not found in pod name")
-		return types.PorterAppEvent{}
+		serviceNameField, ok := updatedStatusMetadata["service_name"]
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "service_name not found in request metadata")
+			return types.PorterAppEvent{}
+		}
+		serviceName, ok = serviceNameField.(string)
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "serviceName is not a string")
+			return types.PorterAppEvent{}
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: serviceName})
+
+		var err error
+		matchEvent, err = p.Repo().PorterAppEvent().ReadDeployEventByAppRevisionID(ctx, appID, appRevisionID)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error finding matching deploy event")
+			return types.PorterAppEvent{}
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: false})
+	} else {
+		revision, ok := updatedStatusMetadata["revision"]
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "revision not found in request metadata")
+			return types.PorterAppEvent{}
+		}
+		revisionFloat64, ok := revision.(float64)
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "revision not a float64")
+			return types.PorterAppEvent{}
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision", Value: revisionFloat64})
+
+		podName, ok := updatedStatusMetadata["pod_name"]
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "pod name not found in request metadata")
+			return types.PorterAppEvent{}
+		}
+		podNameStr, ok := podName.(string)
+		if !ok {
+			_ = telemetry.Error(ctx, span, nil, "pod name not a string")
+			return types.PorterAppEvent{}
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: podNameStr})
+
+		serviceName = getServiceNameFromPodName(podNameStr, appName)
+		if serviceName == "" {
+			_ = telemetry.Error(ctx, span, nil, "service name not found in pod name")
+			return types.PorterAppEvent{}
+		}
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: serviceName})
+
+		var err error
+		matchEvent, err = p.Repo().PorterAppEvent().ReadDeployEventByRevision(ctx, appID, revisionFloat64)
+		if err != nil {
+			_ = telemetry.Error(ctx, span, err, "error finding matching deploy event")
+			return types.PorterAppEvent{}
+		}
+
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: false})
 	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: serviceName})
 
 	newStatus, ok := updatedStatusMetadata["deploy_status"]
 	if !ok {
@@ -306,14 +380,6 @@ func (p *CreateUpdatePorterAppEventHandler) updateDeployEvent(ctx context.Contex
 
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "new-status", Value: string(porterAppEventStatus)})
 
-	matchEvent, err := p.Repo().PorterAppEvent().ReadDeployEventByRevision(ctx, appID, revisionFloat64)
-	if err != nil {
-		_ = telemetry.Error(ctx, span, err, "error finding matching deploy event")
-		return types.PorterAppEvent{}
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: false})
-
 	// first check to see if the event is empty, meaning there was no match found, or not progressing, meaning it has already been updated
 	if matchEvent.ID == uuid.Nil || matchEvent.Status != string(types.PorterAppEventStatus_Progressing) {
 		return types.PorterAppEvent{}
@@ -382,7 +448,7 @@ func (p *CreateUpdatePorterAppEventHandler) updateDeployEvent(ctx context.Contex
 			}
 		}
 
-		err = p.Repo().PorterAppEvent().UpdateEvent(ctx, &matchEvent)
+		err := p.Repo().PorterAppEvent().UpdateEvent(ctx, &matchEvent)
 		if err != nil {
 			_ = telemetry.Error(ctx, span, err, "error updating deploy event")
 			return matchEvent.ToPorterAppEvent()

+ 1 - 1
api/server/handlers/project_integration/preflight_check.go

@@ -53,7 +53,7 @@ func (p *CreatePreflightCheckHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	}
 
 	if cloudValues.PreflightValues != nil {
-		if cloudValues.CloudProvider == porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_GCP {
+		if cloudValues.CloudProvider == porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_GCP || cloudValues.CloudProvider == porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS {
 			input.PreflightValues = cloudValues.PreflightValues
 		}
 	}

+ 5 - 2
api/types/porter_app.go

@@ -93,8 +93,10 @@ type PorterAppEvent struct {
 	// UpdatedAt is the time (UTC) that an event was last updated. This can occur when an event was created as PROGRESSING, then was marked as SUCCESSFUL for example
 	UpdatedAt time.Time `json:"updated_at"`
 	// PorterAppID is the ID that the given event relates to
-	PorterAppID uint           `json:"porter_app_id"`
-	Metadata    map[string]any `json:"metadata,omitempty"`
+	PorterAppID uint `json:"porter_app_id"`
+	// DeploymentTargetID is the ID of the deployment target that the given event relates to
+	DeploymentTargetID string         `json:"deployment_target_id"`
+	Metadata           map[string]any `json:"metadata,omitempty"`
 }
 
 // PorterAppEventType is an alias for a string that represents a Porter Stack Event Type
@@ -137,6 +139,7 @@ type CreateOrUpdatePorterAppEventRequest struct {
 	// TypeExternalSource represents an external event source such as Github, or Gitlab. This is not always required but will commonly be see in build events
 	TypeExternalSource string         `json:"type_source,omitempty"`
 	Metadata           map[string]any `json:"metadata,omitempty"`
+	DeploymentTargetID string         `json:"deployment_target_id"`
 }
 
 // ServiceDeploymentMetadata contains information about a service when it deploys

+ 13 - 13
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.99",
+        "@porter-dev/api-contracts": "^0.0.100",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -1952,9 +1952,9 @@
       }
     },
     "node_modules/@bufbuild/protobuf": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.0.tgz",
-      "integrity": "sha512-G372ods0pLt46yxVRsnP/e2btVPuuzArcMPFpIDeIwiGPuuglEs9y75iG0HMvZgncsj5TvbYRWqbVyOe3PLCWQ=="
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.1.tgz",
+      "integrity": "sha512-BUyJWutgP2S8K/1NphOJokuwDckXS4qI2T1pGZAlkFdZchWae3jm6fCdkcGbLlM1QLOcNFFePd+7Feo4BYGrJQ=="
     },
     "node_modules/@discoveryjs/json-ext": {
       "version": "0.5.7",
@@ -2454,9 +2454,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.99",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.99.tgz",
-      "integrity": "sha512-boropiMEHIXJLTKxmO6689GhIMiTC95JMkL1ouFxn2mkiT6DPcJ08UfD5tKohUMYGhgQNJceBQ1biPVjn5nqJQ==",
+      "version": "0.0.100",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.100.tgz",
+      "integrity": "sha512-Y17fzm6HHmClFnMEWgwr178wZBTOuF17903/2icG/u4CA9JhtVgH6QvSzYcJ/Eu0kX+7pXm6pw24bxagFIeivA==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16595,9 +16595,9 @@
       }
     },
     "@bufbuild/protobuf": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.0.tgz",
-      "integrity": "sha512-G372ods0pLt46yxVRsnP/e2btVPuuzArcMPFpIDeIwiGPuuglEs9y75iG0HMvZgncsj5TvbYRWqbVyOe3PLCWQ=="
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.1.tgz",
+      "integrity": "sha512-BUyJWutgP2S8K/1NphOJokuwDckXS4qI2T1pGZAlkFdZchWae3jm6fCdkcGbLlM1QLOcNFFePd+7Feo4BYGrJQ=="
     },
     "@discoveryjs/json-ext": {
       "version": "0.5.7",
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.99",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.99.tgz",
-      "integrity": "sha512-boropiMEHIXJLTKxmO6689GhIMiTC95JMkL1ouFxn2mkiT6DPcJ08UfD5tKohUMYGhgQNJceBQ1biPVjn5nqJQ==",
+      "version": "0.0.100",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.100.tgz",
+      "integrity": "sha512-Y17fzm6HHmClFnMEWgwr178wZBTOuF17903/2icG/u4CA9JhtVgH6QvSzYcJ/Eu0kX+7pXm6pw24bxagFIeivA==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.99",
+    "@porter-dev/api-contracts": "^0.0.100",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

BIN=BIN
dashboard/src/assets/user-icon.png


+ 2 - 39
dashboard/src/components/GCPCredentialsForm.tsx

@@ -12,9 +12,6 @@ import Text from "components/porter/Text";
 import Button from "components/porter/Button";
 import Spacer from "./porter/Spacer";
 import Container from "./porter/Container";
-import PreflightChecks from "./PreflightChecks";
-import { EnumCloudProvider, GKENetwork, GKEPreflightValues, PreflightCheckRequest } from "@porter-dev/api-contracts";
-
 
 
 type Props = {
@@ -31,8 +28,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
   const [errorMessage, setErrorMessage] = useState("");
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
   const [gcpCloudProviderCredentialID, setGCPCloudProviderCredentialId] = useState<string>("")
-  const [preFlightData, setPreflightData] = useState(null)
-  const [preflightFailed, setPreflightFailed] = useState<boolean>(true)
+
 
   useEffect(() => {
     setDetected(undefined);
@@ -83,25 +79,6 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
       }
       setGCPCloudProviderCredentialId(gcpIntegrationResponse.data.cloud_provider_credentials_id)
       setIsLoading(false)
-
-      if (gcpIntegrationResponse?.data?.cloud_provider_credentials_id) {
-        setIsLoading(true);
-        var data = new PreflightCheckRequest({
-          projectId: BigInt(currentProject.id),
-          cloudProvider: EnumCloudProvider.GCP,
-          cloudProviderCredentialsId: gcpIntegrationResponse.data.cloud_provider_credentials_id
-
-        })
-        const preflightDataResp = await api.preflightCheck(
-          "<token>", data,
-          {
-            id: currentProject.id,
-          }
-        )
-        setPreflightData(preflightDataResp?.data?.Msg);
-        setIsLoading(false)
-
-      }
     }
     catch (err) {
       setIsLoading(false)
@@ -197,27 +174,13 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
             </Text>
           </AppearingDiv>
           <Spacer y={1} />
-          {isLoading ?
-            <>
-              <Placeholder>
-                <Loading />
-              </Placeholder>
-
-            </>
-            :
-
-            preFlightData ?
-              (<PreflightChecks preflightData={preFlightData} setPreflightFailed={setPreflightFailed} />)
-              : (<Text>  Could not perform preflight checks on your account. Please verify your credentials are correct or contact Porter Support at support@porter.run</Text>)
-
-          }
         </>
       </>
       )}
 
       <Spacer y={0.5} />
       <Button
-        disabled={!isContinueEnabled || preflightFailed || isLoading}
+        disabled={!isContinueEnabled || isLoading}
         onClick={saveCredentials}
       >Continue</Button>
 

+ 129 - 97
dashboard/src/components/GCPProvisionerSettings.tsx

@@ -37,6 +37,7 @@ import Placeholder from "./Placeholder";
 import Fieldset from "./porter/Fieldset";
 import ExpandableSection from "./porter/ExpandableSection";
 import PreflightChecks from "./PreflightChecks";
+import VerticalSteps from "./porter/VerticalSteps";
 
 
 const locationOptions = [
@@ -73,6 +74,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
     setCurrentCluster,
     setShouldRefreshClusters,
   } = useContext(Context);
+  const [step, setStep] = useState(0);
   const [createStatus, setCreateStatus] = useState("");
   const [clusterName, setClusterName] = useState("");
   const [region, setRegion] = useState(locationOptions[0].value);
@@ -84,8 +86,8 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
-  const [preflightData, setPreflightData] = useState({})
-  const [preflightFailed, setPreflightFailed] = useState<boolean>(false)
+  const [preflightData, setPreflightData] = useState(null)
+  const [preflightFailed, setPreflightFailed] = useState<boolean>(true)
   const [isLoading, setIsLoading] = useState(false);
   const [isExpanded, setIsExpanded] = useState(false);
 
@@ -367,6 +369,8 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
 
   useEffect(() => {
     if (statusPreflight() == "" && !props.clusterId) {
+      setStep(1)
+      setPreflightData(null)
       preflightChecks()
     }
 
@@ -374,8 +378,7 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
 
   const preflightChecks = async () => {
     setIsLoading(true);
-
-
+    setPreflightData(null);
     var data = new PreflightCheckRequest({
       projectId: BigInt(currentProject.id),
       cloudProvider: EnumCloudProvider.GCP,
@@ -398,6 +401,23 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
         id: currentProject.id,
       }
     )
+    // Check if any of the preflight checks has a message
+    let hasMessage = false;
+    let errors = "Preflight Checks Failed : ";
+    for (let check in preflightDataResp?.data?.Msg.preflight_checks) {
+      if (preflightDataResp?.data?.Msg.preflight_checks[check]?.message) {
+        hasMessage = true;
+        errors = errors + check + ", "
+      }
+    }
+    // If none of the checks have a message, set setPreflightFailed to false
+    if (hasMessage) {
+      markStepStarted("provisioning-failed", errors);
+    }
+    if (!hasMessage) {
+      setPreflightFailed(false);
+      setStep(2);
+    }
     setPreflightData(preflightDataResp?.data?.Msg);
     setIsLoading(false)
 
@@ -407,100 +427,112 @@ const GCPProvisionerSettings: React.FC<Props> = (props) => {
     // Render simplified form if initial create
     if (!props.clusterId) {
       return (
-        <>
-          <Text size={16}>Select a Google Cloud Region for your cluster</Text>
-          <Spacer y={1} />
-          <Text color="helper">
-            Porter will provision your infrastructure in the
-            specified location.
-          </Text>
-          <Spacer height="10px" />
+        <VerticalSteps
+          currentStep={step}
+          steps={[
+            <>
+              <Text size={16}>Select a Google Cloud Region for your cluster</Text>
+              <Spacer y={1} />
+              <Text color="helper">
+                Porter will provision your infrastructure in the
+                specified location.
+              </Text>
+              <Spacer height="10px" />
+              <SelectRow
+                options={locationOptions}
+                width="350px"
+                disabled={isReadOnly}
+                value={region}
+                scrollBuffer={true}
+                dropdownMaxHeight="240px"
+                setActiveValue={setRegion}
+                label="📍 GCP location" />
+              {renderAdvancedSettings()}
+
+            </>,
+            <>
+              <PreflightChecks provider="GCP" preflightData={preflightData} />
+              <Spacer y={.5} />
+              {(preflightFailed && preflightData) &&
+                <>
+                  <Text color="helper">
+                    Preflight checks for the account didn't pass. Please fix the issues and retry.
+                  </Text>
+                  < Button
+                    // disabled={isDisabled()}
+                    disabled={isLoading}
+                    onClick={preflightChecks}
+                  >
+                    Retry Checks
+                  </Button>
+                </>
+              }
+            </>,
+            <>
+              <Text size={16}>Provision your cluster</Text>
+              <Spacer y={1} />
+              <Button
+                disabled={isDisabled() || isLoading || preflightFailed || statusPreflight() != ""}
+                onClick={createCluster}
+                status={getStatus()}
+              >
+                Provision
+              </Button><Spacer y={1} /></>
+          ].filter((x) => x)}
+        />
+      );
+    }
+
+    // If settings, update full form
+    return (
+      <>
+        <StyledForm>
+          <Heading isAtTop>GCP configuration</Heading>
           <SelectRow
             options={locationOptions}
             width="350px"
-            disabled={isReadOnly}
+            disabled={isReadOnly || true}
             value={region}
             scrollBuffer={true}
             dropdownMaxHeight="240px"
             setActiveValue={setRegion}
-            label="📍 GCP location"
+            label="📍 Google Cloud Region"
           />
-          {renderAdvancedSettings()}
-
-        </>
-      );
-    }
-
-    // If settings, update full form
-    return (
-      <>
-        <Heading isAtTop>GCP configuration</Heading>
-        <SelectRow
-          options={locationOptions}
-          width="350px"
-          disabled={isReadOnly || true}
-          value={region}
-          scrollBuffer={true}
-          dropdownMaxHeight="240px"
-          setActiveValue={setRegion}
-          label="📍 Google Cloud Region"
-        />
-        <SelectRow
-          options={clusterVersionOptions}
-          width="350px"
-          disabled={isReadOnly}
-          value={clusterVersion}
-          scrollBuffer={true}
-          dropdownMaxHeight="240px"
-          setActiveValue={setClusterVersion}
-          label="Cluster version"
-        />
-      </>
-    );
-  };
-
-  return (
-    <>
-      <StyledForm>{renderForm()}</StyledForm>
+          <SelectRow
+            options={clusterVersionOptions}
+            width="350px"
+            disabled={isReadOnly}
+            value={clusterVersion}
+            scrollBuffer={true}
+            dropdownMaxHeight="240px"
+            setActiveValue={setClusterVersion}
+            label="Cluster version"
+          />
+        </StyledForm>
 
-      {props.credentialId && (<>
+        <Button
+          disabled={isDisabled() || isLoading || preflightFailed || statusPreflight() != ""}
+          onClick={createCluster}
+          status={getStatus()}
+        >
+          Provision
+        </Button>
 
-        {isLoading ?
+        {
+          (!currentProject?.enable_reprovision && props.clusterId) &&
           <>
-            <Placeholder>
-              <Loading />
-            </Placeholder>
             <Spacer y={1} />
-          </>
-          :
-          <>
-            {(!props.clusterId) &&
-              <>
-                <PreflightChecks preflightData={preflightData} setPreflightFailed={setPreflightFailed} />
-                <Spacer y={1} />
-              </>
-            }
+            <Text>Updates to the cluster are disabled on this project. Enable re-provisioning by contacting <a href="mailto:support@porter.run">Porter Support</a>.</Text>
           </>
         }
-
       </>
-      )}
+    );
+  };
 
-      <Button
-        disabled={isDisabled() || isLoading || preflightFailed || statusPreflight() != ""}
-        onClick={createCluster}
-        status={getStatus()}
-      >
-        Provision
-      </Button>
+  return (
+    <>
+      {renderForm()}
 
-      {
-        (!currentProject?.enable_reprovision && props.clusterId) &&
-        <>
-          <Spacer y={1} />
-          <Text>Updates to the cluster are disabled on this project. Enable re-provisioning by contacting <a href="mailto:support@porter.run">Porter Support</a>.</Text>
-        </>
-      }
 
       {user.isPorterUser &&
         <>
@@ -525,14 +557,14 @@ export default withRouter(GCPProvisionerSettings);
 
 
 const StyledForm = styled.div`
-      position: relative;
-      padding: 30px 30px 25px;
-      border-radius: 5px;
-      background: ${({ theme }) => theme.fg};
-      border: 1px solid #494b4f;
-      font-size: 13px;
-      margin-bottom: 30px;
-      `;
+              position: relative;
+              padding: 30px 30px 25px;
+              border-radius: 5px;
+              background: ${({ theme }) => theme.fg};
+              border: 1px solid #494b4f;
+              font-size: 13px;
+              margin-bottom: 30px;
+              `;
 
 const DEFAULT_ERROR_MESSAGE =
   "An error occurred while provisioning your infrastructure. Please try again.";
@@ -545,14 +577,14 @@ const errorMessageToModal = (errorMessage: string) => {
 };
 
 const ExpandHeader = styled.div<{ isExpanded: boolean }>`
-  display: flex;
-  align-items: center;
-  cursor: pointer;
+              display: flex;
+              align-items: center;
+              cursor: pointer;
   > i {
-    margin-right: 7px;
-    margin-left: -7px;
-    transform: ${(props) =>
+                margin - right: 7px;
+              margin-left: -7px;
+              transform: ${(props) =>
     props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
-    transition: transform 0.1s ease;
+              transition: transform 0.1s ease;
   }
-`;
+              `;

+ 361 - 38
dashboard/src/components/PreflightChecks.tsx

@@ -2,75 +2,110 @@ import React, { useEffect, useState, useContext } from "react";
 import styled from "styled-components";
 import { RouteComponentProps, withRouter } from "react-router";
 import Spacer from "./porter/Spacer";
-
+import Step from "./porter/Step";
+import Link from "./porter/Link";
 import Text from "./porter/Text";
+import Error from "./porter/Error";
 import healthy from "assets/status-healthy.png";
 import failure from "assets/failure.svg";
-import { PREFLIGHT_MESSAGE_CONST } from "shared/util";
-
+import { PREFLIGHT_MESSAGE_CONST, PREFLIGHT_MESSAGE_CONST_AWS, PREFLIGHT_MESSAGE_CONST_GCP } from "shared/util";
+import Loading from "./Loading";
 type Props = RouteComponentProps & {
   preflightData: any
-  setPreflightFailed: (x: boolean) => void;
-};
+  provider: 'AWS' | 'GCP' | 'DEFAULT';
 
+};
 
 const PreflightChecks: React.FC<Props> = (props) => {
-  const [trackFailures, setFailures] = useState<boolean>(false)
-  const PreflightCheckItem = ({ check }) => {
-    const [isExpanded, setIsExpanded] = useState(false);
-    const hasMessage = !!check.value?.message;
-    if (hasMessage) {
-      setFailures(hasMessage)
+  const getMessageConstByProvider = (provider: 'AWS' | 'GCP' | 'DEFAULT') => {
+    switch (provider) {
+      case 'AWS':
+        return PREFLIGHT_MESSAGE_CONST_AWS;
+      case 'GCP':
+        return PREFLIGHT_MESSAGE_CONST_GCP;
+      default:
+        return PREFLIGHT_MESSAGE_CONST;
     }
+  };
+  const currentMessageConst = getMessageConstByProvider(props.provider);
+
+  const PreflightCheckItem = ({ checkKey }) => {
+    // Using optional chaining to prevent potential null/undefined errors
+    const checkData = props.preflightData?.preflight_checks?.[checkKey];
+    const hasMessage = checkData?.message;
+
+    const [isExpanded, setIsExpanded] = useState(false);
+
     const handleToggle = () => {
       if (hasMessage) {
         setIsExpanded(!isExpanded);
       }
-    }
-    props.setPreflightFailed(trackFailures)
+    };
+
+
+
     return (
-      <CheckItemContainer hasMessage={hasMessage} onClick={handleToggle}>
-        <CheckItemTop>
-          {hasMessage ? <StatusIcon src={failure} /> : <StatusIcon src={healthy} />}
+      <CheckItemContainer hasMessage={hasMessage}>
+        <CheckItemTop onClick={handleToggle}>
+          {!props.preflightData ? (
+            <Loading
+              offset="0px"
+              width="20px"
+              height="20px" />
+          ) : hasMessage ? (
+            <StatusIcon src={failure} />
+          ) : (
+            <StatusIcon src={healthy} />
+          )}
           <Spacer inline x={1} />
-          <Text style={{ marginLeft: '10px', flex: 1 }}>{PREFLIGHT_MESSAGE_CONST[check.key]}</Text>
+          <Text style={{ marginLeft: '10px', flex: 1 }}>{currentMessageConst[checkKey]}</Text>
           {hasMessage && <ExpandIcon className="material-icons" isExpanded={isExpanded}>
             arrow_drop_down
           </ExpandIcon>}
         </CheckItemTop>
         {isExpanded && hasMessage && (
           <div>
-            <ErrorMessageLabel>Error Message:</ErrorMessageLabel>
-            <ErrorMessageContent>{check.value.message}</ErrorMessageContent>
-            {check.value.metadata &&
-              Object.entries(check.value.metadata).map(([key, value]) => (
-                <div key={key}>
-                  <ErrorMessageLabel>{key}:</ErrorMessageLabel>
-                  <ErrorMessageContent>{value}</ErrorMessageContent>
-                </div>
+            <Error
+              message={checkData?.message}
+              ctaText={
+                checkData?.message !== DEFAULT_ERROR_MESSAGE
+                  ? "Troubleshooting steps"
+                  : null
+              }
+              errorModalContents={errorMessageToModal(checkData?.message)}
+            />
+            <Spacer y={.5} />
+            {checkData?.metadata &&
+              Object.entries(checkData.metadata).map(([key, value]) => (
+                <>
+                  <div key={key}>
+                    <ErrorMessageLabel>{key}:</ErrorMessageLabel>
+                    <ErrorMessageContent>{value}</ErrorMessageContent>
+                  </div>
+                </>
               ))}
           </div>
         )}
       </CheckItemContainer>
     );
   };
-
   return (
-    <div>
-      {props.preflightData && (
-        <AppearingDiv>
-          <Text> Preflight Checks </Text>
-          <Spacer y={.5} />
-          {Object.entries(props.preflightData.preflight_checks || {}).map(([key, value]) => (
-            <PreflightCheckItem key={key} check={{ key, value }} />
-          ))}
-        </AppearingDiv>
-      )}
-    </div>
+    <AppearingDiv>
+      <Text size={16}>Cluster provision check</Text>
+      <Spacer y={.5} />
+      <Text color="helper">
+        Porter checks that the account has the right permissions and resources to provision a cluster.
+      </Text>
+      <Spacer y={1} />
+      {Object.keys(currentMessageConst).map((checkKey) => (
+        <PreflightCheckItem key={checkKey} checkKey={checkKey} />
+      ))}
+    </AppearingDiv>
   );
 };
 
 
+
 export default withRouter(PreflightChecks);
 
 
@@ -136,4 +171,292 @@ const ErrorMessageContent = styled.div`
   margin-left: 10px;
   user-select: text;
   cursor: text
-`;
+`;
+
+const AWS_LOGIN_ERROR_MESSAGE =
+  "Porter could not access your AWS account. Please make sure you have granted permissions and try again.";
+const AWS_EIP_QUOTA_ERROR_MESSAGE =
+  "Your AWS account has reached the limit of elastic IPs allowed in the region. Additional addresses must be requested in order to provision.";
+const AWS_VPC_QUOTA_ERROR_MESSAGE =
+  "Your AWS account has reached the limit of VPCs allowed in the region. Additional VPCs must be requested in order to provision.";
+const AWS_NAT_GATEWAY_QUOTA_ERROR_MESSAGE =
+  "Your AWS account has reached the limit of NAT Gateways allowed in the region. Additional NAT Gateways must be requested in order to provision.";
+const AWS_VCPU_QUOTA_ERROR_MESSAGE =
+  "Your AWS account has reached the limit of vCPUs allowed in the region. Additional vCPUs must be requested in order to provision.";
+const DEFAULT_ERROR_MESSAGE =
+  "An error occurred while provisioning your infrastructure. Please try again.";
+
+const errorMessageToModal = (errorMessage: string) => {
+  switch (errorMessage) {
+    case AWS_LOGIN_ERROR_MESSAGE:
+      return (
+        <>
+          <Text size={16} weight={500}>
+            Granting Porter access to AWS
+          </Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            Porter needs access to your AWS account in order to create
+            infrastructure. You can grant Porter access to AWS by following
+            these steps:
+          </Text>
+          <Spacer y={1} />
+          <Step number={1}>
+            <Link
+              to="https://aws.amazon.com/resources/create-account/"
+              target="_blank"
+            >
+              Create an AWS account
+            </Link>
+            <Spacer inline width="5px" />
+            if you don't already have one.
+          </Step>
+          <Spacer y={1} />
+          <Step number={2}>
+            Once you are logged in to your AWS account,
+            <Spacer inline width="5px" />
+            <Link
+              to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account"
+              target="_blank"
+            >
+              copy your account ID
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={3}>
+            Fill in your account ID on Porter and select "Grant permissions".
+          </Step>
+          <Spacer y={1} />
+          <Step number={4}>
+            After being redirected to AWS, select "Create stack" on the AWS
+            console.
+          </Step>
+          <Spacer y={1} />
+          <Step number={5}>Return to Porter and select "Continue".</Step>
+        </>
+      );
+    case AWS_EIP_QUOTA_ERROR_MESSAGE:
+      return (
+        <>
+          <Text size={16} weight={500}>
+            Requesting more EIP Adresses
+          </Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            You will need to either request more EIP addresses or delete
+            existing ones in order to provision in the region specified. You can
+            request more addresses by following these steps:
+          </Text>
+          <Spacer y={1} />
+          <Step number={1}>
+            Log into
+            <Spacer inline width="5px" />
+            <Link
+              to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account"
+              target="_blank"
+            >
+              your AWS account
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={2}>
+            Navigate to
+            <Spacer inline width="5px" />
+            <Link
+              to="https://us-east-1.console.aws.amazon.com/servicequotas/home/services/ec2/quotas"
+              target="_blank"
+            >
+              the Amazon Elastic Compute Cloud (Amazon EC2) Service Quotas
+              portal
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={3}>
+            Search for "EC2-VPC Elastic IPs" in the search box and click on the
+            search result.
+          </Step>
+          <Spacer y={1} />
+          <Step number={4}>
+            Click on "Request quota increase". In order to provision with
+            Porter, you will need to request at least 3 addresses above your
+            current quota limit.
+          </Step>
+          <Spacer y={1} />
+          <Step number={5}>
+            Once that request is approved, return to Porter and retry the
+            provision.
+          </Step>
+        </>
+      );
+    case AWS_VPC_QUOTA_ERROR_MESSAGE:
+      return (
+        <>
+          <Text size={16} weight={500}>
+            Requesting more VPCs
+          </Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            You will need to either request more VPCs or delete existing ones in
+            order to provision in the region specified. You can request more
+            VPCs by following these steps:
+          </Text>
+          <Spacer y={1} />
+          <Step number={1}>
+            Log into
+            <Spacer inline width="5px" />
+            <Link
+              to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account"
+              target="_blank"
+            >
+              your AWS account
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={2}>
+            Navigate to
+            <Spacer inline width="5px" />
+            <Link
+              to="https://us-east-1.console.aws.amazon.com/servicequotas/home/services/vpc/quotas"
+              target="_blank"
+            >
+              the Amazon Virtual Private Cloud (Amazon VPC) Service Quotas
+              portal
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={3}>
+            Search for "VPCs per Region" in the search box and click on the
+            search result.
+          </Step>
+          <Spacer y={1} />
+          <Step number={4}>
+            Click on "Request quota increase". In order to provision with
+            Porter, you will need to request at least 1 VPCs above your current
+            quota limit.
+          </Step>
+          <Spacer y={1} />
+          <Step number={5}>
+            Once that request is approved, return to Porter and retry the
+            provision.
+          </Step>
+        </>
+      );
+    case AWS_NAT_GATEWAY_QUOTA_ERROR_MESSAGE:
+      return (
+        <>
+          <Text size={16} weight={500}>
+            Requesting more NAT Gateways
+          </Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            You will need to either request more NAT Gateways or delete existing
+            ones in order to provision in the region specified. You can request
+            more NAT Gateways by following these steps:
+          </Text>
+          <Spacer y={1} />
+          <Step number={1}>
+            Log into
+            <Spacer inline width="5px" />
+            <Link
+              to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account"
+              target="_blank"
+            >
+              your AWS account
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={2}>
+            Navigate to
+            <Spacer inline width="5px" />
+            <Link
+              to="https://us-east-1.console.aws.amazon.com/servicequotas/home/services/vpc/quotas"
+              target="_blank"
+            >
+              the Amazon Virtual Private Cloud (Amazon VPC) Service Quotas
+              portal
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={3}>
+            Search for "NAT gateways per Availability Zone" in the search box
+            and click on the search result.
+          </Step>
+          <Spacer y={1} />
+          <Step number={4}>
+            Click on "Request quota increase". In order to provision with
+            Porter, you will need to request at least 3 NAT Gateways above your
+            current quota limit.
+          </Step>
+          <Spacer y={1} />
+          <Step number={5}>
+            Once that request is approved, return to Porter and retry the
+            provision.
+          </Step>
+        </>
+      );
+    case AWS_VCPU_QUOTA_ERROR_MESSAGE:
+      return (
+        <>
+          <Text size={16} weight={500}>
+            Requesting more vCPUs
+          </Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            You will need to either request more vCPUs or delete existing
+            instances in order to provision in the region specified. You can
+            request more vCPUs by following these steps:
+          </Text>
+          <Spacer y={1} />
+          <Step number={1}>
+            Log into
+            <Spacer inline width="5px" />
+            <Link
+              to="https://console.aws.amazon.com/billing/home?region=us-east-1#/account"
+              target="_blank"
+            >
+              your AWS account
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={2}>
+            Navigate to
+            <Spacer inline width="5px" />
+            <Link
+              to="https://us-east-1.console.aws.amazon.com/servicequotas/home/services/ec2/quotas"
+              target="_blank"
+            >
+              the Amazon Elastic Compute Cloud (Amazon EC2) Service Quotas
+              portal
+            </Link>
+            .
+          </Step>
+          <Spacer y={1} />
+          <Step number={3}>
+            Search for "Running On-Demand Standard (A, C, D, H, I, M, R, T, Z)
+            instances" in the search box and click on the search result.
+          </Step>
+          <Spacer y={1} />
+          <Step number={4}>
+            Click on "Request quota increase". In order to provision with
+            Porter, you will need to request at least 10 vCPUs above your
+            current quota limit.
+          </Step>
+          <Spacer y={1} />
+          <Step number={5}>
+            Once that request is approved, return to Porter and retry the
+            provision.
+          </Step>
+        </>
+      );
+    default:
+      return null;
+  }
+};

+ 0 - 8
dashboard/src/components/ProvisionerForm.tsx

@@ -39,10 +39,6 @@ const ProvisionerForm: React.FC<Props> = ({
             <Text size={16}>Configure settings</Text>
           </Container>
           <Spacer y={1} />
-          <Text color="helper">
-            Configure settings for your AWS environment.
-          </Text>
-          <Spacer y={1} />
           <ProvisionerSettings credentialId={credentialId} />
         </>
       )}
@@ -58,10 +54,6 @@ const ProvisionerForm: React.FC<Props> = ({
             <Text size={16}>Configure settings</Text>
           </Container>
           <Spacer y={1} />
-          <Text color="helper">
-            Configure settings for your Azure environment.
-          </Text>
-          <Spacer y={1} />
           <AzureProvisionerSettings credentialId={credentialId} />
         </>
       )}

+ 127 - 37
dashboard/src/components/ProvisionerSettings.tsx

@@ -21,6 +21,9 @@ import {
   LoadBalancer,
   LoadBalancerType,
   EKSLogging,
+  EKSPreflightValues,
+  PreflightCheckRequest,
+  GKE
 } from "@porter-dev/api-contracts";
 import { ClusterType } from "shared/types";
 import Button from "./porter/Button";
@@ -35,6 +38,9 @@ import Checkbox from "./porter/Checkbox";
 import Tooltip from "./porter/Tooltip";
 import Icon from "./porter/Icon";
 import Loading from "./Loading";
+import PreflightChecks from "./PreflightChecks";
+import Placeholder from "./Placeholder";
+import VerticalSteps from "./porter/VerticalSteps";
 const regionOptions = [
   { value: "us-east-1", label: "US East (N. Virginia) us-east-1" },
   { value: "us-east-2", label: "US East (Ohio) us-east-2" },
@@ -101,6 +107,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   const [kmsEncryptionEnabled, setKmsEncryptionEnabled] = useState<boolean>(
     false
   );
+  const [step, setStep] = useState(0);
   const [loadBalancerType, setLoadBalancerType] = useState(false);
   const [wildCardDomain, setWildCardDomain] = useState("");
   const [IPAllowList, setIPAllowList] = useState<string>("");
@@ -124,6 +131,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   const [errorMessage, setErrorMessage] = useState<string>(undefined);
   const [isClicked, setIsClicked] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
+  const [preflightData, setPreflightData] = useState(null)
+  const [preflightFailed, setPreflightFailed] = useState<boolean>(true)
 
   const markStepStarted = async (step: string, errMessage?: string) => {
     try {
@@ -468,7 +477,57 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     }
   }, [isExpanded, props.selectedClusterVersion]);
 
+  useEffect(() => {
+    if (!props.clusterId) {
+      setStep(1)
+      setPreflightData(null)
+      preflightChecks()
+    }
+  }, [props.selectedClusterVersion, awsRegion]);
+
+
+  const preflightChecks = async () => {
+    setIsLoading(true);
+    setPreflightData(null);
 
+    var data = new PreflightCheckRequest({
+      projectId: BigInt(currentProject.id),
+      cloudProvider: EnumCloudProvider.AWS,
+      cloudProviderCredentialsId: props.credentialId,
+      preflightValues: {
+        case: "eksPreflightValues",
+        value: new EKSPreflightValues({
+          region: awsRegion,
+        })
+      }
+    });
+    const preflightDataResp = await api.preflightCheck(
+      "<token>", data,
+      {
+        id: currentProject.id,
+      }
+    )
+    // Check if any of the preflight checks has a message
+    let hasMessage = false;
+    let errors = "Preflight Checks Failed : ";
+    for (let check in preflightDataResp?.data?.Msg.preflight_checks) {
+      if (preflightDataResp?.data?.Msg.preflight_checks[check]?.message) {
+        hasMessage = true;
+        errors = errors + check + ", "
+      }
+    }
+    // If none of the checks have a message, set setPreflightFailed to false
+    if (hasMessage) {
+      markStepStarted("provisioning-failed", errors);
+    }
+    if (!hasMessage) {
+      setPreflightFailed(false);
+      setStep(2);
+    }
+    setPreflightData(preflightDataResp?.data?.Msg);
+    setIsLoading(false)
+
+  }
   const renderAdvancedSettings = () => {
     return (
       <>
@@ -915,33 +974,66 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     // Render simplified form if initial create
     if (!props.clusterId) {
       return (
-        <>
-          <Text size={16}>Select an AWS region</Text>
-          <Spacer y={1} />
-          <Text color="helper">
-            Porter will automatically provision your infrastructure in the
-            specified region.
-          </Text>
-          <Spacer height="10px" />
-          <SelectRow
-            options={regionOptions}
-            width="350px"
-            disabled={isReadOnly}
-            value={awsRegion}
-            scrollBuffer={true}
-            dropdownMaxHeight="240px"
-            setActiveValue={setAwsRegion}
-            label="📍 AWS region"
-          />
-          {(user?.isPorterUser || !currentProject?.simplified_view_enabled) &&
-            renderAdvancedSettings()}
-        </>
+        <VerticalSteps
+          currentStep={step}
+          steps={[
+            <>
+              <Text size={16}>Select an AWS region</Text><Spacer y={.5} /><Text color="helper">
+                Porter will automatically provision your infrastructure in the
+                specified region.
+              </Text><Spacer height="10px" /><SelectRow
+                options={regionOptions}
+                width="350px"
+                disabled={isReadOnly}
+                value={awsRegion}
+                scrollBuffer={true}
+                dropdownMaxHeight="240px"
+                setActiveValue={setAwsRegion}
+                label="📍 AWS region" />
+              <>
+                {
+                  user?.isPorterUser && renderAdvancedSettings()
+                }
+              </>
+            </>,
+            <>
+              <PreflightChecks provider='AWS' preflightData={preflightData} />
+              <Spacer y={.5} />
+              {(preflightFailed && preflightData) &&
+                <>
+                  <Text color="helper">
+                    Preflight checks for the account didn't pass. Please fix the issues and retry.
+                  </Text>
+                  < Button
+                    // disabled={isDisabled()}
+                    disabled={isLoading}
+                    onClick={preflightChecks}
+                  >
+                    Retry Checks
+                  </Button>
+                </>
+              }
+            </>,
+            <>
+              <Text size={16}>Provision your cluster</Text>
+              <Spacer y={1} />
+              <Button
+                // disabled={isDisabled()}
+                disabled={isDisabled() || preflightFailed || isLoading}
+                onClick={createCluster}
+                status={getStatus()}
+              >
+                Provision
+              </Button>
+              <Spacer y={1} /></>
+          ].filter((x) => x)}
+        />
       );
     }
 
     // If settings, update full form
     return (
-      <>
+      <><StyledForm>
         <Heading isAtTop>EKS configuration</Heading>
         <SelectRow
           options={regionOptions}
@@ -951,27 +1043,25 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
           scrollBuffer={true}
           dropdownMaxHeight="240px"
           setActiveValue={setAwsRegion}
-          label="📍 AWS region"
-        />
+          label="📍 AWS region" />
         {renderAdvancedSettings()}
-      </>
+      </StyledForm>
+        <Button
+          // disabled={isDisabled()}
+          disabled={isDisabled() || preflightFailed || isLoading}
+          onClick={createCluster}
+          status={getStatus()}
+        >
+          Provision
+        </Button></>
     );
   };
 
   return (
     <>
-      <StyledForm>{renderForm()}</StyledForm>
-      <Button
-        // disabled={isDisabled()}
-        disabled={
-          isDisabled()
-        }
-        onClick={createCluster}
-        status={getStatus()}
-      >
-        Provision
-      </Button>
-      {user.isPorterUser &&
+      {renderForm()}
+      {
+        user.isPorterUser &&
         <>
 
           <Spacer y={1} />

+ 1 - 0
dashboard/src/main/home/add-on-dashboard/AddOnDashboard.tsx

@@ -228,6 +228,7 @@ const AddOnDashboard: React.FC<Props> = ({
                           }
                         />
                         <Text size={14}>{app.name}</Text>
+                        <Spacer inline x={2} />
                       </Container>
                       <StatusIcon src={healthy} />
                       <Container row>

+ 5 - 2
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -27,11 +27,12 @@ import Icon from "components/porter/Icon";
 import save from "assets/save-01.svg";
 import LogsTab from "./tabs/LogsTab";
 import MetricsTab from "./tabs/MetricsTab";
+import Activity from "./tabs/Activity";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
 const validTabs = [
-  // "activity",
+  "activity",
   // "events",
   "overview",
   "logs",
@@ -43,7 +44,7 @@ const validTabs = [
   // "helm-values",
   // "job-history",
 ] as const;
-const DEFAULT_TAB = "overview";
+const DEFAULT_TAB = "activity";
 type ValidTab = typeof validTabs[number];
 
 type AppDataContainerProps = {
@@ -246,6 +247,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         <TabSelector
           noBuffer
           options={[
+            { label: "Activity", value: "activity" },
             { label: "Overview", value: "overview" },
             { label: "Logs", value: "logs" },
             { label: "Metrics", value: "metrics" },
@@ -267,6 +269,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         />
         <Spacer y={1} />
         {match(currentTab)
+          .with("activity", () => <Activity />)
           .with("overview", () => <Overview />)
           .with("build-settings", () => (
             <BuildSettings

+ 20 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx

@@ -0,0 +1,20 @@
+import React from "react";
+import { useLatestRevision } from "../LatestRevisionContext";
+import ActivityFeed from "./activity-feed/ActivityFeed";
+
+const Activity: React.FC = () => {
+    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+
+    return (
+        <>
+            <ActivityFeed
+                currentProject={projectId}
+                currentCluster={clusterId}
+                appName={latestProto.name}
+                deploymentTargetId={deploymentTargetId}
+            />
+        </>
+    );
+};
+
+export default Activity;

+ 288 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/ActivityFeed.tsx

@@ -0,0 +1,288 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+
+import Text from "components/porter/Text";
+
+import EventCard from "./events/cards/EventCard";
+import Loading from "components/Loading";
+import Spacer from "components/porter/Spacer";
+import Fieldset from "components/porter/Fieldset";
+
+import { feedDate } from "shared/string_utils";
+import Pagination from "components/porter/Pagination";
+import _ from "lodash";
+import Button from "components/porter/Button";
+import { PorterAppEvent, PorterAppEventType, porterAppEventValidator } from "./events/types";
+import { z } from "zod";
+
+type Props = {
+    appName: string;
+    currentProject: number;
+    currentCluster: number;
+    deploymentTargetId: string;
+};
+
+const EVENTS_POLL_INTERVAL = 5000; // poll every 5 seconds
+
+const ActivityFeed: React.FC<Props> = ({ appName, deploymentTargetId, currentCluster, currentProject }) => {
+
+    const [events, setEvents] = useState<PorterAppEvent[]>([]);
+    const [loading, setLoading] = useState<boolean>(true);
+    const [error, setError] = useState<any>(null);
+    const [page, setPage] = useState<number>(1);
+    const [numPages, setNumPages] = useState<number>(0);
+    const [hasPorterAgent, setHasPorterAgent] = useState(false);
+    const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+    const [shouldAnimate, setShouldAnimate] = useState(true);
+
+    const getEvents = async () => {
+        setLoading(true)
+        try {
+            const res = await api.getFeedEvents(
+                "<token>",
+                {},
+                {
+                    cluster_id: currentCluster,
+                    project_id: currentProject,
+                    stack_name: appName,
+                    page,
+                }
+            );
+
+            setNumPages(res.data.num_pages);
+            const events = z.array(porterAppEventValidator).optional().default([]).parse(res.data.events);
+            setEvents(events);
+        } catch (err) {
+            setError(err);
+        } finally {
+            setLoading(false);
+            setShouldAnimate(false);
+        }
+    };
+
+    const getLatestDeployEventIndex = () => {
+        const deployEvents = events.filter((event) => event.type === PorterAppEventType.DEPLOY);
+        if (deployEvents.length === 0) {
+            return -1;
+        }
+        return events.indexOf(deployEvents[0]);
+    };
+
+    const updateEvents = async () => {
+        try {
+            const res = await api.getFeedEvents(
+                "<token>",
+                {},
+                {
+                    cluster_id: currentCluster,
+                    project_id: currentProject,
+                    stack_name: appName,
+                    page,
+                }
+            );
+            setError(undefined)
+            setNumPages(res.data.num_pages);
+            const events = z.array(porterAppEventValidator).optional().default([]).parse(res.data.events);
+            setEvents(events);
+        } catch (err) {
+            setError(err);
+        }
+    }
+
+    useEffect(() => {
+        const checkForAgent = async () => {
+            try {
+                const project_id = currentProject;
+                const cluster_id = currentCluster;
+                const res = await api.detectPorterAgent("<token>", {}, { project_id, cluster_id });
+                const hasAgent = res.data?.version === "v3";
+                setHasPorterAgent(hasAgent);
+            } catch (err) {
+                if (err.response?.status === 404) {
+                    setHasPorterAgent(false);
+                }
+            } finally {
+                setLoading(false);
+            }
+        };
+
+        if (!hasPorterAgent) {
+            checkForAgent();
+        } else {
+            const intervalId = setInterval(updateEvents, EVENTS_POLL_INTERVAL);
+            getEvents();
+            return () => clearInterval(intervalId);
+        }
+
+    }, [currentProject, currentCluster, hasPorterAgent, page]);
+
+    const installAgent = async () => {
+        const project_id = currentProject;
+        const cluster_id = currentCluster;
+
+        setIsPorterAgentInstalling(true);
+        try {
+            await api.installPorterAgent("<token>", {}, { project_id, cluster_id });
+            window.location.reload();
+        } catch (err) {
+            setIsPorterAgentInstalling(false);
+            console.log(err);
+        }
+    };
+
+    if (isPorterAgentInstalling) {
+        return (
+            <Fieldset>
+                <Text size={16}>Installing agent...</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">If you are not redirected automatically after a minute, you may need to refresh this page.</Text>
+            </Fieldset>
+        );
+    }
+
+    if (error) {
+        return (
+            <Fieldset>
+                <Text size={16}>Error retrieving events</Text>
+                <Spacer height="15px" />
+                <Text color="helper">An unexpected error occurred.</Text>
+            </Fieldset>
+        );
+    }
+
+    if (loading) {
+        return (
+            <div>
+                <Spacer y={2} />
+                <Loading />
+            </div>
+        );
+    }
+
+    if (!loading && !hasPorterAgent) {
+        return (
+            <Fieldset>
+                <Text size={16}>
+                    We couldn't detect the Porter agent on your cluster
+                </Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                    In order to use the Activity tab, you need to install the Porter agent.
+                </Text>
+                <Spacer y={1} />
+                <Button onClick={() => installAgent()}>
+                    <I className="material-icons">add</I> Install Porter agent
+                </Button>
+            </Fieldset>
+        );
+    }
+
+    if (!loading && events?.length === 0) {
+        return (
+            <Fieldset>
+                <Text size={16}>No events found for "{appName}"</Text>
+                <Spacer height="15px" />
+                <Text color="helper">
+                    This application currently has no associated events.
+                </Text>
+            </Fieldset>
+        );
+    }
+
+    return (
+        <StyledActivityFeed shouldAnimate={shouldAnimate}>
+            {events.map((event, i) => {
+                return (
+                    <EventWrapper isLast={i === events.length - 1} key={i}>
+                        {i !== events.length - 1 && events.length > 1 && <Line shouldAnimate={shouldAnimate} />}
+                        <Dot shouldAnimate={shouldAnimate} />
+                        <Time shouldAnimate={shouldAnimate}>
+                            <Text>{feedDate(event.created_at).split(", ")[0]}</Text>
+                            <Spacer x={0.5} />
+                            <Text>{feedDate(event.created_at).split(", ")[1]}</Text>
+                        </Time>
+                        <EventCard
+                            deploymentTargetId={deploymentTargetId}
+                            event={event}
+                            key={i}
+                            isLatestDeployEvent={i === getLatestDeployEventIndex()}
+                            projectId={currentProject}
+                            clusterId={currentCluster}
+                            appName={appName}
+                        />
+                    </EventWrapper>
+                );
+            })}
+            {numPages > 1 && (
+                <>
+                    <Spacer y={1} />
+                    <Pagination page={page} setPage={setPage} totalPages={numPages} />
+                </>
+            )}
+        </StyledActivityFeed>
+    );
+};
+
+export default ActivityFeed;
+
+const I = styled.i`
+  font-size: 14px;
+  margin-right: 5px;
+`;
+
+const Time = styled.div<{ shouldAnimate: boolean }>`
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+  width: 90px;
+`;
+
+const Line = styled.div<{ shouldAnimate: boolean }>`
+  width: 1px;
+  height: calc(100% + 30px);
+  background: #414141;
+  position: absolute;
+  left: 3px;
+  top: 36px;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+`;
+
+const Dot = styled.div<{ shouldAnimate: boolean }>`
+  width: 7px;
+  height: 7px;
+  background: #fff;
+  border-radius: 50%;
+  margin-left: -29px;
+  margin-right: 20px;
+  z-index: 1;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+`;
+
+const EventWrapper = styled.div<{
+    isLast: boolean;
+}>`
+  padding-left: 30px;
+  display: flex;
+  align-items: center;
+  position: relative;
+  margin-bottom: ${(props) => (props.isLast ? "" : "25px")};
+`;
+
+const StyledActivityFeed = styled.div<{ shouldAnimate: boolean }>`
+  width: 100%;
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0s;"}
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 103 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx

@@ -0,0 +1,103 @@
+import React, { useState } from "react";
+
+import app_event from "assets/app_event.png";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import Icon from "components/porter/Icon";
+
+import { StyledEventCard } from "./EventCard";
+import { readableDate } from "shared/string_utils";
+import dayjs from "dayjs";
+import Anser from "anser";
+import api from "shared/api";
+import { PorterAppAppEvent } from "../types";
+import { Direction } from "main/home/app-dashboard/expanded-app/logs/types";
+import AppEventModal from "main/home/app-dashboard/expanded-app/status/AppEventModal";
+
+type Props = {
+  event: PorterAppAppEvent;
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+};
+
+const AppEventCard: React.FC<Props> = ({ event, deploymentTargetId, projectId, clusterId, appName }) => {
+  const [showModal, setShowModal] = useState<boolean>(false);
+  const [logs, setLogs] = useState([]);
+
+  const getAppLogs = async () => {
+    setShowModal(true);
+    try {
+      const logResp = await api.appLogs(
+        "<token>",
+        {
+          start_range: dayjs(event.created_at).subtract(1, 'minute').toISOString(),
+          end_range: dayjs(event.updated_at).add(1, 'minute').toISOString(),
+          app_name: event.metadata.app_name,
+          service_name: event.metadata.service_name,
+          deployment_target_id: deploymentTargetId,
+          limit: 1000,
+          direction: Direction.forward,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+        }
+      )
+
+      if (logResp.data?.logs != null) {
+        const updatedLogs = logResp.data.logs.map((l: { line: string; timestamp: string; }, index: number) => {
+          try {
+            return {
+              line: JSON.parse(l.line)?.log ?? Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
+          } catch (err) {
+            return {
+              line: Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
+          }
+        });
+        setLogs(updatedLogs);
+      }
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={app_event} />
+          <Spacer inline x={1} />
+          <Text>{event.metadata.summary}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Link onClick={getAppLogs} hasunderline>
+          View details
+        </Link>
+      </Container>
+      {showModal && (
+        <AppEventModal
+          setModalVisible={setShowModal}
+          logs={logs}
+          porterAppName={appName}
+          timestamp={readableDate(event.updated_at)}
+          expandedAppEventMessage={event.metadata.detail}
+        />
+      )}
+    </StyledEventCard>
+  );
+};
+
+export default AppEventCard;
+

+ 117 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/BuildEventCard.tsx

@@ -0,0 +1,117 @@
+import React from "react";
+import styled from "styled-components";
+
+import build from "assets/build.png";
+
+import run_for from "assets/run_for.png";
+import refresh from "assets/refresh.png";
+
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import Icon from "components/porter/Icon";
+import { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
+import { StyledEventCard } from "./EventCard";
+import document from "assets/document.svg";
+import { PorterAppBuildEvent, PorterAppEvent } from "../types";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+type Props = {
+  event: PorterAppBuildEvent;
+  appName: string;
+  projectId: number;
+  clusterId: number;
+};
+
+const BuildEventCard: React.FC<Props> = ({ event, appName, projectId, clusterId }) => {
+  const { porterApp } = useLatestRevision();
+  const renderStatusText = (event: PorterAppEvent) => {
+    switch (event.status) {
+      case "SUCCESS":
+        return <Text color={getStatusColor(event.status)}>Build succeeded</Text>;
+      case "FAILED":
+        return <Text color={getStatusColor(event.status)}>Build failed</Text>;
+      default:
+        return <Text color={getStatusColor(event.status)}>Build in progress...</Text>;
+    }
+  };
+
+  const renderInfoCta = (event: PorterAppBuildEvent) => {
+    switch (event.status) {
+      case "SUCCESS":
+        return null;
+      case "FAILED":
+        return (
+          <Wrapper>
+            <Link to={`/apps/${appName}/events?event_id=${event.id}`} hasunderline>
+              <Container row>
+                <Icon src={document} height="10px" />
+                <Spacer inline width="5px" />
+                View details
+              </Container>
+            </Link>
+            <Spacer inline x={1} />
+            <Link hasunderline onClick={() => triggerWorkflow({
+              projectId,
+              clusterId,
+              porterApp,
+            })}>
+              <Container row>
+                <Icon height="10px" src={refresh} />
+                <Spacer inline width="5px" />
+                Retry
+              </Container>
+            </Link>
+          </Wrapper>
+        );
+      default:
+        return (
+          <Wrapper>
+            <Link
+              hasunderline
+              target="_blank"
+              to={`https://github.com/${porterApp.repo_name}/actions/runs/${event.metadata.action_run_id}`}
+            >
+              View live logs
+            </Link>
+            <Spacer inline x={1} />
+          </Wrapper>
+        );
+    }
+  };
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={build} />
+          <Spacer inline width="10px" />
+          <Text>Application build</Text>
+        </Container>
+        <Container row>
+          <Icon height="14px" src={run_for} />
+          <Spacer inline width="6px" />
+          <Text color="helper">{getDuration(event)}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Container row>
+          <Icon height="12px" src={getStatusIcon(event.status)} />
+          <Spacer inline width="10px" />
+          {renderStatusText(event)}
+          <Spacer inline x={1} />
+          {renderInfoCta(event)}
+          <Spacer inline x={1} />
+        </Container>
+      </Container>
+    </StyledEventCard>
+  );
+};
+
+export default BuildEventCard;
+
+const Wrapper = styled.div`
+  margin-top: -3px;
+`;

+ 230 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx

@@ -0,0 +1,230 @@
+import React, { useState } from "react";
+import deploy from "assets/deploy.png";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Icon from "components/porter/Icon";
+import { getStatusColor, getStatusIcon } from '../utils';
+import { StyledEventCard } from "./EventCard";
+import styled from "styled-components";
+import Link from "components/porter/Link";
+import { PorterAppDeployEvent } from "../types";
+import AnimateHeight from "react-animate-height";
+import ServiceStatusDetail from "./ServiceStatusDetail";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+type Props = {
+  event: PorterAppDeployEvent;
+  appName: string;
+  showServiceStatusDetail?: boolean;
+};
+
+const DeployEventCard: React.FC<Props> = ({ event, appName, showServiceStatusDetail = false }) => {
+  const { latestRevision } = useLatestRevision();
+  const [diffModalVisible, setDiffModalVisible] = useState(false);
+  const [revertModalVisible, setRevertModalVisible] = useState(false);
+  const [serviceStatusVisible, setServiceStatusVisible] = useState(showServiceStatusDetail);
+
+  const renderStatusText = () => {
+    switch (event.status) {
+      case "SUCCESS":
+        return event.metadata.image_tag != null ?
+          event.metadata.service_deployment_metadata != null ?
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deployed <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
+            :
+            <Text color={getStatusColor(event.status)}>
+              Deployed <Code>{event.metadata.image_tag}</Code>
+            </Text>
+          :
+          <Text color={getStatusColor(event.status)}>
+            Deployment successful
+          </Text>;
+      case "FAILED":
+        if (event.metadata.service_deployment_metadata != null) {
+          let failedServices = 0;
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "FAILED") {
+              failedServices++;
+            }
+          }
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Failed to deploy <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(failedServices, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deployment failed
+            </Text>
+          );
+        }
+      case "CANCELED":
+        if (event.metadata.service_deployment_metadata != null) {
+          let canceledServices = 0;
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "CANCELED") {
+              canceledServices++;
+            }
+          }
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Canceled deploy of <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(canceledServices, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deployment canceled
+            </Text>
+          );
+        }
+      default:
+        if (event.metadata.service_deployment_metadata != null) {
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deploying <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deploying <Code>{event.metadata.image_tag}</Code>...
+            </Text>
+          );
+        }
+    }
+  };
+
+  const renderServiceDropdownCta = (numServices: number, color?: string) => {
+    return (
+      <ServiceStatusDropdownCtaContainer >
+        <Link color={color} onClick={() => setServiceStatusVisible(!serviceStatusVisible)}>
+          <ServiceStatusDropdownIcon className="material-icons" serviceStatusVisible={serviceStatusVisible}>arrow_drop_down</ServiceStatusDropdownIcon>
+          {numServices} service{numServices === 1 ? "" : "s"}
+        </Link>
+      </ServiceStatusDropdownCtaContainer>
+    )
+  }
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={deploy} />
+          <Spacer inline width="10px" />
+          <Text>Application version no. {event.metadata?.revision}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Container row>
+          <Icon height="12px" src={getStatusIcon(event.status)} />
+          <Spacer inline width="10px" />
+          {renderStatusText()}
+          {latestRevision.id !== event.metadata.app_revision_id && (
+            <>
+              <Spacer inline x={1} />
+              <TempWrapper>
+                <Link hasunderline onClick={() => setRevertModalVisible(true)}>
+                  Revert to version {event.metadata.revision}
+                </Link>
+
+              </TempWrapper>
+            </>
+          )}
+          <Spacer inline x={1} />
+          {/* <TempWrapper>
+            {event.metadata.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
+              View changes
+            </Link>)}
+            {diffModalVisible && (
+              <ChangeLogModal
+                revision={event.metadata.revision}
+                currentChart={appData.chart}
+                modalVisible={diffModalVisible}
+                setModalVisible={setDiffModalVisible}
+                appData={appData}
+              />
+            )}
+            {revertModalVisible && (
+              <ChangeLogModal
+                revision={event.metadata.revision}
+                currentChart={appData.chart}
+                modalVisible={revertModalVisible}
+                setModalVisible={setRevertModalVisible}
+                revertModal={true}
+                appData={appData}
+              />
+            )}
+          </TempWrapper> */}
+        </Container>
+      </Container>
+      {event.metadata.service_deployment_metadata != null &&
+        <AnimateHeight height={serviceStatusVisible ? "auto" : 0}>
+          <Spacer y={0.5} />
+          <ServiceStatusDetail
+            serviceDeploymentMetadata={event.metadata.service_deployment_metadata}
+            appName={appName}
+            revision={event.metadata.revision}
+          />
+        </AnimateHeight>
+      }
+    </StyledEventCard>
+  );
+};
+
+export default DeployEventCard;
+
+// TODO: remove after fixing v-align
+const TempWrapper = styled.div`
+  margin-top: -3px;
+`;
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const ServiceStatusDropdownCtaContainer = styled.div`
+  display: flex;
+  justify-content: center;
+  cursor: pointer;
+  padding: 3px 5px;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const ServiceStatusDropdownIcon = styled.i`
+  margin-left: -5px;
+  font-size: 20px;
+  border-radius: 20px;
+  transform: ${(props: { serviceStatusVisible: boolean }) =>
+    props.serviceStatusVisible ? "" : "rotate(-90deg)"};
+  transition: transform 0.1s ease;
+`
+
+const StatusTextContainer = styled.div`
+  display: flex;
+  align-items: center;
+  flex-direction: row;
+`;

+ 55 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx

@@ -0,0 +1,55 @@
+import React from "react";
+import styled from "styled-components";
+
+import BuildEventCard from "./BuildEventCard";
+import PreDeployEventCard from "./PreDeployEventCard";
+import AppEventCard from "./AppEventCard";
+import DeployEventCard from "./DeployEventCard";
+import { PorterAppAppEvent, PorterAppBuildEvent, PorterAppDeployEvent, PorterAppEvent, PorterAppEventType, } from "../types";
+import { match } from "ts-pattern";
+
+type Props = {
+  event: PorterAppEvent;
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  isLatestDeployEvent?: boolean;
+};
+
+const EventCard: React.FC<Props> = ({ event, deploymentTargetId, isLatestDeployEvent, projectId, clusterId, appName }) => {
+  return match(event.type)
+    .with(PorterAppEventType.APP_EVENT, () => <AppEventCard event={event as PorterAppAppEvent} deploymentTargetId={deploymentTargetId} projectId={projectId} clusterId={clusterId} appName={appName} />)
+    .with(PorterAppEventType.BUILD, () => <BuildEventCard event={event as PorterAppBuildEvent} projectId={projectId} clusterId={clusterId} appName={appName} />)
+    .with(PorterAppEventType.DEPLOY, () => <DeployEventCard event={event as PorterAppDeployEvent} appName={appName} showServiceStatusDetail={isLatestDeployEvent} />)
+    .with(PorterAppEventType.PRE_DEPLOY, () => <PreDeployEventCard event={event} appName={appName} projectId={projectId} clusterId={clusterId} />)
+    .exhaustive();
+};
+
+export default EventCard;
+
+export const StyledEventCard = styled.div<{ row?: boolean }>`
+  width: 100%;
+  padding: 15px;
+  display: flex;
+  flex-direction: ${({ row }) => row ? "row" : "column"};
+  justify-content: space-between;
+  border-radius: 5px;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid ${({ theme }) => theme.border};
+  opacity: 0;
+  animation: slideIn 0.5s 0s;
+  animation-fill-mode: forwards;
+  @keyframes slideIn {
+    from {
+      margin-left: -10px;
+      opacity: 0;
+      margin-right: 10px;
+    }
+    to {
+      margin-left: 0;
+      opacity: 1;
+      margin-right: 0;
+    }
+  }
+`;

+ 99 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -0,0 +1,99 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import pre_deploy from "assets/pre_deploy.png";
+
+import run_for from "assets/run_for.png";
+import refresh from "assets/refresh.png";
+
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Icon from "components/porter/Icon";
+
+import { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
+import { StyledEventCard } from "./EventCard";
+import Link from "components/porter/Link";
+import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+type Props = {
+  event: PorterAppEvent;
+  appName: string;
+  projectId: number;
+  clusterId: number;
+};
+
+const PreDeployEventCard: React.FC<Props> = ({ event, appName, projectId, clusterId }) => {
+  const { porterApp } = useLatestRevision();
+
+  const renderStatusText = (event: PorterAppEvent) => {
+    switch (event.status) {
+      case "SUCCESS":
+        return <Text color={getStatusColor(event.status)}>Pre-deploy succeeded</Text>;
+      case "FAILED":
+        return <Text color={getStatusColor(event.status)}>Pre-deploy failed</Text>;
+      default:
+        return <Text color={getStatusColor(event.status)}>Pre-deploy in progress...</Text>;
+    }
+  };
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={pre_deploy} />
+          <Spacer inline width="10px" />
+          <Text>Application pre-deploy</Text>
+        </Container>
+        <Container row>
+          <Icon height="14px" src={run_for} />
+          <Spacer inline width="6px" />
+          <Text color="helper">{getDuration(event)}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Container row>
+          <Icon height="12px" src={getStatusIcon(event.status)} />
+          <Spacer inline width="10px" />
+          {renderStatusText(event)}
+          {(event.status !== "SUCCESS") &&
+            <>
+              <Spacer inline x={1} />
+              <Wrapper>
+                <Link to={`/apps/${appName}/events?event_id=${event.id}`} hasunderline>
+                  <Container row>
+                    <Icon src={document} height="10px" />
+                    <Spacer inline width="5px" />
+                    View details
+                  </Container>
+                </Link>
+                <Spacer inline x={1} />
+                <Link hasunderline onClick={() => triggerWorkflow({
+                  projectId,
+                  clusterId,
+                  porterApp,
+                })}>
+                  <Container row>
+                    <Icon height="10px" src={refresh} />
+                    <Spacer inline width="5px" />
+                    Retry
+                  </Container>
+                </Link>
+              </Wrapper>
+            </>
+          }
+          <Spacer inline x={1} />
+        </Container>
+      </Container>
+    </StyledEventCard>
+  );
+};
+
+export default PreDeployEventCard;
+
+const Wrapper = styled.div`
+  margin-top: -3px;
+`;

+ 137 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/ServiceStatusDetail.tsx

@@ -0,0 +1,137 @@
+import Icon from 'components/porter/Icon';
+import Spacer from 'components/porter/Spacer';
+import Text from 'components/porter/Text';
+import React from 'react'
+import styled from 'styled-components';
+import { getStatusColor, getStatusIcon } from '../utils';
+import Link from 'components/porter/Link';
+import { PorterAppDeployEvent } from "../types";
+import { Service } from 'main/home/app-dashboard/new-app-flow/serviceTypes';
+import { useLatestRevision } from 'main/home/app-dashboard/app-view/LatestRevisionContext';
+import { deserializeService, serializedServiceFromProto } from 'lib/porter-apps/services';
+
+type Props = {
+    serviceDeploymentMetadata: PorterAppDeployEvent["metadata"]["service_deployment_metadata"];
+    appName: string;
+    revision: number;
+}
+
+const ServiceStatusDetail: React.FC<Props> = ({
+    serviceDeploymentMetadata,
+    appName,
+    revision,
+}) => {
+    const { latestProto } = useLatestRevision();
+    const convertEventStatusToCopy = (status: string) => {
+        switch (status) {
+            case "PROGRESSING":
+                return "DEPLOYING";
+            case "SUCCESS":
+                return "DEPLOYED";
+            case "FAILED":
+                return "FAILED";
+            case "CANCELED":
+                return "CANCELED";
+            default:
+                return "UNKNOWN";
+        }
+    };
+
+    return (
+        <ServiceStatusTable>
+            <tbody>
+                {Object.keys(serviceDeploymentMetadata).map((key) => {
+                    const deploymentMetadata = serviceDeploymentMetadata[key];
+                    const service = latestProto.services[key];
+                    let externalUri = "";
+                    if (service != null) {
+                        const deserializedService = deserializeService({ service: serializedServiceFromProto({ service, name: key }) });
+                        if (deserializedService.config.type === "web" && deserializedService.config.domains.length > 0) {
+                            externalUri = deserializedService.config.domains[0].name.value;
+                        }
+                    }
+                    return (
+                        <ServiceStatusTableRow key={key}>
+                            <ServiceStatusTableData width={"100px"}>
+                                <Text>{key}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData width={"120px"}>
+                                <Icon height="12px" src={getStatusIcon(deploymentMetadata.status)} />
+                                <Spacer inline x={0.5} />
+                                <Text color={getStatusColor(deploymentMetadata.status)}>{convertEventStatusToCopy(serviceDeploymentMetadata[key].status)}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData>
+                                {deploymentMetadata.type !== "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Logs
+                                        </Link>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={`/apps/${appName}/metrics?service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Metrics
+                                        </Link>
+                                    </>
+                                }
+                                {deploymentMetadata.type === "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/job-history?service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            History
+                                        </Link>
+                                    </>
+                                }
+                                {externalUri !== "" &&
+                                    <>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={Service.prefixSubdomain(externalUri)}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                            target={"_blank"}
+                                        >
+                                            External Link
+                                        </Link>
+                                    </>
+                                }
+                            </ServiceStatusTableData>
+                        </ServiceStatusTableRow>
+                    );
+                })}
+            </tbody>
+        </ServiceStatusTable>
+    )
+}
+
+export default ServiceStatusDetail;
+
+const ServiceStatusTable = styled.table`
+  border-collapse: collapse;
+  width: 100%;
+`;
+
+const ServiceStatusTableRow = styled.tr`
+  display: flex;
+  align-items: center;  
+`;
+
+const ServiceStatusTableData = styled.td`
+  padding: 8px;
+  display: flex;
+  align-items: center;
+  ${(props) => props.width && `width: ${props.width};`}
+
+  &:not(:last-child) {
+    border-right: 2px solid #ffffff11;
+  }
+`;

+ 278 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx

@@ -0,0 +1,278 @@
+import Loading from "components/Loading";
+import Spacer from "components/porter/Spacer";
+import React, { useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import Anser, { AnserJsonEntry } from "anser";
+import JSZip from "jszip";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import Link from "components/porter/Link";
+import { PorterLog } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
+
+type Props = {
+    event: PorterAppEvent;
+    appData: any;
+};
+
+const BuildFailureEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+}) => {
+    const [logs, setLogs] = useState<PorterLog[]>([]);
+    const [isLoading, setIsLoading] = useState<boolean>(true);
+    const scrollToBottomRef = useRef<HTMLDivElement>(null);
+
+    useEffect(() => {
+        if (!isLoading && scrollToBottomRef.current) {
+            scrollToBottomRef.current.scrollIntoView({
+                behavior: "smooth",
+                block: "end",
+            });
+        }
+    }, [isLoading, logs, scrollToBottomRef]);
+
+    const getBuildLogs = async () => {
+        if (event == null) {
+            return;
+        }
+        try {
+            setLogs([]);
+
+            const res = await api.getGHWorkflowLogById(
+                "",
+                {},
+                {
+                    project_id: appData.app.project_id,
+                    cluster_id: appData.app.cluster_id,
+                    git_installation_id: appData.app.git_repo_id,
+                    owner: appData.app.repo_name?.split("/")[0],
+                    name: appData.app.repo_name?.split("/")[1],
+                    filename: "porter_stack_" + appData.chart.name + ".yml",
+                    run_id: event.metadata.action_run_id,
+                }
+            );
+            let logs: PorterLog[] = [];
+            if (res.data != null) {
+                // Fetch the logs
+                const logsResponse = await fetch(res.data);
+
+                // Ensure that the response body is only read once
+                const logsBlob = await logsResponse.blob();
+
+                if (logsResponse.headers.get("Content-Type") === "application/zip") {
+                    const zip = await JSZip.loadAsync(logsBlob);
+                    const promises: any[] = [];
+
+                    zip.forEach(function (relativePath, zipEntry) {
+                        promises.push(
+                            (async function () {
+                                const fileData = await zip
+                                    .file(relativePath)
+                                    ?.async("string");
+
+                                if (
+                                    fileData &&
+                                    fileData.includes("Run porter-dev/porter-cli-action@v0.1.0")
+                                ) {
+                                    const lines = fileData.split("\n");
+                                    const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/;
+
+                                    for (let i = 0; i < lines.length; i++) {
+                                        const line = lines[i];
+                                        if (line.includes("Post job cleanup.")) {
+                                            break;
+                                        }
+                                        const lineWithoutTimestamp = line.replace(timestampPattern, "").trimStart();
+                                        const anserLine: AnserJsonEntry[] = Anser.ansiToJson(lineWithoutTimestamp);
+                                        if (lineWithoutTimestamp.toLowerCase().includes("error")) {
+                                            anserLine[0].fg = "238,75,43";
+                                        }
+
+                                        const log: PorterLog = {
+                                            line: anserLine,
+                                            lineNumber: i + 1,
+                                            timestamp: line.match(timestampPattern)?.[0],
+                                        };
+
+                                        logs.push(log);
+                                    }
+                                }
+                            })()
+                        );
+                    });
+
+                    await Promise.all(promises);
+                    setLogs(logs);
+                }
+            }
+        } catch (error) {
+            console.log(error);
+        } finally {
+            setIsLoading(false);
+        }
+    };
+
+    useEffect(() => {
+        getBuildLogs();
+    }, []);
+
+    return (
+        <>
+            <Text size={16} color="#FF6060">Build failed</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>
+            <Spacer y={0.5} />
+            <StyledLogsSection>
+                {isLoading ? (
+                    <Loading message="Waiting for logs..." />
+                ) : logs.length == 0 ? (
+                    <>
+                        <Message>
+                            No logs found.
+                        </Message>
+                    </>
+                ) : (
+                    <>
+                        {logs?.map((log, i) => {
+                            return (
+                                <Log key={[log.lineNumber, i].join(".")}>
+                                    <span className="line-number">{log.lineNumber}.</span>
+                                    <span className="line-timestamp">
+                                        {log.timestamp
+                                            ? dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")
+                                            : "-"}
+                                    </span>
+                                    <LogOuter key={[log.lineNumber, i].join(".")}>
+                                        {log.line?.map((ansi, j) => {
+                                            if (ansi.clearLine) {
+                                                return null;
+                                            }
+
+                                            return (
+                                                <LogInnerSpan
+                                                    key={[log.lineNumber, i, j].join(".")}
+                                                    ansi={ansi}
+                                                >
+                                                    {ansi.content.replace(/ /g, "\u00a0")}
+                                                </LogInnerSpan>
+                                            );
+                                        })}
+                                    </LogOuter>
+                                </Log>
+                            );
+                        })}
+                    </>
+                )}
+                <div ref={scrollToBottomRef} />
+            </StyledLogsSection>
+            <Spacer y={0.5} />
+            <Link
+                hasunderline
+                target="_blank"
+                to={
+                    event.metadata.action_run_id
+                        ? `https://github.com/${appData.app.repo_name}/actions/runs/${event.metadata.action_run_id}`
+                        : `https://github.com/${appData.app.repo_name}/actions`
+                }
+            >
+                View full build logs
+            </Link>
+        </>
+    );
+};
+
+export default BuildFailureEventFocusView;
+
+const StyledLogsSection = styled.div`
+  width: 100%;
+  min-height: 600px;
+  height: calc(100vh - 460px);
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  background: #000000;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Log = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  width: 100%;
+  & > * {
+    padding-block: 5px;
+  }
+  & > .line-timestamp {
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+    padding-inline-end: 5px;
+  }
+  & > .line-number {
+    height: 100%;
+    background: #202538;
+    display: inline-block;
+    text-align: right;
+    min-width: 45px;
+    padding-inline-end: 5px;
+    opacity: 0.3;
+    font-family: monospace;
+  }
+`;
+
+const LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;

+ 71 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/DeployEventFocusView.tsx

@@ -0,0 +1,71 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import LogSection from "../../../logs/LogSection";
+import { AppearingView } from "./EventFocusView";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import { PorterAppDeployEvent } from "../types";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
+
+type Props = {
+    event: PorterAppDeployEvent;
+    appData: any;
+    filterOpts?: LogFilterQueryParamOpts
+};
+
+const DeployEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+    filterOpts,
+}) => {
+    const renderHeaderText = () => {
+        switch (event.status) {
+            case "SUCCESS":
+                return <Text color="#68BF8B" size={16}>Deploy succeeded</Text>;
+            case "FAILED":
+                return <Text color="#FF6060" size={16}>Deploy failed</Text>;
+            case "CANCELED":
+                return <Text color="#FFBF00" size={16}>Deploy canceled</Text>;
+            default:
+                return (
+                    <Container row>
+                        <Icon height="16px" src={loading} />
+                        <Spacer inline width="10px" />
+                        <Text size={16}>Deploy in progress...</Text>
+                    </Container>
+                );
+        }
+    };
+
+    const renderDurationText = () => {
+        switch (event.status) {
+            case "PROGRESSING":
+                return <Text color="helper">Started {readableDate(event.created_at)}.</Text>
+            default:
+                return <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>;
+        }
+    }
+
+    return (
+        <>
+            <AppearingView>
+                {renderHeaderText()}
+            </AppearingView>
+            <Spacer y={0.5} />
+            {renderDurationText()}
+            <Spacer y={0.5} />
+            <LogSection
+                currentChart={appData.chart}
+                appName={appData.app.name}
+                filterOpts={filterOpts}
+            />
+        </>
+    );
+};
+
+export default DeployEventFocusView;

+ 129 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx

@@ -0,0 +1,129 @@
+import Loading from "components/Loading";
+import Spacer from "components/porter/Spacer";
+import React, { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Link from "components/porter/Link";
+import BuildFailureEventFocusView from "./BuildFailureEventFocusView";
+import PreDeployEventFocusView from "./PredeployEventFocusView";
+import _ from "lodash";
+import { PorterAppEvent, porterAppEventValidator } from "../types";
+import DeployEventFocusView from "./DeployEventFocusView";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
+
+type Props = {
+    eventId: string;
+    appData: any;
+    filterOpts?: LogFilterQueryParamOpts;
+};
+
+const EVENT_POLL_INTERVAL = 5000; // poll every 5 seconds
+
+const EventFocusView: React.FC<Props> = ({
+    eventId,
+    appData,
+    filterOpts,
+}) => {
+    const { currentProject, currentCluster } = useContext(Context);
+    const [event, setEvent] = useState<PorterAppEvent | null>(null);
+
+    useEffect(() => {
+        const getEvent = async () => {
+            if (currentProject == null || currentCluster == null) {
+                return;
+            }
+            try {
+                const eventResp = await api.getPorterAppEvent(
+                    "<token>",
+                    {},
+                    {
+                        project_id: currentProject.id,
+                        cluster_id: currentCluster.id,
+                        event_id: eventId,
+                    }
+                )
+                const newEvent = porterAppEventValidator.parse(eventResp.data.event);
+                setEvent(newEvent);
+                if (newEvent.metadata?.end_time != null) {
+                    clearInterval(intervalId);
+                }
+            } catch (err) {
+                console.log(err);
+            }
+        }
+        const intervalId = setInterval(getEvent, EVENT_POLL_INTERVAL);
+        getEvent();
+        return () => clearInterval(intervalId);
+    }, []);
+
+    const getEventFocusView = (event: PorterAppEvent, appData: any) => {
+        switch (event.type) {
+            case "BUILD":
+                return <BuildFailureEventFocusView event={event} appData={appData} />
+            case "PRE_DEPLOY":
+                return <PreDeployEventFocusView event={event} appData={appData} />
+            case "DEPLOY":
+                return <DeployEventFocusView
+                    event={event as PorterAppDeployEvent}
+                    appData={appData}
+                    filterOpts={filterOpts}
+                />
+            default:
+                return null
+        }
+    }
+
+    return (
+        <AppearingView>
+            <Link to={`/apps/${appData.app.name}/activity`}>
+                <BackButton>
+                    <i className="material-icons">keyboard_backspace</i>
+                    Activity feed
+                </BackButton>
+            </Link>
+            <Spacer y={0.5} />
+            {event == null && <Loading />}
+            {event != null && getEventFocusView(event, appData)}
+        </AppearingView>
+    );
+};
+
+export default EventFocusView;
+
+export const AppearingView = styled.div`
+    width: 100%;
+    animation: fadeIn 0.3s 0s;
+    @keyframes fadeIn {
+    from {
+        opacity: 0;
+    }
+    to {
+        opacity: 1;
+    }
+    }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  max-width: fit-content;
+  cursor: pointer;
+  font-size: 11px;
+  max-height: fit-content;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 70 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx

@@ -0,0 +1,70 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import LogSection from "../../../logs/LogSection";
+import { AppearingView } from "./EventFocusView";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import { PorterAppEvent } from "../types";
+
+type Props = {
+  event: PorterAppEvent;
+  appData: any;
+};
+
+const PreDeployEventFocusView: React.FC<Props> = ({
+  event,
+  appData,
+}) => {
+  const renderHeaderText = () => {
+    switch (event.status) {
+      case "SUCCESS":
+        return <Text color="#68BF8B" size={16}>Pre-deploy succeeded</Text>;
+      case "FAILED":
+        return <Text color="#FF6060" size={16}>Pre-deploy failed</Text>;
+      default:
+        return (
+          <Container row>
+            <Icon height="16px" src={loading} />
+            <Spacer inline width="10px" />
+            <Text size={16}>Pre-deploy in progress...</Text>
+          </Container>
+        );
+    }
+  };
+
+  const renderDurationText = () => {
+    switch (event.status) {
+      case "PROGRESSING":
+        return <Text color="helper">Started {readableDate(event.created_at)}.</Text>
+      default:
+        return <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>;
+    }
+  }
+
+  return (
+    <>
+      <AppearingView>
+        {renderHeaderText()}
+      </AppearingView>
+      <Spacer y={0.5} />
+      {renderDurationText()}
+      <Spacer y={0.5} />
+      <LogSection
+        currentChart={appData.releaseChart}
+        timeRange={{
+          startTime: event.metadata.end_time != null ? dayjs(event.metadata.start_time).subtract(1, 'minute') : undefined,
+          endTime: event.metadata.end_time != null ? dayjs(event.metadata.end_time).add(1, 'minute') : undefined,
+        }}
+        showFilter={false}
+        appName={appData.app.name}
+      />
+    </>
+  );
+};
+
+export default PreDeployEventFocusView;

+ 89 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts

@@ -0,0 +1,89 @@
+import { z } from "zod";
+
+export enum PorterAppEventType {
+    BUILD = "BUILD",
+    DEPLOY = "DEPLOY",
+    APP_EVENT = "APP_EVENT",
+    PRE_DEPLOY = "PRE_DEPLOY",
+}
+const porterAppAppEventMetadataValidator = z.object({
+    namespace: z.string(),
+    summary: z.string(),
+    short_summary: z.string(),
+    detail: z.string(),
+    service_name: z.string(),
+    app_revision_id: z.string(),
+    app_name: z.string(),
+    app_id: z.string(),
+    agent_event_id: z.string(),
+});
+const porterAppDeployEventMetadataValidator = z.object({
+    image_tag: z.string(),
+    revision: z.number(),
+    app_revision_id: z.string(),
+    service_deployment_metadata: z.record(z.object({
+        status: z.string(),
+        type: z.string(),
+    })),
+});
+const porterAppBuildEventMetadataValidator = z.object({
+    org: z.string(),
+    repo: z.string(),
+    branch: z.string(),
+    action_run_id: z.string(),
+    github_account_id: z.string(),
+})
+export const porterAppEventValidator = z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    status: z.string().optional().default(""),
+    type: z.nativeEnum(PorterAppEventType),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: z.union([
+        porterAppAppEventMetadataValidator,
+        porterAppDeployEventMetadataValidator,
+        porterAppBuildEventMetadataValidator,
+    ]).optional(),
+}).refine((data) => {
+    if (data.type === PorterAppEventType.APP_EVENT) {
+        return porterAppAppEventMetadataValidator.safeParse(data.metadata).success;
+    }
+    if (data.type === PorterAppEventType.DEPLOY) {
+        return porterAppDeployEventMetadataValidator.safeParse(data.metadata).success;
+    }
+    if (data.type === PorterAppEventType.BUILD) {
+        return porterAppBuildEventMetadataValidator.safeParse(data.metadata).success;
+    }
+    return true;
+});
+
+export const getPorterAppEventsValidator = z.array(porterAppEventValidator).optional().default([]);
+
+export type PorterAppEvent = z.infer<typeof porterAppEventValidator>;
+// TODO: figure out how to type this easier
+export type PorterAppAppEvent = Omit<PorterAppEvent, 'metadata'> & { type: PorterAppEventType.APP_EVENT, metadata: z.infer<typeof porterAppAppEventMetadataValidator> };
+export type PorterAppDeployEvent = Omit<PorterAppEvent, 'metadata'> & { type: PorterAppEventType.DEPLOY, metadata: z.infer<typeof porterAppDeployEventMetadataValidator> };
+export type PorterAppBuildEvent = Omit<PorterAppEvent, 'metadata'> & { type: PorterAppEventType.BUILD, metadata: z.infer<typeof porterAppBuildEventMetadataValidator> };
+// interface PorterAppServiceDeploymentMetadata {
+//     status: string;
+//     external_uri: string;
+//     type: string;
+// }
+// export interface PorterAppDeployEvent extends PorterAppEvent {
+//     type: PorterAppEventType.DEPLOY;
+//     metadata: {
+//         image_tag: string;
+//         revision: number;
+//         service_deployment_metadata: Record<string, PorterAppServiceDeploymentMetadata>;
+//     };
+// }
+// export interface PorterAppAppEvent extends PorterAppEvent {
+//     type: PorterAppEventType.APP_EVENT;
+//     metadata: {
+//         image_tag: string;
+//         revision: number;
+//         service_deployment_metadata: Record<string, PorterAppServiceDeploymentMetadata>;
+//     };
+// }

+ 104 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts

@@ -0,0 +1,104 @@
+import healthy from "assets/status-healthy.png";
+import failure from "assets/failure.svg";
+import loading from "assets/loading.gif";
+import canceled from "assets/canceled.svg"
+import api from "shared/api";
+import { PorterAppEvent } from "./types";
+import { SourceOptions } from "lib/porter-apps";
+import { PorterAppRecord } from "../../../AppView";
+
+export const getDuration = (event: PorterAppEvent): string => {
+    const startTimeStamp = new Date(event.metadata.start_time ?? event.created_at).getTime();
+    const endTimeStamp = new Date(event.metadata.end_time ?? event.updated_at).getTime();
+
+    const timeDifferenceMilliseconds = endTimeStamp - startTimeStamp;
+
+    const seconds = Math.floor(timeDifferenceMilliseconds / 1000);
+    const weeks = Math.floor(seconds / 604800);
+    const remainingDays = Math.floor((seconds % 604800) / 86400);
+    const remainingHours = Math.floor((seconds % 86400) / 3600);
+    const remainingMinutes = Math.floor((seconds % 3600) / 60);
+    const remainingSeconds = seconds % 60;
+
+    if (weeks > 0) {
+        return `${weeks}w ${remainingDays}d`;
+    }
+
+    if (remainingDays > 0) {
+        return `${remainingDays}d ${remainingHours}h`;
+    }
+
+    if (remainingHours > 0) {
+        return `${remainingHours}h ${remainingMinutes}m`;
+    }
+
+    if (remainingMinutes > 0) {
+        return `${remainingMinutes}m ${remainingSeconds}s`;
+    }
+
+    return `${remainingSeconds}s`;
+};
+
+export const getStatusIcon = (status: string) => {
+    switch (status) {
+        case "SUCCESS":
+            return healthy;
+        case "FAILED":
+            return failure;
+        case "PROGRESSING":
+            return loading;
+        case "CANCELED":
+            return canceled;
+        default:
+            return loading;
+    }
+};
+
+export const getStatusColor = (status: string) => {
+    switch (status) {
+        case "SUCCESS":
+            return "#68BF8B";
+        case "FAILED":
+            return "#FF6060";
+        case "PROGRESSING":
+            return "#6e9df5";
+        case "CANCELED":
+            return "#FFBF00";
+        default:
+            return "#6e9df5";
+    }
+};
+
+export const triggerWorkflow = async ({
+    projectId,
+    clusterId,
+    porterApp,
+}: {
+    projectId: number;
+    clusterId: number;
+    porterApp: PorterAppRecord;
+}) => {
+    if (porterApp.git_repo_id != null && porterApp.repo_name != null) {
+        try {
+            const res = await api.reRunGHWorkflow(
+                "<token>",
+                {},
+                {
+                    project_id: projectId,
+                    cluster_id: clusterId,
+                    git_installation_id: porterApp.git_repo_id ?? 0,
+                    owner: porterApp.repo_name.split("/")[0],
+                    name: porterApp.repo_name.split("/")[1],
+                    branch: porterApp.git_branch,
+                    filename: "porter_stack_" + porterApp.name + ".yml",
+                }
+            );
+            if (res.data != null) {
+                window.open(res.data, "_blank", "noreferrer");
+            }
+
+        } catch (error) {
+            console.log(error);
+        }
+    }
+};

+ 1 - 0
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -140,6 +140,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
 
   const onSubmit = handleSubmit(async (data) => {
     try {
+      console.log("submitting!")
       setDeployError("");
       const validatedAppProto = await validateApp(data);
       setValidatedAppProto(validatedAppProto);

+ 1 - 0
dashboard/src/main/home/app-dashboard/expanded-app/metrics/AreaChart.tsx

@@ -331,6 +331,7 @@ const AreaChart: React.FunctionComponent<AreaProps> = ({
                     left={10}
                     scale={valueScale}
                     hideAxisLine={true}
+                    numTicks={6}
                     hideTicks={true}
                     tickLabelProps={() => ({
                         fill: "white",

+ 12 - 6
dashboard/src/main/home/app-dashboard/expanded-app/metrics/MetricsSection.tsx

@@ -16,6 +16,7 @@ import MetricsChart from "./MetricsChart";
 import { useQuery } from "@tanstack/react-query";
 import Loading from "components/Loading";
 import CheckboxRow from "components/CheckboxRow";
+import Spacer from "components/porter/Spacer";
 
 type PropsType = {
   currentChart: ChartType;
@@ -267,11 +268,14 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
       return null;
     }
     return (
-      <CheckboxRow
-        toggle={() => setShowAutoscalingThresholds(!showAutoscalingThresholds)}
-        checked={showAutoscalingThresholds}
-        label="Show Autoscaling Thresholds"
-      />
+      <>
+        <Spacer inline x={1} />
+        <CheckboxRow
+          toggle={() => setShowAutoscalingThresholds(!showAutoscalingThresholds)}
+          checked={showAutoscalingThresholds}
+          label="Show autoscaling thresholds"
+        />
+      </>
     )
   }
 
@@ -329,6 +333,7 @@ const MetricsHeader = styled.div`
   width: 100%;
   display: flex;
   align-items: center;
+  margin-bottom: 10px;
   overflow: visible;
   justify-content: space-between;
 `;
@@ -343,6 +348,7 @@ const RangeWrapper = styled.div`
 const StyledMetricsSection = styled.div`
   width: 100%;
   display: flex;
+  margin-top: -20px;
   flex-direction: column;
   position: relative;
 `;
@@ -351,9 +357,9 @@ const Highlight = styled.div`
   display: flex;
   align-items: center;
   justify-content: center;
+  margin-top: 20px;
   margin-left: 8px;
   margin-bottom: 15px;
-  margin-top: 20px;
   color: ${(props: { color: string }) => props.color};
   cursor: pointer;
 

+ 1 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/CustomDomains.tsx

@@ -54,6 +54,7 @@ const CustomDomains: React.FC<Props> = ({ index }) => {
         </>
       )}
       <Button
+        type="button" // this is required so that CreateApp.tsx doesn't try to submit the form onClick lol
         onClick={() => {
           append({
             name: {

+ 4 - 13
dashboard/src/main/home/navbar/Help.tsx

@@ -35,7 +35,6 @@ export default class Help extends Component<PropsType, StateType> {
               <i className="material-icons-outlined">book</i>
               Documentation
             </Option>
-            <Line />
             <Option
               onClick={() => {
                 window.open("https://discord.gg/Vbse9vJtPU", "_blank").focus();
@@ -102,14 +101,6 @@ const Option = styled.div`
   }
 `;
 
-const Line = styled.div`
-  height: 1px;
-  z-index: 0;
-  left: 0;
-  background: #aaaabb55;
-  width: 100%;
-`;
-
 const CloseOverlay = styled.div`
   position: fixed;
   width: 100vw;
@@ -129,8 +120,8 @@ const Flex = styled.div`
 const Dropdown = styled.div`
   position: absolute;
   right: 0;
-  top: calc(100% + 5px);
-  background: #26282f;
+  top: calc(100% + 15px);
+  background: #121212;
   width: ${(props: {
     dropdownWidth: string;
     dropdownMaxHeight: string;
@@ -141,11 +132,11 @@ const Dropdown = styled.div`
     dropdownMaxHeight: string;
     feedbackSent?: boolean;
   }) => (props.dropdownMaxHeight ? props.dropdownMaxHeight : "300px")};
-  border-radius: 10px;
+  border-radius: 5px;
   z-index: 999;
   overflow-y: auto;
   margin-bottom: 20px;
-  box-shadow: 0 8px 20px 0px #00000088;
+  border: 1px solid #494B4F;
   animation: ${(props: {
     dropdownWidth: string;
     dropdownMaxHeight: string;

+ 37 - 9
dashboard/src/main/home/navbar/Navbar.tsx

@@ -2,6 +2,9 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 
+import userIcon from "assets/user-icon.png"
+import settings from "assets/settings-bold.png";
+
 import Feedback from "./Feedback";
 import Help from "./Help";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -40,7 +43,7 @@ class Navbar extends Component<PropsType, StateType> {
               }
             >
               <SettingsIcon>
-                <i className="material-icons">settings</i>
+                <Icon src={settings} />
               </SettingsIcon>
               Account settings
             </UserDropdownButton>
@@ -62,14 +65,13 @@ class Navbar extends Component<PropsType, StateType> {
     return (
       <StyledNavbar>
         <Help />
-        {this.renderFeedbackButton()}
         <NavButton
           selected={this.state.showDropdown}
           onClick={() =>
             this.setState({ showDropdown: !this.state.showDropdown })
           }
         >
-          <I className="material-icons">account_circle</I>
+          <Img src={userIcon} selected={this.state.showDropdown} />
           {this.renderSettingsDropdown()}
         </NavButton>
       </StyledNavbar>
@@ -81,6 +83,13 @@ Navbar.contextType = Context;
 
 export default withAuth(Navbar);
 
+const Icon = styled.img`
+  height: 15px;
+  margin-right: 10px;
+  opacity: 0.6;
+  margin-bottom: -3px;
+`;
+
 const VersionTag = styled.div`
   position: absolute;
   right: 10px;
@@ -108,6 +117,16 @@ const I = styled.i`
   margin-right: 7px;
 `;
 
+const Img = styled.img<{ selected: boolean }>`
+  height: 16px;
+  opacity: ${props => props.selected ? "1" : "0.6"};
+  margin-right: 10px; 
+  border-radius: 5px;
+  :hover {
+    opacity: 1;
+  }
+`;
+
 const CloseOverlay = styled.div`
   position: fixed;
   width: 100vw;
@@ -124,7 +143,7 @@ const UserDropdownButton = styled.button`
   height: 40px;
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
-  color: white;
+  color: #ffffff88;
   width: 100%;
   border: 0;
   text-align: left;
@@ -135,7 +154,16 @@ const UserDropdownButton = styled.button`
     outline: 0;
   }
   :hover {
-    background: #ffffff11;
+    color: #fff;
+    > i {
+      color: #fff;
+      border: 1px solid #fff;
+    }
+    > div {
+      > img {
+        opacity: 100%;
+      }
+    }
   }
   display: flex;
   align-items: center;
@@ -168,8 +196,8 @@ const DropdownLabel = styled.div`
 const Dropdown = styled.div`
   position: absolute;
   right: 0;
-  top: calc(100% + 5px);
-  background: #26282f;
+  top: calc(100% + 15px);
+  background: #121212;
   width: ${(props: {
     dropdownWidth: string;
     dropdownMaxHeight: string;
@@ -180,11 +208,11 @@ const Dropdown = styled.div`
     dropdownMaxHeight: string;
     feedbackSent?: boolean;
   }) => (props.dropdownMaxHeight ? props.dropdownMaxHeight : "300px")};
-  border-radius: 3px;
+  border-radius: 5px;
   z-index: 999;
+  border: 1px solid #494B4F;
   overflow-y: auto;
   margin-bottom: 20px;
-  box-shadow: 0 8px 20px 0px #00000088;
   animation: ${(props: {
     dropdownWidth: string;
     dropdownMaxHeight: string;

+ 17 - 1
dashboard/src/shared/util.ts

@@ -14,5 +14,21 @@ export function valueExists<T>(value: T | null | undefined): value is T {
 
 export const PREFLIGHT_MESSAGE_CONST = {
   "apiEnabled": "APIs enabled on service account",
-  "cidrAvailability": "CIDR availability"
+  "cidrAvailability": "CIDR availability",
+  "eip": "Elastic IP availability",
+  "natGateway": "NAT Gateway availability",
+  "vpc": "VPC availability",
+  "vcpus": "vCPUs availability",
+}
+
+export const PREFLIGHT_MESSAGE_CONST_AWS = {
+  "eip": "Elastic IP availability",
+  "natGateway": "NAT Gateway availability",
+  "vpc": "VPC availability",
+  "vcpus": "vCPU availability",
+}
+
+export const PREFLIGHT_MESSAGE_CONST_GCP = {
+  "apiEnabled": "APIs enabled on service account",
+  "cidrAvailability": "CIDR availability",
 }

+ 1 - 1
go.mod

@@ -81,7 +81,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.0.99
+	github.com/porter-dev/api-contracts v0.0.100
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1512,8 +1512,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 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/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.0.99 h1:7VltsUOtlTPlTApmcFyAhC29QxptgS87JNoeUk7VWGk=
-github.com/porter-dev/api-contracts v0.0.99/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.0.100 h1:378cKlIjPKlTsEVeyoGTQXskxy0xFmuIpxODSD1hzmo=
+github.com/porter-dev/api-contracts v0.0.100/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 5 - 2
internal/models/porter_app_event.go

@@ -25,8 +25,10 @@ type PorterAppEvent struct {
 	// UpdatedAt is the time (UTC) that an event was last updated. This can occur when an event was created as PROGRESSING, then was marked as SUCCESSFUL for example
 	UpdatedAt time.Time `json:"updated_at"`
 	// PorterAppID is the ID that the given event relates to
-	PorterAppID uint  `json:"porter_app_id"`
-	Metadata    JSONB `json:"metadata" sql:"type:jsonb" gorm:"type:jsonb"`
+	PorterAppID uint `json:"porter_app_id" gorm:"index:idx_app_deployment_target"`
+	// DeploymentTargetID is the ID of the deployment target that the event relates to
+	DeploymentTargetID uuid.UUID `json:"deployment_target_id" gorm:"type:uuid;index:idx_app_deployment_target;default:00000000-0000-0000-0000-000000000000"`
+	Metadata           JSONB     `json:"metadata" sql:"type:jsonb" gorm:"type:jsonb"`
 }
 
 // TableName overrides the table name
@@ -46,6 +48,7 @@ func (p *PorterAppEvent) ToPorterAppEvent() types.PorterAppEvent {
 		CreatedAt:          p.CreatedAt,
 		UpdatedAt:          p.UpdatedAt,
 		PorterAppID:        p.PorterAppID,
+		DeploymentTargetID: p.DeploymentTargetID.String(),
 	}
 	if p.Metadata != nil {
 		ty.Metadata = p.Metadata

+ 49 - 0
internal/repository/gorm/porter_app_event.go

@@ -47,6 +47,33 @@ func (repo *PorterAppEventRepository) ListEventsByPorterAppID(ctx context.Contex
 	return apps, paginatedResult, nil
 }
 
+// ListEventsByPorterAppIDAndDeploymentTargetID returns a list of events for a given porter app id and deployment target id
+func (repo *PorterAppEventRepository) ListEventsByPorterAppIDAndDeploymentTargetID(ctx context.Context, porterAppID uint, deploymentTargetID uuid.UUID, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error) {
+	apps := []*models.PorterAppEvent{}
+	paginatedResult := helpers.PaginatedResult{}
+
+	id := strconv.Itoa(int(porterAppID))
+	if id == "" {
+		return nil, paginatedResult, errors.New("invalid porter app id supplied")
+	}
+
+	if deploymentTargetID == uuid.Nil {
+		return nil, paginatedResult, errors.New("invalid deployment target id supplied")
+	}
+
+	db := repo.db.Model(&models.PorterAppEvent{})
+	resultDB := db.Where("porter_app_id = ? AND deployment_target_id = ?", id, deploymentTargetID).Order("created_at DESC")
+	resultDB = resultDB.Scopes(helpers.Paginate(db, &paginatedResult, opts...))
+
+	if err := resultDB.Find(&apps).Error; err != nil {
+		if !errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, paginatedResult, err
+		}
+	}
+
+	return apps, paginatedResult, nil
+}
+
 func (repo *PorterAppEventRepository) CreateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error {
 	if appEvent.ID == uuid.Nil {
 		appEvent.ID = uuid.New()
@@ -126,3 +153,25 @@ func (repo *PorterAppEventRepository) ReadDeployEventByRevision(ctx context.Cont
 
 	return appEvent, nil
 }
+
+// ReadDeployEventByAppRevisionID returns a deploy event for a given porter app id and app revision ID
+func (repo *PorterAppEventRepository) ReadDeployEventByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) (models.PorterAppEvent, error) {
+	appEvent := models.PorterAppEvent{}
+
+	if porterAppID == 0 {
+		return appEvent, errors.New("invalid porter app ID supplied")
+	}
+
+	if appRevisionID == "" {
+		return appEvent, errors.New("no app revision ID supplied")
+	}
+
+	// Convert porterAppID to string
+	strAppID := strconv.Itoa(int(porterAppID))
+
+	if err := repo.db.Where("porter_app_id = ? AND type = 'DEPLOY' AND metadata->>'app_revision_id' = ?", strAppID, appRevisionID).First(&appEvent).Error; err != nil {
+		return appEvent, err
+	}
+
+	return appEvent, nil
+}

+ 4 - 0
internal/repository/porter_app_event.go

@@ -11,8 +11,12 @@ import (
 // PorterAppEventRepository represents the set of queries on the PorterAppEvent model
 type PorterAppEventRepository interface {
 	ListEventsByPorterAppID(ctx context.Context, porterAppID uint, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error)
+	// ListEventsByPorterAppIDAndDeploymentTargetID returns a list of events for a given porter app id and deployment target id
+	ListEventsByPorterAppIDAndDeploymentTargetID(ctx context.Context, porterAppID uint, deploymentTargetID uuid.UUID, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error)
 	CreateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error
 	UpdateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error
 	ReadEvent(ctx context.Context, id uuid.UUID) (models.PorterAppEvent, error)
 	ReadDeployEventByRevision(ctx context.Context, porterAppID uint, revision float64) (models.PorterAppEvent, error)
+	// ReadDeployEventByAppRevisionID returns a deploy event for a given porter app id and app revision ID
+	ReadDeployEventByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) (models.PorterAppEvent, error)
 }

+ 10 - 0
internal/repository/test/porter_app_event.go

@@ -22,6 +22,11 @@ func (repo *PorterAppEventRepository) ListEventsByPorterAppID(ctx context.Contex
 	return nil, helpers.PaginatedResult{}, errors.New("cannot write database")
 }
 
+// ListEventsByPorterAppIDAndDeploymentTargetID is a test method
+func (repo *PorterAppEventRepository) ListEventsByPorterAppIDAndDeploymentTargetID(ctx context.Context, porterAppID uint, deploymentTargetID uuid.UUID, opts ...helpers.QueryOption) ([]*models.PorterAppEvent, helpers.PaginatedResult, error) {
+	return nil, helpers.PaginatedResult{}, errors.New("cannot write database")
+}
+
 func (repo *PorterAppEventRepository) CreateEvent(ctx context.Context, appEvent *models.PorterAppEvent) error {
 	return errors.New("cannot write database")
 }
@@ -37,3 +42,8 @@ func (repo *PorterAppEventRepository) ReadEvent(ctx context.Context, id uuid.UUI
 func (repo *PorterAppEventRepository) ReadDeployEventByRevision(ctx context.Context, porterAppID uint, revision float64) (models.PorterAppEvent, error) {
 	return models.PorterAppEvent{}, errors.New("cannot read database")
 }
+
+// ReadDeployEventByAppRevisionID is a test method
+func (repo *PorterAppEventRepository) ReadDeployEventByAppRevisionID(ctx context.Context, porterAppID uint, appRevisionID string) (models.PorterAppEvent, error) {
+	return models.PorterAppEvent{}, errors.New("cannot read database")
+}