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

Merge branch 'master' of github.com-meehawk:porter-dev/porter into preview-env-v2-fe

Soham Parekh 3 лет назад
Родитель
Сommit
0f5bd7824a
81 измененных файлов с 1487 добавлено и 2405 удалено
  1. 4 4
      api/client/api.go
  2. 2 2
      api/client/k8s.go
  3. 3 45
      api/server/handlers/cluster/detect_agent_installed.go
  4. 38 36
      api/server/handlers/cluster/install_agent.go
  5. 0 2
      api/server/handlers/cluster/notify_new_incident.go
  6. 1 1
      api/server/handlers/cluster/upgrade_agent.go
  7. 1 0
      api/server/handlers/environment/create.go
  8. 1 0
      api/server/handlers/environment/delete.go
  9. 1 38
      api/server/handlers/gitinstallation/webhook.go
  10. 27 1
      api/server/handlers/infra/forms.go
  11. 0 5
      api/server/handlers/infra/stream_logs.go
  12. 1 1
      api/server/handlers/namespace/create_env_group.go
  13. 6 1
      api/server/handlers/registry/list_images.go
  14. 1 1
      api/server/handlers/release/create.go
  15. 1 1
      api/server/handlers/release/create_addon.go
  16. 1 1
      api/server/handlers/release/update_image_batch.go
  17. 2 1
      api/server/handlers/release/upgrade.go
  18. 1 1
      api/server/handlers/release/upgrade_webhook.go
  19. 3 2
      api/server/handlers/stack/helpers.go
  20. 1 1
      api/server/handlers/v1/env_group/create.go
  21. 1 1
      api/server/handlers/v1/release/upgrade.go
  22. 4 0
      api/server/shared/config/env/envconfs.go
  23. 3 0
      api/types/incident.go
  24. 9 5
      cli/cmd/apply.go
  25. 11 11
      cli/cmd/config.go
  26. 3 3
      cli/cmd/config/config.go
  27. 2 1
      cli/cmd/connect/ecr.go
  28. 7 7
      cli/cmd/connect/kubeconfig.go
  29. 3 2
      cli/cmd/errors.go
  30. 1 1
      cli/cmd/list.go
  31. 28 7
      cli/cmd/run.go
  32. 1 1
      cli/cmd/stack.go
  33. 1 1
      cli/cmd/utils/close.go
  34. 14 4
      dashboard/src/components/Banner.tsx
  35. 0 202
      dashboard/src/components/events/EventCard.tsx
  36. 0 360
      dashboard/src/components/events/SubEventsList.tsx
  37. 0 166
      dashboard/src/components/events/sub-events/LogBucketCard.tsx
  38. 0 57
      dashboard/src/components/events/sub-events/SubEventCard.tsx
  39. 0 217
      dashboard/src/components/events/useEvents.ts
  40. 0 89
      dashboard/src/components/events/useLastSeenPodStatus.ts
  41. 1 1
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  42. 1 2
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  43. 0 214
      dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx
  44. 0 214
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  45. 27 12
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  46. 29 16
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ControllerTab.tsx
  47. 20 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx
  48. 12 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodDropdown.tsx
  49. 10 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodRow.tsx
  50. 244 60
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx
  51. 59 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  52. 81 37
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx
  53. 100 325
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  54. 112 43
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx
  55. 27 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts
  56. 30 73
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx
  57. 5 9
      dashboard/src/main/home/dashboard/Dashboard.tsx
  58. 1 1
      dashboard/src/main/home/launch/Launch.tsx
  59. 2 0
      dashboard/src/main/home/modals/Modal.tsx
  60. 1 0
      dashboard/src/shared/api.tsx
  61. 143 0
      dashboard/src/shared/hooks/usePods.ts
  62. 8 0
      dashboard/src/shared/string_utils.ts
  63. 9 3
      internal/helm/agent.go
  64. 2 1
      internal/helm/postrenderer.go
  65. 12 3
      internal/integrations/ci/actions/preview.go
  66. 6 4
      internal/integrations/preview/dep_resolver.go
  67. 95 14
      internal/integrations/preview/driver_validators.go
  68. 31 18
      internal/integrations/preview/schema_validate.go
  69. 1 1
      internal/integrations/preview/utils.go
  70. 0 4
      internal/integrations/preview/validate.go
  71. 1 0
      internal/kubernetes/porter_agent/v2/agent_server.go
  72. 5 0
      internal/kubernetes/prometheus/metrics.go
  73. 8 1
      internal/notifier/sendgrid/incident_notifier.go
  74. 9 1
      internal/notifier/slack/incident_notifier.go
  75. 13 5
      internal/opa/config.yaml
  76. 93 25
      internal/opa/opa.go
  77. 25 0
      internal/opa/policies/daemonset/running.rego
  78. 2 2
      internal/templater/helm/values/writer.go
  79. 3 2
      provisioner/client/client.go
  80. 75 0
      scripts/dev-environment/CheckPreviewEnvLocal.sh
  81. 1 0
      workers/jobs/recommender.go

+ 4 - 4
api/client/api.go

@@ -150,9 +150,9 @@ func (c *Client) postRequest(relPath string, data interface{}, response interfac
 
 		if i != int(retryCount)-1 {
 			if httpErr != nil {
-				fmt.Printf("Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
+				fmt.Fprintf(os.Stderr, "Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
 			} else {
-				fmt.Printf("Error: %v, retrying request...\n", err)
+				fmt.Fprintf(os.Stderr, "Error: %v, retrying request...\n", err)
 			}
 		}
 	}
@@ -205,9 +205,9 @@ func (c *Client) patchRequest(relPath string, data interface{}, response interfa
 
 		if i != int(retryCount)-1 {
 			if httpErr != nil {
-				fmt.Printf("Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
+				fmt.Fprintf(os.Stderr, "Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
 			} else {
-				fmt.Printf("Error: %v, retrying request...\n", err)
+				fmt.Fprintf(os.Stderr, "Error: %v, retrying request...\n", err)
 			}
 		}
 	}

+ 2 - 2
api/client/k8s.go

@@ -64,7 +64,7 @@ func (c *Client) GetKubeconfig(
 	resp := &types.GetTemporaryKubeconfigResponse{}
 
 	if localKubeconfigPath != "" {
-		color.New(color.FgBlue).Printf("using local kubeconfig: %s\n", localKubeconfigPath)
+		color.New(color.FgBlue).Fprintf(os.Stderr, "using local kubeconfig: %s\n", localKubeconfigPath)
 
 		if _, err := os.Stat(localKubeconfigPath); !os.IsNotExist(err) {
 			file, err := os.Open(localKubeconfigPath)
@@ -85,7 +85,7 @@ func (c *Client) GetKubeconfig(
 		}
 	}
 
-	color.New(color.FgBlue).Println("using remote kubeconfig")
+	color.New(color.FgBlue).Fprintln(os.Stderr, "using remote kubeconfig")
 
 	err := c.getRequest(
 		fmt.Sprintf(

+ 3 - 45
api/server/handlers/cluster/detect_agent_installed.go

@@ -2,18 +2,15 @@ package cluster
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 	"strings"
 
-	"github.com/Masterminds/semver/v3"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	v1 "k8s.io/api/apps/v1"
@@ -60,46 +57,17 @@ func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		ShouldUpgrade: false,
 	}
 
-	res.LatestVersion, err = getLatestAgentVersion(c.Config().ServerConf.DefaultAddonHelmRepoURL)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	if res.LatestVersion != res.Version {
-		versionSem, err := semver.NewConstraint(fmt.Sprintf("> %s", strings.TrimPrefix(res.Version, "v")))
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-
-		latestVersionSem, err := semver.NewVersion(strings.TrimPrefix(res.LatestVersion, "v"))
-
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-			return
-		}
-
-		if versionSem.Check(latestVersionSem) {
-			res.ShouldUpgrade = true
-		}
+	if res.Version != "v3" {
+		res.ShouldUpgrade = true
 	}
 
 	res.Version = "v" + strings.TrimPrefix(res.Version, "v")
-	res.LatestVersion = "v" + strings.TrimPrefix(res.LatestVersion, "v")
 
 	c.WriteResult(w, r, res)
 }
 
 func getAgentVersionFromDeployment(depl *v1.Deployment) string {
-	versionAnn, ok := depl.ObjectMeta.Annotations["porter.run/agent-version"]
-
-	if !ok {
-		// fallback to porter agent v2 annotation
-		versionAnn = depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
-	}
+	versionAnn := depl.ObjectMeta.Annotations["porter.run/agent-major-version"]
 
 	if versionAnn != "" {
 		return versionAnn
@@ -107,13 +75,3 @@ func getAgentVersionFromDeployment(depl *v1.Deployment) string {
 
 	return "v1"
 }
-
-func getLatestAgentVersion(helmRepoURL string) (string, error) {
-	chart, err := loader.LoadChartPublic(helmRepoURL, "porter-agent", "")
-
-	if err != nil {
-		return "", fmt.Errorf("could not load latest porter-agent chart: %w", err)
-	}
-
-	return chart.Metadata.Version, nil
-}

+ 38 - 36
api/server/handlers/cluster/install_agent.go

@@ -53,14 +53,14 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err = checkAndDeleteOlderAgent(k8sAgent)
+	helmAgent, err := c.GetHelmAgent(r, cluster, "porter-agent-system")
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	helmAgent, err := c.GetHelmAgent(r, cluster, "porter-agent-system")
+	err = checkAndDeleteOlderAgent(k8sAgent, helmAgent)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -97,24 +97,8 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	lokiValues := make(map[string]interface{})
-
-	// case on whether a node with porter.run/workload-kind=monitoring exists. If it does, we place loki in that node group.
-	if nodes, err := nodes.ListNodesByLabels(k8sAgent.Clientset, "porter.run/workload-kind=monitoring"); err == nil && len(nodes) >= 1 {
-		lokiValues = map[string]interface{}{
-			"nodeSelector": map[string]interface{}{
-				"porter.run/workload-kind": "monitoring",
-			},
-			"tolerations": []map[string]interface{}{
-				{
-					"key":      "porter.run/workload-kind",
-					"operator": "Equal",
-					"value":    "monitoring",
-					"effect":   "NoSchedule",
-				},
-			},
-		}
-	}
+	nodes, err := nodes.ListNodesByLabels(k8sAgent.Clientset, "porter.run/workload-kind=monitoring")
+	hasMonitoringNodes := err == nil && len(nodes) >= 1
 
 	porterAgentValues := map[string]interface{}{
 		"agent": map[string]interface{}{
@@ -124,7 +108,31 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			"clusterID":   fmt.Sprintf("%d", cluster.ID),
 			"projectID":   fmt.Sprintf("%d", proj.ID),
 		},
-		"loki": lokiValues,
+		"loki": map[string]interface{}{},
+	}
+
+	// case on whether a node with porter.run/workload-kind=monitoring exists. If it does, we place loki in that node group.
+	if hasMonitoringNodes {
+		sharedNS := map[string]interface{}{
+			"porter.run/workload-kind": "monitoring",
+		}
+
+		sharedTolerations := []map[string]interface{}{
+			{
+				"key":      "porter.run/workload-kind",
+				"operator": "Equal",
+				"value":    "monitoring",
+				"effect":   "NoSchedule",
+			},
+		}
+
+		porterAgentValues["loki"] = map[string]interface{}{
+			"nodeSelector": sharedNS,
+			"tolerations":  sharedTolerations,
+		}
+
+		porterAgentValues["nodeSelector"] = sharedNS
+		porterAgentValues["tolerations"] = sharedTolerations
 	}
 
 	conf := &helm.InstallChartConfig{
@@ -136,7 +144,7 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Values:    porterAgentValues,
 	}
 
-	_, err = helmAgent.InstallChart(conf, c.Config().DOConf)
+	_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
@@ -149,7 +157,7 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	w.WriteHeader(http.StatusOK)
 }
 
-func checkAndDeleteOlderAgent(k8sAgent *kubernetes.Agent) error {
+func checkAndDeleteOlderAgent(k8sAgent *kubernetes.Agent, helmAgent *helm.Agent) error {
 	namespaceList, err := k8sAgent.Clientset.CoreV1().Namespaces().List(context.Background(), v1.ListOptions{})
 
 	if err != nil {
@@ -169,23 +177,17 @@ func checkAndDeleteOlderAgent(k8sAgent *kubernetes.Agent) error {
 		return nil
 	}
 
-	podList, err := k8sAgent.Clientset.CoreV1().Pods("porter-agent-system").List(context.Background(), v1.ListOptions{
-		LabelSelector: olderAgentLabel,
-	})
+	// detect if the `porter-agent` release is installed
+	helmRelease, err := helmAgent.GetRelease("porter-agent", 0, false)
 
-	if err != nil {
-		return fmt.Errorf("error listing pods for older porter-agent: %w", err)
+	if err != nil || helmRelease == nil {
+		return nil
 	}
 
-	if len(podList.Items) > 0 {
-		// older porter-agent exists, delete the entire namespace
-		err := k8sAgent.Clientset.CoreV1().Namespaces().Delete(
-			context.Background(), "porter-agent-system", v1.DeleteOptions{},
-		)
+	_, err = helmAgent.UninstallChart("porter-agent")
 
-		if err != nil {
-			return fmt.Errorf("error deleting older porter-agent's namespace: %w", err)
-		}
+	if err != nil {
+		return err
 	}
 
 	return nil

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

@@ -118,8 +118,6 @@ func (c *NotifyNewIncidentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 			)
 		}
 
-		fmt.Println("NOTIFYING NEW:", request.ReleaseName, request.InvolvedObjectKind, request.InvolvedObjectName)
-
 		err := multi.NotifyNew(request, url)
 
 		if err != nil {

+ 1 - 1
api/server/handlers/cluster/upgrade_agent.go

@@ -66,7 +66,7 @@ func (c *UpgradeAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Cluster:    cluster,
 		Repo:       c.Repo(),
 		Registries: []*models.Registry{},
-	}, c.Config().DOConf)
+	}, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 1 - 0
api/server/handlers/environment/create.go

@@ -178,6 +178,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		ClusterID:         cluster.ID,
 		GitInstallationID: uint(ga.InstallationID),
 		EnvironmentName:   request.Name,
+		InstanceName:      c.Config().ServerConf.InstanceName,
 	})
 
 	if err != nil {

+ 1 - 0
api/server/handlers/environment/delete.go

@@ -129,6 +129,7 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		ClusterID:         cluster.ID,
 		GitInstallationID: uint(ga.InstallationID),
 		EnvironmentName:   env.Name,
+		InstanceName:      c.Config().ServerConf.InstanceName,
 	})
 
 	if err != nil {

+ 1 - 38
api/server/handlers/gitinstallation/webhook.go

@@ -1,12 +1,7 @@
 package gitinstallation
 
 import (
-	"crypto/hmac"
-	"crypto/sha256"
-	"encoding/hex"
-	"io/ioutil"
 	"net/http"
-	"strings"
 
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -35,21 +30,13 @@ func NewGithubAppWebhookHandler(
 }
 
 func (c *GithubAppWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	payload, err := ioutil.ReadAll(r.Body)
+	payload, err := github.ValidatePayload(r, []byte(c.Config().GithubAppConf.WebhookSecret))
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	// verify webhook secret
-	signature := r.Header.Get("X-Hub-Signature-256")
-
-	if !verifySignature([]byte(c.Config().GithubAppConf.WebhookSecret), signature, payload) {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
-		return
-	}
-
 	event, err := github.ParseWebHook(github.WebHookType(r), payload)
 
 	if err != nil {
@@ -89,27 +76,3 @@ func (c *GithubAppWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		}
 	}
 }
-
-// verifySignature verifies a signature based on hmac protocal
-// https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks
-func verifySignature(secret []byte, signature string, body []byte) bool {
-	if len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") {
-		return false
-	}
-
-	actual := make([]byte, 32)
-	_, err := hex.Decode(actual, []byte(signature[7:]))
-
-	if err != nil {
-		return false
-	}
-
-	computed := hmac.New(sha256.New, secret)
-	_, err = computed.Write(body)
-
-	if err != nil {
-		return false
-	}
-
-	return hmac.Equal(computed.Sum(nil), actual)
-}

+ 27 - 1
api/server/handlers/infra/forms.go

@@ -408,6 +408,16 @@ tabs:
           value: c6i.xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
+        - label: c6i.4xlarge
+          value: c6i.4xlarge
+        - label: m6i.large
+          value: m6i.large
+        - label: m6i.xlarge
+          value: m6i.xlarge
+        - label: m6i.2xlarge
+          value: m6i.2xlarge
+        - label: m6i.4xlarge
+          value: m6i.4xlarge
         - label: r5.large
           value: r5.large
         - value: r5.xlarge
@@ -426,7 +436,7 @@ tabs:
       label: EKS control plane version
       variable: cluster_version
       settings:
-        default: "1.20"
+        default: "1.22"
         options:
         - label: "1.20"
           value: "1.20"
@@ -506,6 +516,16 @@ tabs:
           value: c6i.xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
+        - label: c6i.4xlarge
+          value: c6i.4xlarge
+        - label: m6i.large
+          value: m6i.large
+        - label: m6i.xlarge
+          value: m6i.xlarge
+        - label: m6i.2xlarge
+          value: m6i.2xlarge
+        - label: m6i.4xlarge
+          value: m6i.4xlarge
     - type: number-input
       label: Minimum number of EC2 instances to create in the application autoscaling group.
       variable: additional_nodegroup_min_instances
@@ -584,8 +604,14 @@ tabs:
           value: t3.xlarge
         - label: t3.2xlarge
           value: t3.2xlarge
+        - label: c6i.large
+          value: c6i.large
+        - label: c6i.xlarge
+          value: c6i.xlarge
         - label: c6i.2xlarge
           value: c6i.2xlarge
+        - label: c6i.4xlarge
+          value: c6i.4xlarge
   - name: spot_instance_should_enable
     contents:
     - type: heading

+ 0 - 5
api/server/handlers/infra/stream_logs.go

@@ -3,7 +3,6 @@ package infra
 import (
 	"context"
 	"errors"
-	"fmt"
 	"io"
 	"net/http"
 	"sync"
@@ -68,7 +67,6 @@ func (c *InfraStreamLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		for {
 			if _, _, err := safeRW.ReadMessage(); err != nil {
 				errorchan <- nil
-				fmt.Println("closing websocket goroutine")
 				return
 			}
 		}
@@ -87,8 +85,6 @@ func (c *InfraStreamLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 					errorchan <- err
 				}
 
-				fmt.Println("closing grpc goroutine")
-
 				return
 			}
 
@@ -96,7 +92,6 @@ func (c *InfraStreamLogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 			if err != nil {
 				errorchan <- nil
-				fmt.Println("closing grpc goroutine")
 				return
 			}
 		}

+ 1 - 1
api/server/handlers/namespace/create_env_group.go

@@ -191,7 +191,7 @@ func rolloutApplications(
 				Values:     newConfig,
 			}
 
-			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf)
+			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf, config.ServerConf.DisablePullSecretsInjection)
 
 			if err != nil {
 				mu.Lock()

+ 6 - 1
api/server/handlers/registry/list_images.go

@@ -1,7 +1,9 @@
 package registry
 
 import (
+	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -37,7 +39,10 @@ func (c *RegistryListImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 
 	imgs, err := regAPI.ListImages(repoName, c.Repo(), c.Config().DOConf)
 
-	if err != nil {
+	if err != nil && strings.Contains(err.Error(), "RepositoryNotFoundException") {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such repository: %s", repoName)))
+		return
+	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 1 - 1
api/server/handlers/release/create.go

@@ -104,7 +104,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Registries: registries,
 	}
 
-	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf)
+	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 1 - 1
api/server/handlers/release/create_addon.go

@@ -94,7 +94,7 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Registries: registries,
 	}
 
-	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf)
+	helmRelease, err := helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 1 - 1
api/server/handlers/release/update_image_batch.go

@@ -108,7 +108,7 @@ func (c *UpdateImageBatchHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 					Values:     rel.Config,
 				}
 
-				_, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+				_, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 				if err != nil {
 					// if this is a release not found error, just return - the release has likely been deleted from the underlying

+ 2 - 1
api/server/handlers/release/upgrade.go

@@ -160,7 +160,8 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 	}
 
-	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf)
+	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf,
+		c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if upgradeErr == nil && newHelmRelease != nil {
 		helmRelease = newHelmRelease

+ 1 - 1
api/server/handlers/release/upgrade_webhook.go

@@ -174,7 +174,7 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		),
 	}
 
-	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if err != nil {
 		notifyOpts.Status = notifier.StatusHelmFailed

+ 3 - 2
api/server/handlers/stack/helpers.go

@@ -54,7 +54,7 @@ func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
 		"revision": opts.stackRevision,
 	}
 
-	return opts.helmAgent.InstallChart(conf, opts.config.DOConf)
+	return opts.helmAgent.InstallChart(conf, opts.config.DOConf, opts.config.ServerConf.DisablePullSecretsInjection)
 }
 
 type rollbackAppResourceOpts struct {
@@ -106,7 +106,8 @@ func updateAppResourceTag(opts *updateAppResourceTagOpts) error {
 		StackRevision: opts.stackRevision,
 	}
 
-	_, err = opts.helmAgent.UpgradeReleaseByValues(conf, opts.config.DOConf)
+	_, err = opts.helmAgent.UpgradeReleaseByValues(conf, opts.config.DOConf,
+		opts.config.ServerConf.DisablePullSecretsInjection)
 
 	return err
 }

+ 1 - 1
api/server/handlers/v1/env_group/create.go

@@ -207,7 +207,7 @@ func rolloutApplications(
 				Values:     newConfig,
 			}
 
-			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf)
+			_, err = helmAgent.UpgradeReleaseByValues(conf, config.DOConf, config.ServerConf.DisablePullSecretsInjection)
 
 			if err != nil {
 				mu.Lock()

+ 1 - 1
api/server/handlers/v1/release/upgrade.go

@@ -144,7 +144,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 	}
 
-	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
+	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
 
 	if upgradeErr == nil && newHelmRelease != nil {
 		helmRelease = newHelmRelease

+ 4 - 0
api/server/shared/config/env/envconfs.go

@@ -106,6 +106,10 @@ type ServerConf struct {
 
 	// Enable gitlab integration
 	EnableGitlab bool `env:"ENABLE_GITLAB,default=false"`
+
+	// DisableRegistrySecretsInjection is used to denote if Porter should not inject
+	// imagePullSecrets into a kubernetes deployment (Porter application)
+	DisablePullSecretsInjection bool `env:"DISABLE_PULL_SECRETS_INJECTION,default=false"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 3 - 0
api/types/incident.go

@@ -42,6 +42,8 @@ type IncidentMeta struct {
 	InvolvedObjectName      string             `json:"involved_object_name" form:"required"`
 	InvolvedObjectNamespace string             `json:"involved_object_namespace" form:"required"`
 	ShouldViewLogs          bool               `json:"should_view_logs"`
+	Revision                string             `json:"revision"`
+	PorterDocLink           string             `json:"porter_doc_link"`
 }
 
 type PaginationRequest struct {
@@ -112,6 +114,7 @@ type GetLogRequest struct {
 type GetPodValuesRequest struct {
 	StartRange  *time.Time `schema:"start_range"`
 	EndRange    *time.Time `schema:"end_range"`
+	Namespace   string     `schema:"namespace"`
 	MatchPrefix string     `schema:"match_prefix"`
 	Revision    string     `schema:"revision"`
 }

+ 9 - 5
cli/cmd/apply.go

@@ -81,7 +81,7 @@ var applyValidateCmd = &cobra.Command{
 		err := applyValidate()
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Error: %s\n", err.Error())
+			color.New(color.FgRed).Fprintf(os.Stderr, "Error: %s\n", err.Error())
 			os.Exit(1)
 		} else {
 			color.New(color.FgGreen).Printf("The porter.yaml file is valid!\n")
@@ -101,10 +101,12 @@ func init() {
 }
 
 func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
-	err := applyValidate()
+	if _, ok := os.LookupEnv("PORTER_VALIDATE_YAML"); ok {
+		err := applyValidate()
 
-	if err != nil {
-		return err
+		if err != nil {
+			return err
+		}
 	}
 
 	fileBytes, err := ioutil.ReadFile(porterYAML)
@@ -760,7 +762,9 @@ func (t *DeploymentHook) PreApply() error {
 	envs := *envList
 
 	for _, env := range envs {
-		if env.GitRepoOwner == t.repoOwner && env.GitRepoName == t.repoName && env.GitInstallationID == t.gitInstallationID {
+		if strings.EqualFold(env.GitRepoOwner, t.repoOwner) &&
+			strings.EqualFold(env.GitRepoName, t.repoName) &&
+			env.GitInstallationID == t.gitInstallationID {
 			t.envID = env.ID
 			break
 		}

+ 11 - 11
cli/cmd/config.go

@@ -26,7 +26,7 @@ var configCmd = &cobra.Command{
 	Short: "Commands that control local configuration settings",
 	Run: func(cmd *cobra.Command, args []string) {
 		if err := printConfig(); err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},
@@ -47,14 +47,14 @@ var configSetProjectCmd = &cobra.Command{
 			projID, err := strconv.ParseUint(args[0], 10, 64)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 
 			err = cliConf.SetProject(uint(projID))
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 		}
@@ -76,14 +76,14 @@ var configSetClusterCmd = &cobra.Command{
 			clusterID, err := strconv.ParseUint(args[0], 10, 64)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 
 			err = cliConf.SetCluster(uint(clusterID))
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 		}
@@ -105,14 +105,14 @@ var configSetRegistryCmd = &cobra.Command{
 			registryID, err := strconv.ParseUint(args[0], 10, 64)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 
 			err = cliConf.SetRegistry(uint(registryID))
 
 			if err != nil {
-				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 				os.Exit(1)
 			}
 		}
@@ -127,14 +127,14 @@ var configSetHelmRepoCmd = &cobra.Command{
 		hrID, err := strconv.ParseUint(args[0], 10, 64)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 
 		err = cliConf.SetHelmRepo(uint(hrID))
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},
@@ -148,7 +148,7 @@ var configSetHostCmd = &cobra.Command{
 		err := cliConf.SetHost(args[0])
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},
@@ -162,7 +162,7 @@ var configSetKubeconfigCmd = &cobra.Command{
 		err := cliConf.SetKubeconfig(args[0])
 
 		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "An error occurred: %v\n", err)
 			os.Exit(1)
 		}
 	},

+ 3 - 3
cli/cmd/config/config.go

@@ -67,7 +67,7 @@ func initAndLoadConfig(_config *CLIConfig) {
 	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
 		os.Mkdir(porterDir, 0700)
 	} else if err != nil {
-		color.New(color.FgRed).Printf("%v\n", err)
+		color.New(color.FgRed).Fprintf(os.Stderr, "%v\n", err)
 		os.Exit(1)
 	}
 
@@ -96,12 +96,12 @@ func initAndLoadConfig(_config *CLIConfig) {
 			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
 
 			if err != nil {
-				color.New(color.FgRed).Printf("%v\n", err)
+				color.New(color.FgRed).Fprintf(os.Stderr, "%v\n", err)
 				os.Exit(1)
 			}
 		} else {
 			// Config file was found but another error was produced
-			color.New(color.FgRed).Printf("%v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "%v\n", err)
 			os.Exit(1)
 		}
 	}

+ 2 - 1
cli/cmd/connect/ecr.go

@@ -3,6 +3,7 @@ package connect
 import (
 	"context"
 	"fmt"
+	"os"
 	"strings"
 	"time"
 
@@ -53,7 +54,7 @@ Would you like to proceed? %s `,
 		creds, err := agent.CreateIAMECRUser(region)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return ecrManual(client, projectID, region)
 		}
 

+ 7 - 7
cli/cmd/connect/kubeconfig.go

@@ -351,14 +351,14 @@ Would you like to proceed? %s `,
 		agent, err := gcpLocal.NewDefaultAgent()
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		projID, err := agent.GetProjectIDForGKECluster(endpoint)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
@@ -370,14 +370,14 @@ Would you like to proceed? %s `,
 		resp, err := agent.CreateServiceAccount(name)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		err = agent.SetServiceAccountIAMPolicy(resp)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
@@ -385,7 +385,7 @@ Would you like to proceed? %s `,
 		bytes, err := agent.CreateServiceAccountKey(resp)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
@@ -454,14 +454,14 @@ Would you like to proceed? %s `,
 		agent, err := awsLocal.NewDefaultKubernetesAgent(kubeconfigPath, contextName)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess, resolver)
 		}
 
 		creds, err := agent.CreateIAMKubernetesMapping(awsClusterIDGuess)
 
 		if err != nil {
-			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			color.New(color.FgRed).Fprintf(os.Stderr, "Automatic creation failed, manual input required. Error was: %v\n", err)
 			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess, resolver)
 		}
 

+ 3 - 2
cli/cmd/errors.go

@@ -3,6 +3,7 @@ package cmd
 import (
 	"context"
 	"errors"
+	"os"
 	"strings"
 
 	"github.com/fatih/color"
@@ -32,7 +33,7 @@ func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUse
 			return ErrCannotConnect
 		}
 
-		red.Printf("Error: %v\n", err.Error())
+		red.Fprintf(os.Stderr, "Error: %v\n", err.Error())
 		return err
 	}
 
@@ -51,7 +52,7 @@ func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUse
 			return nil
 		}
 
-		red.Printf("Error: %v\n", err.Error())
+		red.Fprintf(os.Stderr, "Error: %v\n", err.Error())
 		return err
 	}
 

+ 1 - 1
cli/cmd/list.go

@@ -27,7 +27,7 @@ var listCmd = &cobra.Command{
 				os.Exit(1)
 			}
 		} else {
-			color.New(color.FgRed).Printf("invalid command: %s\n", args[0])
+			color.New(color.FgRed).Fprintf(os.Stderr, "invalid command: %s\n", args[0])
 		}
 	},
 }

+ 28 - 7
cli/cmd/run.go

@@ -111,12 +111,33 @@ func init() {
 }
 
 func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
+	execArgs := args[1:]
+
+	color.New(color.FgGreen).Println("Running", strings.Join(execArgs, " "), "for release", args[0])
 
 	if nonInteractive {
 		color.New(color.FgBlue).Println("Using non-interactive mode. The first available pod will be used to run the command.")
 	}
 
+	if len(execArgs) > 0 {
+		release, err := client.GetRelease(
+			context.Background(), cliConf.Project, cliConf.Cluster, namespace, args[0],
+		)
+
+		if err != nil {
+			return fmt.Errorf("error fetching release %s: %w", args[0], err)
+		}
+
+		if release.BuildConfig != nil &&
+			(strings.Contains(release.BuildConfig.Builder, "heroku") ||
+				strings.Contains(release.BuildConfig.Builder, "paketo")) &&
+			execArgs[0] != "/cnb/lifecycle/launcher" &&
+			execArgs[0] != "launcher" {
+			// this is a buildpacks release using a heroku builder, prepend the launcher
+			execArgs = append([]string{"/cnb/lifecycle/launcher"}, execArgs...)
+		}
+	}
+
 	podsSimple, err := getPods(client, namespace, args[0])
 
 	if err != nil {
@@ -202,10 +223,10 @@ func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 	}
 
 	if existingPod {
-		return executeRun(config, namespace, selectedPod.Name, selectedContainerName, args[1:])
+		return executeRun(config, namespace, selectedPod.Name, selectedContainerName, execArgs)
 	}
 
-	return executeRunEphemeral(config, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	return executeRunEphemeral(config, namespace, selectedPod.Name, selectedContainerName, execArgs)
 }
 
 func cleanup(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
@@ -815,15 +836,15 @@ func isPodExited(pod *v1.Pod) bool {
 
 func handlePodAttachError(err error, config *PorterRunSharedConfig, namespace, podName, container string) error {
 	if verbose {
-		color.New(color.FgYellow).Printf("Error: %s\n", err)
+		color.New(color.FgYellow).Fprintf(os.Stderr, "Error: %s\n", err)
 	}
-	color.New(color.FgYellow).Println("Could not open a shell to this container. Container logs:")
+	color.New(color.FgYellow).Fprintln(os.Stderr, "Could not open a shell to this container. Container logs:")
 
 	var writtenBytes int64
 	writtenBytes, _ = pipePodLogsToStdout(config, namespace, podName, container, false)
 
 	if verbose || writtenBytes == 0 {
-		color.New(color.FgYellow).Println("Could not get logs. Pod events:")
+		color.New(color.FgYellow).Fprintln(os.Stderr, "Could not get logs. Pod events:")
 		pipeEventsToStdout(config, namespace, podName, container, false)
 	}
 	return err
@@ -892,7 +913,7 @@ func deletePod(config *PorterRunSharedConfig, name, namespace string) error {
 	)
 
 	if err != nil {
-		color.New(color.FgRed).Printf("Could not delete ephemeral pod: %s\n", err.Error())
+		color.New(color.FgRed).Fprintf(os.Stderr, "Could not delete ephemeral pod: %s\n", err.Error())
 		return err
 	}
 

+ 1 - 1
cli/cmd/stack.go

@@ -26,7 +26,7 @@ var stackEnvGroupCmd = &cobra.Command{
 	Aliases: []string{"eg", "envgroup", "env-groups", "envgroups"},
 	Short:   "Commands to add or remove an env group in a stack",
 	Run: func(cmd *cobra.Command, args []string) {
-		color.New(color.FgRed).Println("need to specify an operation to continue")
+		color.New(color.FgRed).Fprintln(os.Stderr, "need to specify an operation to continue")
 	},
 }
 

+ 1 - 1
cli/cmd/utils/close.go

@@ -20,7 +20,7 @@ func closeHandler(closer func() error) {
 			os.Exit(0)
 		}
 
-		color.New(color.FgRed).Printf("shutdown unsuccessful: %s\n", err.Error())
+		color.New(color.FgRed).Fprintf(os.Stderr, "shutdown unsuccessful: %s\n", err.Error())
 		os.Exit(1)
 	}()
 }

+ 14 - 4
dashboard/src/components/Banner.tsx

@@ -6,11 +6,17 @@ import warning from "assets/warning.png";
 
 interface Props {
   type?: string;
+  icon?: React.ReactNode;
   children: React.ReactNode;
+  noMargin?: boolean;
 }
 
-const Banner: React.FC<Props> = ({ type, children }) => {
+const Banner: React.FC<Props> = ({ type, icon, children, noMargin }) => {
   const renderIcon = () => {
+    if (icon) {
+      return icon;
+    }
+
     if (type === "error" || type === "warning") {
       return <i className="material-icons-round">warning</i>;
     }
@@ -20,6 +26,7 @@ const Banner: React.FC<Props> = ({ type, children }) => {
   return (
     <StyledBanner
       color={type === "error" ? "#ff385d" : type === "warning" && "#f5cb42"}
+      noMargin={noMargin}
     >
       {renderIcon()}
       {children}
@@ -29,16 +36,19 @@ const Banner: React.FC<Props> = ({ type, children }) => {
 
 export default Banner;
 
-const StyledBanner = styled.div<{ color?: string }>`
+const StyledBanner = styled.div<{
+  color?: string;
+  noMargin?: boolean;
+}>`
   height: 40px;
   width: 100%;
-  margin: 5px 0 10px;
+  margin: ${(props) => (props.noMargin ? "5px 0 10px" : "")};
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
   display: flex;
   border: 1px solid ${(props) => props.color || "#ffffff00"};
   border-radius: 8px;
-  padding-inline: 14px;
+  padding: 14px;
   color: ${(props) => props.color || "#ffffff"};
   align-items: center;
   background: #ffffff11;

+ 0 - 202
dashboard/src/components/events/EventCard.tsx

@@ -1,202 +0,0 @@
-import React, { useState } from "react";
-import styled from "styled-components";
-
-type CardProps = {
-  event: any;
-  selectEvent?: (event: any) => void;
-  overrideName?: string;
-};
-
-export const getReadableDate = (s: string) => {
-  let ts = new Date(s);
-  let date = ts.toLocaleDateString();
-  let time = ts.toLocaleTimeString([], {
-    hour: "numeric",
-    minute: "2-digit",
-  });
-  return `${time} ${date}`;
-};
-
-// Rename to Event Card
-const EventCard: React.FunctionComponent<CardProps> = ({
-  event,
-  selectEvent,
-  overrideName,
-}) => {
-  const [showTooltip, setShowTooltip] = useState(false);
-  return (
-    <>
-      <StyledCard
-        onClick={() => selectEvent(event)}
-        status={event.event_type.toLowerCase()}
-      >
-        <ContentContainer>
-          <Icon
-            status={event.event_type.toLowerCase() as any}
-            className="material-icons-outlined"
-          >
-            {event.event_type === "critical" ? "report_problem" : "info"}
-          </Icon>
-          <EventInformation>
-            <EventName>
-              <Helper>{event.resource_type}:</Helper>
-              {event.name}
-            </EventName>
-            <EventReason>{event.last_message}</EventReason>
-          </EventInformation>
-        </ContentContainer>
-        <ActionContainer>
-          <TimestampContainer>
-            <TimestampIcon className="material-icons-outlined">
-              access_time
-            </TimestampIcon>
-            <span>{getReadableDate(event.timestamp)}</span>
-          </TimestampContainer>
-        </ActionContainer>
-      </StyledCard>
-    </>
-  );
-};
-
-export default EventCard;
-
-const StyledCard = styled.div<{ status: string }>`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  border: 1px solid
-    ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff44")};
-  background: #ffffff08;
-  margin-bottom: 5px;
-  border-radius: 10px;
-  padding: 14px;
-  overflow: hidden;
-  height: 80px;
-  font-size: 13px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff11;
-    border: 1px solid
-      ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff66")};
-  }
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const ContentContainer = styled.div`
-  display: flex;
-  height: 100%;
-  width: 100%;
-  align-items: center;
-`;
-
-const Icon = styled.span<{ status: "critical" | "normal" }>`
-  font-size: 20px;
-  margin-left: 10px;
-  margin-right: 20px;
-  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
-`;
-
-const EventInformation = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-  height: 100%;
-`;
-
-const EventName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const Helper = styled.span`
-  text-transform: capitalize;
-  color: #ffffff44;
-  margin-right: 5px;
-`;
-
-const EventReason = styled.div`
-  font-family: "Work Sans", sans-serif;
-  color: #aaaabb;
-  margin-top: 5px;
-`;
-
-const ActionContainer = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  height: 100%;
-`;
-
-const HistoryButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  color: #ffffff44;
-  :hover {
-    background: #32343a;
-    cursor: pointer;
-  }
-`;
-
-const Tooltip = styled.div`
-  position: absolute;
-  left: 0px;
-  word-wrap: break-word;
-  top: 38px;
-  min-height: 18px;
-  padding: 5px 7px;
-  background: #272731;
-  z-index: 999;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  flex: 1;
-  color: white;
-  text-transform: none;
-  font-size: 12px;
-  font-family: "Work Sans", sans-serif;
-  outline: 1px solid #ffffff55;
-  opacity: 0;
-  animation: faded-in 0.2s 0.15s;
-  animation-fill-mode: forwards;
-  @keyframes faded-in {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const TimestampContainer = styled.div`
-  display: flex;
-  white-space: nowrap;
-  align-items: center;
-  justify-self: flex-end;
-  color: #ffffff55;
-  margin-right: 10px;
-  font-size: 13px;
-  min-width: 130px;
-  justify-content: space-between;
-`;
-
-const TimestampIcon = styled.span`
-  margin-right: 7px;
-  font-size: 18px;
-`;

+ 0 - 360
dashboard/src/components/events/SubEventsList.tsx

@@ -1,360 +0,0 @@
-import React, { useContext, useEffect, useMemo, useState } from "react";
-import styled from "styled-components";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import SubEventCard from "./sub-events/SubEventCard";
-import Loading from "components/Loading";
-import LogBucketCard from "./sub-events/LogBucketCard";
-import useLastSeenPodStatus from "./useLastSeenPodStatus";
-
-const getReadableDate = (s: number) => {
-  let ts = new Date(s);
-  let date = ts.toLocaleDateString();
-  let time = ts.toLocaleTimeString([], {
-    hour: "numeric",
-    minute: "2-digit",
-  });
-  return `${time} ${date}`;
-};
-
-const SubEventsList: React.FC<{
-  clearSelectedEvent: () => void;
-  event: any;
-  enableTopMargin?: boolean;
-}> = ({ event, clearSelectedEvent, enableTopMargin }) => {
-  const { currentProject, currentCluster } = useContext(Context);
-  const {
-    status,
-    hasError: hasPodStatusErrored,
-    isLoading: isPodStatusLoading,
-  } = useLastSeenPodStatus({
-    podName: event.name,
-    namespace: event.namespace,
-    resource_type: event.resource_type,
-  });
-  const [isLoading, setIsLoading] = useState(true);
-  const [subEvents, setSubEvents] = useState(null);
-
-  const getData = async () => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-    const kube_event_id = event?.id;
-    let updatedEvent: any = null;
-    try {
-      updatedEvent = await api
-        .getKubeEvent("<token>", {}, { project_id, cluster_id, kube_event_id })
-        .then((res) => res?.data);
-    } catch (error) {
-      console.error(error);
-    }
-
-    let logBucketsParsed = [];
-    try {
-      const logBucketsData = await api
-        .getLogBuckets("token", {}, { project_id, cluster_id, kube_event_id })
-        .then((res) => res?.data);
-
-      logBucketsParsed = logBucketsData.log_buckets.map((bucket: string) => {
-        const [
-          _resourceType,
-          _namespace,
-          resource_name,
-          timestamp,
-        ] = bucket.split(":");
-        return {
-          event_type: "log_bucket",
-          resource_name,
-          timestamp: new Date(Number(timestamp) * 1000).toUTCString(),
-          parent_id: updatedEvent?.id,
-        };
-      });
-    } catch (error) {
-      console.error(error);
-    }
-
-    const subEventsSorted = (updatedEvent.sub_events as any[])
-      .map((s: any) => ({
-        ...s,
-        timestamp: new Date(s.timestamp).getTime(),
-      }))
-      .sort((prev: any, next: any) => next.timestamp - prev.timestamp);
-
-    const firstEvent = subEventsSorted.shift();
-    const lastEvent = subEventsSorted.pop();
-
-    const filteredLogBuckets = (logBucketsParsed as any[]).filter((bucket) => {
-      const bucketTime = new Date(bucket.timestamp).getTime();
-      return (
-        bucketTime >= lastEvent.timestamp && bucketTime <= firstEvent.timestamp
-      );
-    });
-
-    setSubEvents([...updatedEvent.sub_events, ...filteredLogBuckets]);
-    setIsLoading(false);
-  };
-
-  useEffect(() => {
-    getData();
-  }, [event, currentCluster, currentProject]);
-
-  const sortedSubEvents = useMemo(() => {
-    if (!Array.isArray(subEvents)) {
-      return [];
-    }
-    return subEvents
-      .map((s) => ({
-        ...s,
-        timestamp: new Date(s.timestamp).getTime(),
-      }))
-      .sort((prev, next) => next.timestamp - prev.timestamp)
-      .map((s) => ({
-        ...s,
-        timestamp: new Date(s.timestamp).toUTCString(),
-      }));
-  }, [subEvents]);
-
-  return (
-    <>
-      <Timeline enableTopMargin={enableTopMargin}>
-        <ControlRow>
-          <BackButton onClick={clearSelectedEvent}>
-            <i className="material-icons">close</i>
-          </BackButton>
-          <Icon
-            status={event.event_type.toLowerCase() as any}
-            className="material-icons-outlined"
-          >
-            {event.event_type === "critical" ? "report_problem" : "info"}
-          </Icon>
-          <div>
-            Pod {event.name} crashed
-            {event?.resource_type?.toLowerCase() === "pod" && (
-              <StyledHelper>
-                {hasPodStatusErrored ? (
-                  "We couldn't retrieve last pod status, please try again later"
-                ) : (
-                  <>
-                    {isPodStatusLoading ? (
-                      "Loading last seen pod status"
-                    ) : (
-                      <>
-                        Last seen pod status: {status}{" "}
-                        <StatusColor
-                          status={status?.toLowerCase()}
-                        ></StatusColor>
-                      </>
-                    )}
-                  </>
-                )}
-              </StyledHelper>
-            )}
-          </div>
-        </ControlRow>
-        {isLoading ? (
-          <Placeholder>
-            <Loading />
-          </Placeholder>
-        ) : sortedSubEvents?.length ? (
-          <EventsGrid>
-            <Rail />
-            {sortedSubEvents.map((subEvent: any, i: number) => {
-              if (subEvent?.event_type === "log_bucket") {
-                return (
-                  <Wrapper>
-                    <TimelineNode>
-                      <Penumbra>
-                        <Circle />
-                      </Penumbra>
-                      {getReadableDate(subEvent.timestamp)}
-                    </TimelineNode>
-                    <LogBucketCard logEvent={subEvent} />
-                    {i === sortedSubEvents.length - 1 && <RailCover />}
-                  </Wrapper>
-                );
-              }
-              return (
-                <Wrapper>
-                  <TimelineNode>
-                    <Penumbra>
-                      <Circle />
-                    </Penumbra>
-                    {getReadableDate(subEvent.timestamp)}
-                  </TimelineNode>
-                  <SubEventCard subEvent={subEvent} />
-                  {i === sortedSubEvents.length - 1 && <RailCover />}
-                </Wrapper>
-              );
-            })}
-          </EventsGrid>
-        ) : (
-          <Placeholder>
-            <i className="material-icons">search</i>
-            No sub-events were found.
-          </Placeholder>
-        )}
-      </Timeline>
-    </>
-  );
-};
-
-export default SubEventsList;
-
-const StyledHelper = styled.div`
-  color: #aaaabb;
-  line-height: 1.6em;
-  font-size: 13px;
-`;
-
-const Placeholder = styled.div`
-  padding: 30px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 340px;
-  margin-top: 20px;
-  background: #ffffff08;
-  height: calc(50vh - 60px);
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const RailCover = styled.div`
-  background: #202227;
-  height: 100%;
-  width: 35px;
-  position: absolute;
-  top: 20px;
-  left: 0;
-`;
-
-const Penumbra = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #202227;
-  padding: 8px;
-  border-radius: 30px;
-  margin-right: 4px;
-`;
-
-const TimelineNode = styled.div`
-  position: absolute;
-  top: 0;
-  left: 7px;
-  display: flex;
-  align-items: center;
-  color: #aaaabb;
-  font-size: 13px;
-`;
-
-const Circle = styled.div`
-  width: 7px;
-  height: 7px;
-  border-radius: 20px;
-  background: #aaaabb;
-`;
-
-const Wrapper = styled.div`
-  position: relative;
-  width: 100%;
-  padding-top: 35px;
-  padding-left: 35px;
-`;
-
-const Rail = styled.div`
-  position: absolute;
-  top: -8px;
-  left: 17px;
-  width: 3px;
-  height: 100%;
-  z-index: -1;
-  background: #36383d;
-`;
-
-const Timeline = styled.div`
-  margin-top: ${(props: { enableTopMargin: boolean }) =>
-    props.enableTopMargin ? "30px" : "unset"};
-  animation: floatIn 0.3s;
-  animation-timing-function: ease-out;
-  animation-fill-mode: forwards;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(10px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const Icon = styled.span<{ status: "critical" | "normal" }>`
-  font-size: 26px;
-  margin-left: 17px;
-  margin-right: 10px;
-  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  justify-content: flex-start;
-  align-items: center;
-  margin-bottom: 15px;
-  padding-left: 0px;
-  font-weight: 500;
-`;
-
-const BackButton = styled.div`
-  display: flex;
-  width: 37px;
-  z-index: 1;
-  cursor: pointer;
-  height: 37px;
-  align-items: center;
-  justify-content: center;
-  border: 1px solid #ffffff55;
-  border-radius: 100px;
-  background: #ffffff11;
-
-  > i {
-    font-size: 20px;
-  }
-
-  :hover {
-    background: #ffffff22;
-    > img {
-      opacity: 1;
-    }
-  }
-`;
-
-const EventsGrid = styled.div`
-  position: relative;
-  padding-top: 9px;
-`;
-
-const StatusColor = styled.div`
-  display: inline-block;
-  margin-right: 7px;
-  width: 7px;
-  min-width: 7px;
-  height: 7px;
-  background: ${(props: { status: string }) =>
-    props.status === "running"
-      ? "#4797ff"
-      : props.status === "failed" || props.status === "deleted"
-      ? "#ed5f85"
-      : props.status === "completed"
-      ? "#00d12a"
-      : "#f5cb42"};
-  border-radius: 20px;
-`;

+ 0 - 166
dashboard/src/components/events/sub-events/LogBucketCard.tsx

@@ -1,166 +0,0 @@
-import React, { useContext, useEffect, useState } from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import styled, { keyframes } from "styled-components";
-
-type LogBucketCardProps = {
-  logEvent: any;
-};
-
-const LogBucketCard: React.FunctionComponent<LogBucketCardProps> = ({
-  logEvent,
-}) => {
-  const { currentProject, currentCluster } = useContext(Context);
-  const [isLoading, setIsLoading] = useState(true);
-  const [isExpanded, setIsExpanded] = useState(false);
-  const [logs, setLogs] = useState([]);
-
-  const getLogsForBucket = async () => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-    const kube_event_id = logEvent?.parent_id;
-    const timestamp = logEvent?.timestamp;
-    try {
-      const logsData = await api
-        .getLogBucketLogs(
-          "<token>",
-          { timestamp: new Date(timestamp).getTime() },
-          { project_id, cluster_id, kube_event_id }
-        )
-        .then((res) => res?.data);
-
-      if (!Array.isArray(logsData.logs)) {
-        setLogs([]);
-        setIsLoading(false);
-        return;
-      }
-
-      const filteredLogs = logsData.logs.filter((log: string | unknown) => {
-        if (typeof log === "string") {
-          return log.length;
-        }
-        return false;
-      });
-      setLogs(filteredLogs);
-      setIsLoading(false);
-    } catch (error) {
-      console.error(error);
-    }
-  };
-
-  useEffect(() => {
-    if (!isExpanded) {
-      return;
-    }
-
-    if (!Array.isArray(logs) || !logs.length) {
-      getLogsForBucket();
-    }
-  }, [currentProject, currentCluster, logEvent, isExpanded]);
-
-  return (
-    <StyledCard>
-      <FlexCenter expandLogs={isExpanded}>
-        <ShowLogsButton
-          onClick={() => setIsExpanded((prevIsExpanded) => !prevIsExpanded)}
-        >
-          {isExpanded ? "Hide logs" : "Display logs"}
-          <ButtonIcon className="material-icons">
-            {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
-          </ButtonIcon>
-        </ShowLogsButton>
-      </FlexCenter>
-      {isExpanded && (
-        <>
-          {/* Case: Is still getting logs and user triggered expanded */}
-          {isLoading && <Loading>Loading . . .</Loading>}
-          {/* Case: No logs found after the api call */}
-          {!isLoading && !logs?.length && <Loading>No logs found.</Loading>}
-          {/* Case: Logs were found successfully  */}
-          {!isLoading && !!logs?.length && logs?.map((l) => <Log>{l}</Log>)}
-        </>
-      )}
-    </StyledCard>
-  );
-};
-
-export default LogBucketCard;
-
-const Loading = styled.div`
-  margin-top: 5px;
-  margin-left: 5px;
-`;
-
-const Log = styled.div`
-  font-family: monospace, sans-serif;
-  font-size: 12px;
-  color: white;
-`;
-
-const FlexCenter = styled.div`
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  ${(props: { expandLogs: boolean }) => {
-    if (!props.expandLogs) {
-      return "";
-    }
-
-    return `
-      border-bottom: solid 1px;
-      padding-bottom: 15px;
-      margin-bottom: 15px;
-      border-color: #515256;
-    `;
-  }}
-  transition-property: all;
-  transition-duration: 0.5s;
-  transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
-`;
-
-const fadeInKeyframe = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-const StyledCard = styled.div`
-  border: 1px solid #ffffff44;
-  margin-bottom: 30px;
-  border-radius: 10px;
-  padding: 14px;
-  padding-left: 13px;
-  font-size: 13px;
-  background: #121318;
-  user-select: text;
-  overflow-wrap: break-word;
-  overflow-y: auto;
-  min-height: 55px;
-  color: #aaaabb;
-
-  animation: ${fadeInKeyframe} 0.5s;
-`;
-
-const ShowLogsButton = styled.button`
-  border: solid 1px;
-  border-radius: 10px;
-  border-color: #515256;
-  color: white;
-  background: none;
-  padding: 8px 12px 8px 20px;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-
-  :hover {
-    cursor: pointer;
-    background: #5152569c;
-  }
-`;
-
-const ButtonIcon = styled.i`
-  padding-left: 5px;
-`;

+ 0 - 57
dashboard/src/components/events/sub-events/SubEventCard.tsx

@@ -1,57 +0,0 @@
-import React, { useState } from "react";
-import styled from "styled-components";
-
-type CardProps = {
-  subEvent: any;
-};
-
-const SubEventCard: React.FunctionComponent<CardProps> = ({ subEvent }) => {
-  return (
-    <StyledCard status={subEvent.event_type.toLowerCase()}>
-      <Icon
-        status={subEvent.event_type.toLowerCase() as any}
-        className="material-icons-outlined"
-      >
-        {subEvent.event_type.toLowerCase() === "critical"
-          ? "report_problem"
-          : "info"}
-      </Icon>
-      {subEvent.message}
-    </StyledCard>
-  );
-};
-
-export default SubEventCard;
-
-const StyledCard = styled.div<{ status: string }>`
-  display: flex;
-  align-items: center;
-  justify-content: flex-start;
-  border: 1px solid
-    ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff44")};
-  background: #ffffff08;
-  margin-bottom: 30px;
-  border-radius: 10px;
-  padding: 14px;
-  padding-left: 13px;
-  overflow: hidden;
-  min-height: 55px;
-  font-size: 13px;
-  color: #aaaabb;
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const Icon = styled.span<{ status: "critical" | "normal" }>`
-  font-size: 20px;
-  margin-left: 10px;
-  margin-right: 13px;
-  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
-`;

+ 0 - 217
dashboard/src/components/events/useEvents.ts

@@ -1,217 +0,0 @@
-import { unionBy } from "lodash";
-import { useContext, useEffect, useMemo, useState } from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { KubeEvent } from "shared/types";
-
-type UseKubeEventsProps = {
-  resourceType: "NODE" | "POD" | "HPA";
-  ownerName?: string;
-  ownerType?: string;
-  shouldWaitForOwner?: boolean;
-  ownerNamespace?: string;
-};
-
-export const useKubeEvents = ({
-  resourceType,
-  ownerName,
-  ownerType,
-  shouldWaitForOwner,
-  ownerNamespace,
-}: UseKubeEventsProps) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const [hasPorterAgent, setHasPorterAgent] = useState(false);
-
-  const [isLoading, setIsLoading] = useState(true);
-  const [kubeEvents, setKubeEvents] = useState<KubeEvent[]>([]);
-  const [hasMore, setHasMore] = useState(true);
-  const [totalCount, setTotalCount] = useState(0);
-
-  // Check if the porter agent is installed or not
-  useEffect(() => {
-    let isSubscribed = true;
-
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-
-    api
-      .detectPorterAgent("<token>", {}, { project_id, cluster_id })
-      .then(() => {
-        setHasPorterAgent(true);
-      })
-      .catch(() => {
-        setHasPorterAgent(false);
-        setIsLoading(false);
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentProject, currentCluster]);
-
-  // Get events
-  useEffect(() => {
-    let isSubscribed = true;
-
-    if (shouldWaitForOwner && !ownerName?.length && !ownerType?.length) {
-      return () => {
-        isSubscribed = false;
-      };
-    }
-
-    if (hasPorterAgent) {
-      fetchData(true).then(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
-      });
-    }
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [
-    currentProject?.id,
-    currentCluster?.id,
-    hasPorterAgent,
-    resourceType,
-    ownerType,
-    ownerName,
-  ]);
-
-  const fetchData = async (clear?: boolean) => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-    let skipBy;
-    if (!clear) {
-      skipBy = kubeEvents?.length;
-    } else {
-      setHasMore(true);
-    }
-
-    const type = resourceType;
-
-    try {
-      const data = await api
-        .getKubeEvents(
-          "<token>",
-          {
-            skip: skipBy,
-            resource_type: type,
-            owner_name: ownerName,
-            owner_type: ownerType,
-            namespace: ownerNamespace,
-          },
-          { project_id, cluster_id }
-        )
-        .then((res) => res.data);
-
-      const newKubeEvents = data?.kube_events;
-      const totalCount = data?.count;
-
-      setTotalCount(totalCount);
-
-      if (!newKubeEvents?.length) {
-        setHasMore(false);
-        return;
-      }
-
-      if (clear) {
-        setKubeEvents(newKubeEvents);
-
-        if (totalCount === newKubeEvents.length) {
-          setHasMore(false);
-        } else {
-          setHasMore(true);
-        }
-
-        return;
-      }
-
-      const newEvents = unionBy(kubeEvents, newKubeEvents, "id");
-
-      if (totalCount === newEvents.length) {
-        setHasMore(false);
-      } else {
-        setHasMore(true);
-      }
-
-      setKubeEvents(newEvents);
-    } catch (error) {
-      console.log(error);
-    }
-  };
-
-  const installPorterAgent = () => {
-    const project_id = currentProject?.id;
-    const cluster_id = currentCluster?.id;
-
-    api
-      .installPorterAgent("<token>", {}, { project_id, cluster_id })
-      .then(() => {
-        setHasPorterAgent(true);
-      })
-      .catch(() => {
-        setHasPorterAgent(false);
-      });
-  };
-
-  const getLastSubEvent = (
-    subEvents: {
-      event_type: string;
-      message: string;
-      reason: string;
-      timestamp: string;
-    }[]
-  ) => {
-    const sortedEvents = subEvents
-      .map((s) => {
-        return {
-          ...s,
-          timestamp: new Date(s.timestamp).getTime(),
-        };
-      })
-      .sort((prev, next) => next.timestamp - prev.timestamp);
-
-    return sortedEvents[0];
-  };
-
-  // Fill up the data missing on events with the subevents
-  const processedKubeEvents = useMemo(() => {
-    return kubeEvents
-      .filter((event) => {
-        if (
-          !Array.isArray(event?.sub_events) ||
-          event.sub_events.length === 0
-        ) {
-          return false;
-        }
-        return true;
-      })
-      .map((e: any) => {
-        const lastSubEvent = getLastSubEvent(e.sub_events);
-
-        return {
-          ...e,
-          event_type: lastSubEvent.event_type,
-          timestamp: new Date(lastSubEvent.timestamp).getTime(),
-          last_message: lastSubEvent.message,
-        };
-      })
-      .sort((prev, next) => next.timestamp - prev.timestamp)
-      .map((s) => ({
-        ...s,
-        timestamp: new Date(s.timestamp).toUTCString(),
-      }));
-  }, [kubeEvents]);
-
-  return {
-    hasPorterAgent,
-    isLoading,
-    kubeEvents: processedKubeEvents,
-    hasMore,
-    totalCount,
-    loadMoreEvents: () => fetchData(),
-    triggerInstall: installPorterAgent,
-  };
-};

+ 0 - 89
dashboard/src/components/events/useLastSeenPodStatus.ts

@@ -1,89 +0,0 @@
-import { useContext, useEffect, useState } from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
-
-const useLastSeenPodStatus = ({
-  podName,
-  namespace,
-  resource_type,
-}: {
-  podName: string;
-  namespace: string;
-  resource_type: string;
-}) => {
-  const [status, setCurrentStatus] = useState(null);
-  const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
-  const { currentProject, currentCluster } = useContext(Context);
-
-  const getPodStatus = (status: any) => {
-    if (
-      status?.phase === "Pending" &&
-      status?.containerStatuses !== undefined
-    ) {
-      return status.containerStatuses[0].state?.waiting?.reason || "Pending";
-    } else if (status?.phase === "Pending") {
-      return "Pending";
-    }
-
-    if (status?.phase === "Failed") {
-      return "failed";
-    }
-
-    if (status?.phase === "Running") {
-      let collatedStatus = "running";
-
-      status?.containerStatuses?.forEach((s: any) => {
-        if (s.state?.waiting) {
-          collatedStatus =
-            s.state?.waiting?.reason === "CrashLoopBackOff"
-              ? "failed"
-              : "waiting";
-        } else if (s.state?.terminated) {
-          collatedStatus = "failed";
-        }
-      });
-      return collatedStatus;
-    }
-  };
-
-  const updatePods = async () => {
-    try {
-      const res = await api.getPodByName(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          namespace: "default",
-          name: podName,
-        }
-      );
-      // console.log(getPodStatus(res.data.status));
-
-      setCurrentStatus(getPodStatus(res.data.status));
-    } catch (error) {
-      if (error?.response?.status === 404) {
-        setCurrentStatus("Deleted");
-      } else {
-        setHasError(true);
-      }
-    } finally {
-      setIsLoading(false);
-    }
-  };
-
-  useEffect(() => {
-    if (resource_type?.toLowerCase() === "pod") {
-      updatePods();
-    }
-  }, [podName, namespace, resource_type]);
-
-  return {
-    status,
-    isLoading,
-    hasError,
-  };
-};
-
-export default useLastSeenPodStatus;

+ 1 - 1
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -109,8 +109,8 @@ const PorterFormWrapper: React.FC<PropsType> = ({
           saveValuesStatus={saveValuesStatus}
           currentTab={currentTab}
           setCurrentTab={(newTab) => {
-            setCurrentTab(newTab);
             onTabChange(newTab);
+            setCurrentTab(newTab);
           }}
           isLaunch={isLaunch}
           hideSpacer={hideBottomSpacer}

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -146,7 +146,6 @@ const Label = styled.div`
   display: flex;
   align-items: center;
   margin-right: 12px;
-
   > i {
     margin-right: 8px;
     font-size: 18px;
@@ -157,4 +156,4 @@ const StyledNamespaceSelector = styled.div`
   display: flex;
   align-items: center;
   font-size: 13px;
-`;
+`;

+ 0 - 214
dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx

@@ -1,214 +0,0 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-import EventCard from "components/events/EventCard";
-import Loading from "components/Loading";
-import InfiniteScroll from "react-infinite-scroll-component";
-import { useKubeEvents } from "components/events/useEvents";
-import SubEventsList from "components/events/SubEventsList";
-
-const availableResourceTypes = [
-  { label: "Pods", value: "POD" },
-  { label: "HPA", value: "HPA" },
-  { label: "Nodes", value: "NODE" },
-];
-
-const EventsTab = () => {
-  const [resourceType, setResourceType] = useState(availableResourceTypes[0]);
-  const [currentEvent, setCurrentEvent] = useState(null);
-
-  const {
-    isLoading,
-    hasPorterAgent,
-    triggerInstall,
-    kubeEvents,
-    loadMoreEvents,
-    hasMore,
-  } = useKubeEvents({ resourceType: resourceType.value as any });
-
-  if (isLoading) {
-    return (
-      <Placeholder>
-        <Loading />
-      </Placeholder>
-    );
-  }
-
-  /* TODO: remove
-  if (!hasPorterAgent) {
-    return (
-      <Placeholder>
-        <div>
-          <Header>We couldn't detect the Porter agent on your cluster</Header>
-          In order to use the events tab, you need to install the Porter agent.
-          <InstallPorterAgentButton onClick={() => triggerInstall()}>
-            <i className="material-icons">add</i> Install Porter agent
-          </InstallPorterAgentButton>
-        </div>
-      </Placeholder>
-    );
-  }
-  */
- 
-  if (currentEvent) {
-    return (
-      <SubEventsList
-        event={currentEvent}
-        clearSelectedEvent={() => setCurrentEvent(null)}
-        enableTopMargin
-      />
-    );
-  }
-
-  return (
-    <EventsPageWrapper>
-      {kubeEvents.length > 0 ? (
-        <>
-          <ControlRow>
-            {/*
-              <Dropdown
-                selectedOption={resourceType}
-                options={availableResourceTypes}
-                onSelect={(o) => setResourceType({ ...o, value: o.value as string })}
-              />
-              */}
-          </ControlRow>
-          <InfiniteScroll
-            dataLength={kubeEvents.length}
-            next={loadMoreEvents}
-            hasMore={hasMore}
-            loader={<h4>Loading...</h4>}
-            scrollableTarget="HomeViewWrapper"
-          >
-            <EventsGrid>
-              {kubeEvents.map((event, i) => {
-                return (
-                  <React.Fragment key={i}>
-                    <EventCard
-                      event={event}
-                      selectEvent={() => setCurrentEvent(event)}
-                    />
-                  </React.Fragment>
-                );
-              })}
-            </EventsGrid>
-          </InfiniteScroll>
-        </>
-      ) : (
-        <Placeholder>
-          <i className="material-icons">search</i>
-          No matching events were found.
-        </Placeholder>
-      )}
-    </EventsPageWrapper>
-  );
-};
-
-export default EventsTab;
-
-const Label = styled.div`
-  color: #ffffff44;
-  margin-right: 8px;
-  font-size: 13px;
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  align-items: center;
-  margin-bottom: 30px;
-  padding-left: 0px;
-  font-size: 13px;
-`;
-
-const EventsPageWrapper = styled.div`
-  font-size: 13px;
-  padding-bottom: 80px;
-  border-radius: 8px;
-  animation: floatIn 0.3s;
-  animation-timing-function: ease-out;
-  animation-fill-mode: forwards;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(10px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 15px;
-  grid-template-columns: 1;
-`;
-
-const InstallPorterAgentButton = styled.button`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border: none;
-  border-radius: 5px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-top: 20px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#5561C0"};
-  :hover {
-    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
-  }
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Header = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-`;

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

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

+ 27 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -141,7 +141,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const getControllers = async (chart: ChartType) => {
-    
     // don't retrieve controllers for chart that failed to even deploy.
     if (chart.info.status == "failed") return;
 
@@ -604,6 +603,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const setRevision = (chart: ChartType, isCurrent?: boolean) => {
+    // if we've set the revision, we also override the revision in log data
+    let newLogData = logData;
+
+    newLogData.revision = `${chart.version}`;
+
+    setLogData(newLogData);
+
     setIsPreview(!isCurrent);
     getChartData(chart);
   };
@@ -618,7 +624,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
       return (
         <Url>
           <i className="material-icons">link</i>
-          <a href={url} target="_blank">{url}</a>
+          <a href={url} target="_blank">
+            {url}
+          </a>
         </Url>
       );
     }
@@ -724,7 +732,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
           cluster_id: currentCluster.id,
         }
       )
-      .then(() => setIsAgentInstalled(true))
+      .then((res) => {
+        if (res.data?.version == "v3") {
+          setIsAgentInstalled(true);
+        } else {
+          setIsAgentInstalled(false);
+        }
+      })
       .catch((err) => {
         setIsAgentInstalled(false);
 
@@ -739,7 +753,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   useEffect(() => {
     if (logData.revision) {
       api
-        .getRevisions(
+        .getChart(
           "<token>",
           {},
           {
@@ -747,15 +761,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
             namespace: props.currentChart.namespace,
             cluster_id: currentCluster.id,
             name: props.currentChart.name,
+            revision: parseInt(logData.revision),
           }
         )
         .then((res) => {
-          const chart = res.data?.find(
-            (revision: ChartType) =>
-              revision.version.toString() === logData.revision
-          );
-
-          setCurrentChart(chart ?? props.currentChart);
+          setCurrentChart(res.data || props.currentChart);
         })
         .catch(console.log);
 
@@ -882,7 +892,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     margin_left={"0px"}
                   />
                   */}
-                  <DeployStatusSection chart={currentChart} />
+                  <DeployStatusSection
+                    chart={currentChart}
+                    setLogData={renderLogsAtTimestamp}
+                  />
                   <LastDeployed>
                     <Dot>•</Dot>Last deployed
                     {" " + getReadableDate(currentChart.info.last_deployed)}
@@ -983,7 +996,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
                             onTabChange={(newTab) => {
                               if (newTab !== "logs") {
                                 setOverrideCurrentTab("");
-                                setLogData({});
+                                setLogData({
+                                  revision: `${currentChart.version}`,
+                                });
                               }
                             }}
                           />

+ 29 - 16
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ControllerTab.tsx

@@ -13,7 +13,7 @@ import _ from "lodash";
 type Props = {
   controller: any;
   selectedPod: any;
-  selectPod: (newPod: any) => unknown;
+  selectPod: (newPod: any, userSelected: boolean) => unknown;
   selectors: any;
   isLast?: boolean;
   isFirst?: boolean;
@@ -161,11 +161,15 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
    * @param rawList A rawList of pods in case we don't want to use the state one. Useful to
    * avoid problems with reactivity
    */
-  const handleSelectPod = (pod: ControllerTabPodType, rawList?: any[]) => {
+  const handleSelectPod = (
+    pod: ControllerTabPodType,
+    rawList?: any[],
+    userSelected?: boolean
+  ) => {
     const rawPod = [...rawPodList, ...(rawList || [])].find(
       (rawPod) => rawPod?.metadata?.name === pod?.name
     );
-    selectPod(rawPod);
+    selectPod(rawPod, !!userSelected);
   };
 
   const currentSelectedPod = useMemo(() => {
@@ -224,18 +228,23 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
   };
 
   const replicaSetArray = useMemo(() => {
-    const podsDividedByReplicaSet = _.sortBy(pods, ["revisionNumber"]).reverse().reduce<
-      Array<Array<ControllerTabPodType>>
-    >(function (prev, currentPod, i) {
-      if (
-        !i ||
-        prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
+    const podsDividedByReplicaSet = _.sortBy(pods, ["revisionNumber"])
+      .reverse()
+      .reduce<Array<Array<ControllerTabPodType>>>(function (
+        prev,
+        currentPod,
+        i
       ) {
-        return prev.concat([[currentPod]]);
-      }
-      prev[prev.length - 1].push(currentPod);
-      return prev;
-    }, []);
+        if (
+          !i ||
+          prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
+        ) {
+          return prev.concat([[currentPod]]);
+        }
+        prev[prev.length - 1].push(currentPod);
+        return prev;
+      },
+      []);
 
     return podsDividedByReplicaSet.length === 1 ? [] : podsDividedByReplicaSet;
   }, [pods]);
@@ -303,10 +312,14 @@ const ControllerTabFC: React.FunctionComponent<Props> = ({
             status === "failed" &&
               pod.status?.message &&
               setPodError(pod.status?.message);
-            handleSelectPod(pod);
+            handleSelectPod(pod, [], true);
             setUserSelectedPod(true);
           }}
-          onDeleteClick={() => setPodPendingDelete(pod)}
+          onDeleteClick={(e: MouseEvent) => {
+            e.preventDefault();
+            e.stopPropagation();
+            setPodPendingDelete(pod);
+          }}
         />
       );
     });

+ 20 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx

@@ -3,9 +3,11 @@ import PodDropdown from "./PodDropdown";
 
 import styled from "styled-components";
 import { getPodStatus } from "./util";
+import { InitLogData } from "../logs-section/LogsSection";
 
 type Props = {
   chart?: any;
+  setLogData: (initLogData: InitLogData) => void;
 };
 
 type DeployStatus = "Deploying" | "Deployed" | "Failed";
@@ -94,7 +96,23 @@ const DeployStatusSection: React.FC<Props> = (props) => {
       </StyledDeployStatusSection>
       <DropdownWrapper expanded={isExpanded}>
         <Dropdown ref={wrapperRef}>
-          <PodDropdown currentChart={props.chart} onUpdate={onUpdate} />
+          <PodDropdown
+            currentChart={props.chart}
+            onUpdate={onUpdate}
+            // Allow users to navigate to pod logs upon clicking the pod
+            onSelectPod={(pod: any) => {
+              console.log(
+                "SET LOG DATA",
+                pod?.metadata?.name,
+                pod?.metadata?.annotations?.["helm.sh/revision"]
+              );
+
+              props.setLogData({
+                podName: pod?.metadata?.name,
+                revision: pod?.metadata?.annotations?.["helm.sh/revision"],
+              });
+            }}
+          />
         </Dropdown>
       </DropdownWrapper>
     </>
@@ -184,7 +202,7 @@ const StatusWrapper = styled.div`
   display: flex;
   justify-content: space-between;
   align-items: center;
-  gap: 5px;
+  gap: 10px;
 `;
 
 const StatusColor = styled.div`

+ 12 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodDropdown.tsx

@@ -12,12 +12,14 @@ type Props = {
   selectors?: string[];
   currentChart: ChartType;
   onUpdate: (props: any) => void;
+  onSelectPod: (pod: any) => void;
 };
 
 const PodDropdown: React.FunctionComponent<Props> = ({
   currentChart,
   selectors,
   onUpdate,
+  onSelectPod,
 }) => {
   const [selectedPod, setSelectedPod] = useState<any>({});
   const [controllers, setControllers] = useState<any[]>([]);
@@ -73,7 +75,13 @@ const PodDropdown: React.FunctionComponent<Props> = ({
           // handle CronJob case
           key={c.metadata?.uid || c.uid}
           selectedPod={selectedPod}
-          selectPod={setSelectedPod}
+          selectPod={(pod: any, userSelected) => {
+            setSelectedPod(pod);
+
+            if (userSelected) {
+              onSelectPod(pod);
+            }
+          }}
           selectors={selectors ? [selectors[i]] : null}
           controller={c}
           isLast={i === controllers?.length - 1}
@@ -94,9 +102,7 @@ const PodDropdown: React.FunctionComponent<Props> = ({
       );
     }
     if (controllers?.length > 0) {
-      return (
-        <TabWrapper>{renderTabs()}</TabWrapper>
-      );
+      return <TabWrapper>{renderTabs()}</TabWrapper>;
     }
 
     return (
@@ -108,17 +114,13 @@ const PodDropdown: React.FunctionComponent<Props> = ({
     );
   };
 
-  return (
-    <StyledStatusSection>
-      {renderStatusSection()}
-    </StyledStatusSection>
-  );
+  return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
 };
 
 export default PodDropdown;
 
 const TabWrapper = styled.div`
-  width: 100%; 
+  width: 100%;
   min-height: 50px;
 `;
 

+ 10 - 9
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodRow.tsx

@@ -22,19 +22,19 @@ const PodRow: React.FunctionComponent<PodRowProps> = ({
   const [showTooltip, setShowTooltip] = useState(false);
 
   return (
-    <Tab key={pod?.name}>
+    <Tab key={pod?.name} onClick={onTabClick}>
       <Gutter>
         <Rail />
         <Circle />
         <Rail lastTab={isLastItem} />
       </Gutter>
       <Name
-        onMouseOver={() => {
-          // setShowTooltip(true);
-        }}
-        onMouseOut={() => {
-          setShowTooltip(false);
-        }}
+      // onMouseOver={() => {
+      //   setShowTooltip(true);
+      // }}
+      // onMouseOut={() => {
+      //   setShowTooltip(false);
+      // }}
       >
         {pod?.name}
       </Name>
@@ -57,14 +57,14 @@ const PodRow: React.FunctionComponent<PodRowProps> = ({
       <Status>
         {podStatus}
         <StatusColor status={podStatus} />
-        {podStatus === "failed" && (
+        {/* {podStatus === "failed" && (
           <CloseIcon
             className="material-icons-outlined"
             onClick={onDeleteClick}
           >
             close
           </CloseIcon>
-        )}
+        )} */}
       </Status>
     </Tab>
   );
@@ -145,6 +145,7 @@ const Tab = styled.div`
   padding: 20px 18px 20px 42px;
   text-shadow: 0px 0px 8px none;
   overflow: visible;
+  cursor: pointer;
 `;
 
 const Rail = styled.div`

+ 244 - 60
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventList.tsx

@@ -16,21 +16,50 @@ import Modal from "main/home/modals/Modal";
 import time from "assets/time.svg";
 import { Context } from "shared/Context";
 import { InitLogData } from "../logs-section/LogsSection";
-import { setServers } from "dns";
+import { Direction, Log, parseLogs } from "../logs-section/useAgentLogs";
+import dayjs from "dayjs";
+import Anser from "anser";
 
 type Props = {
+  namespace: string;
   filters: any;
   setLogData?: (logData: InitLogData) => void;
 };
 
-const EventList: React.FC<Props> = ({ filters, setLogData }) => {
+const EventList: React.FC<Props> = ({ filters, namespace, setLogData }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [events, setEvents] = useState([]);
+  const [logs, setLogs] = useState<Log[]>([]);
   const [expandedEvent, setExpandedEvent] = useState(null);
   const [expandedIncidentEvents, setExpandedIncidentEvents] = useState(null);
   const [isLoading, setIsLoading] = useState(true);
   const [refresh, setRefresh] = useState(true);
 
+  const redirectToLogs = (incident: any) => {
+    api
+      .getIncidentEvents(
+        "<token>",
+        {
+          incident_id: incident.id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        const podName = res.data?.events[0]?.pod_name;
+        const timestamp = res.data?.events[0]?.last_seen;
+        const revision = res.data?.events[0]?.revision;
+
+        setLogData({
+          podName,
+          timestamp,
+          revision,
+        });
+      });
+  };
+
   useEffect(() => {
     if (!refresh) {
       return;
@@ -78,34 +107,45 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
         }
       )
       .then((res) => {
-        setExpandedIncidentEvents(res.data.events);
-      });
-  }, [expandedEvent]);
-
-  const redirectToLogs = (incident: any) => {
-    api
-      .getIncidentEvents(
-        "<token>",
-        {
-          incident_id: incident.id,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
+        if (!expandedEvent.should_view_logs) {
+          return null;
         }
-      )
-      .then((res) => {
-        const podName = res.data?.events[0]?.pod_name;
-        const timestamp = res.data?.events[0]?.last_seen;
-        const revision = res.data?.events[0]?.revision;
 
-        setLogData({
-          podName,
-          timestamp,
-          revision,
-        });
+        const events = res.data?.events ?? [];
+
+        api
+          .getLogs(
+            "<token>",
+            {
+              pod_selector: events[0]?.pod_name,
+              namespace,
+              revision: events[0]?.revision,
+              start_range: dayjs(events[0]?.updated_at)
+                .subtract(14, "day")
+                .toISOString(),
+              end_range: dayjs(events[0]?.updated_at).toISOString(),
+              limit: 100,
+              direction: Direction.backward,
+              search_param: "",
+            },
+            {
+              cluster_id: currentCluster.id,
+              project_id: currentProject.id,
+            }
+          )
+          .then((res) => {
+            const logs = parseLogs(
+              res.data.logs
+                ?.filter(Boolean)
+                .map((logLine: any) => logLine.line)
+                .reverse()
+            );
+            setLogs(logs);
+          });
+
+        setExpandedIncidentEvents(res.data.events);
       });
-  };
+  }, [expandedEvent]);
 
   const renderExpandedEventMessage = () => {
     if (!expandedIncidentEvents) {
@@ -113,10 +153,60 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
     }
 
     return (
-      <Message>
-        <img src={document} />
-        {expandedIncidentEvents[0].detail}
-      </Message>
+      <>
+        <Message>
+          <img src={document} />
+          {expandedIncidentEvents[0].detail}
+        </Message>
+        {logs.length ? (
+          <LogsSectionWrapper>
+            <StyledLogsSection>
+              {logs?.map((log, i) => {
+                return (
+                  <LogSpan key={[log.lineNumber, i].join(".")}>
+                    <span className="line-number">{log.lineNumber}.</span>
+                    <span className="line-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>
+                  </LogSpan>
+                );
+              })}
+            </StyledLogsSection>
+            <ViewLogsWrapper>
+              <DocsLink
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+                  redirectToLogs(expandedEvent);
+                }}
+              >
+                View complete log history
+                <i className="material-icons">open_in_new</i>{" "}
+              </DocsLink>
+            </ViewLogsWrapper>
+          </LogsSectionWrapper>
+        ) : (
+          <LogsLoadWrapper>
+            <Loading />
+          </LogsLoadWrapper>
+        )}
+      </>
     );
   };
 
@@ -185,7 +275,7 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
             },
           },
           {
-            Header: "Last Seen",
+            Header: "Last seen",
             accessor: "timestamp",
             width: 140,
             Cell: ({ row }: CellProps<any>) => {
@@ -213,32 +303,6 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
               return null;
             },
           },
-          {
-            id: "logs",
-            accessor: "",
-            width: 30,
-            Cell: ({ row }: CellProps<any>) => {
-              if (row.original.type != "incident") {
-                return null;
-              }
-
-              if (!row.original.data.should_view_logs) {
-                return null;
-              }
-
-              return (
-                <TableButton
-                  width="102px"
-                  onClick={() => {
-                    redirectToLogs(row.original.data);
-                  }}
-                >
-                  <Icon src={document} />
-                  View logs
-                </TableButton>
-              );
-            },
-          },
         ],
       },
     ],
@@ -248,7 +312,13 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
   return (
     <>
       {expandedEvent && (
-        <Modal onRequestClose={() => setExpandedEvent(null)} height="auto">
+        <Modal
+          onRequestClose={() => {
+            setExpandedEvent(null);
+            setLogs([]);
+          }}
+          height="auto"
+        >
           <TitleSection icon={danger}>
             <Text>{expandedEvent.release_name}</Text>
           </TitleSection>
@@ -266,6 +336,12 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
               <Capitalize>{expandedEvent.severity}</Capitalize>
             </InfoTab>
           </InfoRow>
+          {expandedEvent?.porter_doc_link && (
+            <DocsLink target="_blank" href={expandedEvent?.porter_doc_link}>
+              View troubleshooting steps
+              <i className="material-icons">open_in_new</i>{" "}
+            </DocsLink>
+          )}
           {renderExpandedEventMessage()}
         </Modal>
       )}
@@ -301,6 +377,10 @@ const EventList: React.FC<Props> = ({ filters, setLogData }) => {
 
 export default EventList;
 
+const LogsLoadWrapper = styled.div`
+  height: 50px;
+`;
+
 const Message = styled.div`
   padding: 20px;
   background: #26292e;
@@ -343,7 +423,7 @@ const InfoRow = styled.div`
   display: flex;
   align-items: center;
   justify-content: flex-start;
-  margin-bottom: 25px;
+  margin-bottom: 12px;
 `;
 
 const Text = styled.div`
@@ -367,6 +447,7 @@ const TableButton = styled.div<{ width?: string }>`
   justify-content: center;
   background: #ffffff11;
   border: 1px solid #aaaabb33;
+  margin-right: -17px;
   cursor: pointer;
   :hover {
     border: 1px solid #7a7b80;
@@ -489,3 +570,106 @@ const FlexRow = styled.div`
   flex-wrap: wrap;
   margin-top: 20px;
 `;
+
+const DocsLink = styled.a`
+  display: inline-block;
+  color: #8590ff;
+  border-bottom: 1px solid #8590ff;
+  cursor: pointer;
+  user-select: none;
+  padding: 3px 0;
+  margin-bottom: 18px;
+
+  > i {
+    font-size: 12px;
+    margin-left: 5px;
+  }
+`;
+
+const LogsSectionWrapper = styled.div`
+  position: relative;
+`;
+
+const StyledLogsSection = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  max-height: 400px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  border-top: none;
+  background: #101420;
+  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 LogSpan = 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"};
+`;
+
+export const ViewLogsWrapper = styled.div`
+  margin-bottom: -15px;
+  margin-top: 15px;
+`;

+ 59 - 7
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -20,18 +20,57 @@ const EventsTab: React.FC<Props> = ({
   overridingJobName,
 }) => {
   const [hasPorterAgent, setHasPorterAgent] = useState(true);
+  const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
   const { currentProject, currentCluster } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
 
   useEffect(() => {
+    // determine if the agent is installed properly - if not, start by render upgrade screen
+    checkForAgent();
+  }, []);
+
+  useEffect(() => {
+    if (!isPorterAgentInstalling) {
+      return;
+    }
+
+    const checkForAgentInterval = setInterval(checkForAgent, 3000);
+
+    return () => clearInterval(checkForAgentInterval);
+  }, [isPorterAgentInstalling]);
+
+  const checkForAgent = () => {
     const project_id = currentProject?.id;
     const cluster_id = currentCluster?.id;
 
-    // determine if the agent is installed properly - if not, render upgrade screen
     api
       .detectPorterAgent("<token>", {}, { project_id, cluster_id })
       .then((res) => {
-        console.log(res.data);
+        if (res.data?.version != "v3") {
+          setHasPorterAgent(false);
+        } else {
+          // next, check whether events can be queried - if they can, we're good to go
+          let filters: any = getFilters();
+
+          let apiQuery = api.listPorterEvents;
+
+          if (filters.job_name) {
+            apiQuery = api.listPorterJobEvents;
+          }
+
+          apiQuery("<token>", filters, {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          })
+            .then((res) => {
+              setHasPorterAgent(true);
+              setIsPorterAgentInstalling(false);
+            })
+            .catch((err) => {
+              // do nothing - this is expected while installing
+            });
+        }
+
         setIsLoading(false);
       })
       .catch((err) => {
@@ -40,18 +79,19 @@ const EventsTab: React.FC<Props> = ({
           setIsLoading(false);
         }
       });
-  }, []);
+  };
 
   const installAgent = async () => {
     const project_id = currentProject?.id;
     const cluster_id = currentCluster?.id;
 
+    setIsPorterAgentInstalling(true);
+
     api
       .installPorterAgent("<token>", {}, { project_id, cluster_id })
-      .then(() => {
-        setHasPorterAgent(true);
-      })
+      .then()
       .catch((err) => {
+        setIsPorterAgentInstalling(false);
         console.log(err);
       });
   };
@@ -75,6 +115,14 @@ const EventsTab: React.FC<Props> = ({
     };
   };
 
+  if (isPorterAgentInstalling) {
+    return (
+      <Placeholder>
+        <Header>Installing agent...</Header>
+      </Placeholder>
+    );
+  }
+
   if (isLoading) {
     return (
       <Placeholder>
@@ -99,7 +147,11 @@ const EventsTab: React.FC<Props> = ({
 
   return (
     <EventsPageWrapper>
-      <EventList setLogData={setLogData} filters={getFilters()} />
+      <EventList
+        namespace={currentChart.namespace}
+        setLogData={setLogData}
+        filters={getFilters()}
+      />
     </EventsPageWrapper>
   );
 };

+ 81 - 37
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -14,9 +14,11 @@ import DeploymentType from "../DeploymentType";
 import JobMetricsSection from "../metrics/JobMetricsSection";
 import Logs from "../status/Logs";
 import { useRouting } from "shared/routing";
-import Banner from "components/Banner";
 import LogsSection from "../logs-section/LogsSection";
 import EventsTab from "../events/EventsTab";
+import { getPodStatus } from "../deploy-status-section/util";
+import { capitalize } from "shared/string_utils";
+import { usePods } from "shared/hooks/usePods";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -51,18 +53,75 @@ const getLatestPod = (pods: any[]) => {
     .shift();
 };
 
-const renderStatus = (job: any, time: string) => {
+export const isRunning = (deleting: boolean, job: any, pod: any) => {
+  if (deleting) {
+    return false;
+  }
+
+  if (job.status?.succeeded >= 1) {
+    return false;
+  }
+
+  if (job.status?.conditions) {
+    if (job.status?.conditions[0]?.reason == "DeadlineExceeded") {
+      return false;
+    }
+  }
+
+  if (job.status?.failed >= 1) {
+    return false;
+  }
+
+  if (job.status?.active >= 1) {
+    // determine the status from the pod
+    return pod ? pod.status.startTime : false;
+  }
+
+  return true;
+};
+
+export const renderStatus = (
+  deleting: boolean,
+  job: any,
+  pod: any,
+  time?: string
+) => {
+  if (deleting) {
+    return <Status color="#cc3d42">Deleting</Status>;
+  }
+
   if (job.status?.succeeded >= 1) {
-    return <Status color="#38a88a">Succeeded {time}</Status>;
+    if (time) {
+      return <Status color="#38a88a">Succeeded at {time}</Status>;
+    }
+
+    return <Status color="#38a88a">Succeeded</Status>;
+  }
+
+  if (job.status?.conditions) {
+    if (job.status?.conditions[0]?.reason == "DeadlineExceeded") {
+      return <Status color="#cc3d42">Timed Out</Status>;
+    }
   }
 
   if (job.status?.failed >= 1) {
     return <Status color="#cc3d42">Failed</Status>;
   }
 
+  if (job.status?.active >= 1) {
+    // determine the status from the pod
+    return pod ? (
+      <Status color="#ffffff11">{capitalize(getPodStatus(pod?.status))}</Status>
+    ) : (
+      <Status color="#ffffff11">Running</Status>
+    );
+  }
+
   return <Status color="#ffffff11">Running</Status>;
 };
 
+type ExpandedJobRunTabs = "events" | "logs" | "metrics" | "config" | string;
+
 const ExpandedJobRun = ({
   currentChart,
   jobRun,
@@ -75,44 +134,25 @@ const ExpandedJobRun = ({
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
-  const [currentTab, setCurrentTab] = useState<
-    "events" | "logs" | "metrics" | "config" | string
-  >(currentCluster.agent_integration_enabled ? "events" : "logs");
-  const [pods, setPods] = useState<any>(null);
-  const [isLoading, setIsLoading] = useState(true);
+  const [currentTab, setCurrentTab] = useState<ExpandedJobRunTabs>(
+    currentCluster.agent_integration_enabled ? "events" : "logs"
+  );
   const { pushQueryParams } = useRouting();
   const [useDeprecatedLogs, setUseDeprecatedLogs] = useState(false);
 
+  const [pods, isLoading] = usePods({
+    project_id: currentProject.id,
+    cluster_id: currentCluster.id,
+    namespace: jobRun.metadata?.namespace,
+    selectors: [`job-name=${jobRun.metadata?.name}`],
+    controller_kind: "job",
+    controller_name: jobRun.metadata?.name,
+    subscribed: true,
+  });
+
   let chart = currentChart;
   let run = jobRun;
 
-  useEffect(() => {
-    let isSubscribed = true;
-    setIsLoading(true);
-    api
-      .getJobPods(
-        "<token>",
-        {},
-        {
-          id: currentProject.id,
-          name: jobRun.metadata?.name,
-          cluster_id: currentCluster.id,
-          namespace: jobRun.metadata?.namespace,
-        }
-      )
-      .then((res) => {
-        if (isSubscribed) {
-          setPods(res.data);
-          setIsLoading(false);
-        }
-      })
-      .catch((err) => setCurrentError(JSON.stringify(err)));
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [jobRun]);
-
   useEffect(() => {
     return () => {
       pushQueryParams({}, ["job"]);
@@ -171,6 +211,7 @@ const ExpandedJobRun = ({
       <EventsTab
         currentChart={currentChart}
         overridingJobName={jobRun.metadata?.name}
+        setLogData={() => setCurrentTab("logs")}
       />
     );
   };
@@ -205,7 +246,6 @@ const ExpandedJobRun = ({
           isFullscreen={false}
           setIsFullscreen={() => {}}
           overridingPodName={pods[0]?.metadata?.name || jobRun.metadata?.name}
-          setInitData={() => {}}
           currentChart={currentChart}
         />
       </JobLogsWrapper>
@@ -255,7 +295,9 @@ const ExpandedJobRun = ({
         <InfoWrapper>
           <LastDeployed>
             {renderStatus(
+              false,
               run,
+              pods[0],
               run.status.completionTime
                 ? readableDate(run.status.completionTime)
                 : ""
@@ -270,7 +312,9 @@ const ExpandedJobRun = ({
       <BodyWrapper>
         <TabRegion
           currentTab={currentTab}
-          setCurrentTab={(x: string) => setCurrentTab(x)}
+          setCurrentTab={(newTab: string) => {
+            setCurrentTab(newTab);
+          }}
           options={options}
         >
           {currentTab === "events" && renderEventsSection()}

+ 100 - 325
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -1,19 +1,15 @@
-import React, { Component, MouseEvent } from "react";
+import React, { MouseEvent, useContext, useState } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 import _ from "lodash";
 
 import api from "shared/api";
-import Logs from "../status/Logs";
-import plus from "assets/plus.svg";
-import closeRounded from "assets/close-rounded.png";
-import KeyValueArray from "components/form-components/KeyValueArray";
 import DynamicLink from "components/DynamicLink";
 import { readableDate } from "shared/string_utils";
-import CommandLineIcon from "assets/command-line-icon";
-import ConnectToJobInstructionsModal from "./ConnectToJobInstructionsModal";
+import { isRunning, renderStatus } from "./ExpandedJobRun";
+import { usePods } from "shared/hooks/usePods";
 
-type PropsType = {
+type Props = {
   job: any;
   handleDelete: () => void;
   deleting: boolean;
@@ -25,50 +21,40 @@ type PropsType = {
   repositoryUrl?: string;
 };
 
-type StateType = {
-  expanded: boolean;
-  configIsExpanded: boolean;
-  pods: any[];
-  showConnectionModal: boolean;
-};
+const JobResource: React.FC<Props> = (props) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
 
-export default class JobResource extends Component<PropsType, StateType> {
-  state = {
-    expanded: false,
-    configIsExpanded: false,
-    pods: [] as any[],
-    showConnectionModal: false,
-  };
+  const [showConnectionModal, setShowConnectionModal] = useState(false);
 
-  expandJob = (event: MouseEvent) => {
-    if (event) {
-      event.stopPropagation();
-    }
+  const [pods, isLoading] = usePods({
+    project_id: currentProject.id,
+    cluster_id: currentCluster.id,
+    namespace: props.job.metadata?.namespace,
+    selectors: [`job-name=${props.job.metadata?.name}`],
+    controller_kind: "job",
+    controller_name: props.job.metadata?.name,
+    subscribed: props.job?.status.active,
+  });
 
-    this.getPods(() => {
-      this.setState({ expanded: !this.state.expanded });
-    });
-  };
-
-  stopJob = (event: MouseEvent) => {
+  const stopJob = (event: MouseEvent) => {
     if (event) {
       event.stopPropagation();
     }
 
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-
     api
       .stopJob(
         "<token>",
         {},
         {
           id: currentProject.id,
-          name: this.props.job.metadata?.name,
-          namespace: this.props.job.metadata?.namespace,
+          name: props.job.metadata?.name,
+          namespace: props.job.metadata?.namespace,
           cluster_id: currentCluster.id,
         }
       )
-      .then((res) => {})
+      .then(() => {})
       .catch((err) => {
         let parsedErr = err?.response?.data?.error;
         if (parsedErr) {
@@ -78,32 +64,11 @@ export default class JobResource extends Component<PropsType, StateType> {
       });
   };
 
-  getPods = (callback: () => void) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-
-    api
-      .getJobPods(
-        "<token>",
-        {},
-        {
-          id: currentProject.id,
-          name: this.props.job.metadata?.name,
-          cluster_id: currentCluster.id,
-          namespace: this.props.job.metadata?.namespace,
-        }
-      )
-      .then((res) => {
-        this.setState({ pods: res.data });
-        callback();
-      })
-      .catch((err) => setCurrentError(JSON.stringify(err)));
-  };
-
-  getCompletedReason = () => {
+  const getCompletedReason = () => {
     let completeCondition: any;
 
     // get the completed reason from the status
-    this.props.job.status?.conditions?.forEach((condition: any, i: number) => {
+    props.job.status?.conditions?.forEach((condition: any) => {
       if (condition.type == "Complete") {
         completeCondition = condition;
       }
@@ -111,13 +76,11 @@ export default class JobResource extends Component<PropsType, StateType> {
 
     if (!completeCondition) {
       // otherwise look for a failed reason
-      this.props.job.status?.conditions?.forEach(
-        (condition: any, i: number) => {
-          if (condition.type == "Failed") {
-            completeCondition = condition;
-          }
+      props.job.status?.conditions?.forEach((condition: any) => {
+        if (condition.type == "Failed") {
+          completeCondition = condition;
         }
-      );
+      });
     }
 
     // if still no complete condition, return unknown
@@ -131,11 +94,11 @@ export default class JobResource extends Component<PropsType, StateType> {
     );
   };
 
-  getFailedReason = () => {
+  const getFailedReason = () => {
     let failedCondition: any;
 
     // get the completed reason from the status
-    this.props.job.status?.conditions?.forEach((condition: any, i: number) => {
+    props.job.status?.conditions?.forEach((condition: any) => {
       if (condition.type == "Failed") {
         failedCondition = condition;
       }
@@ -146,149 +109,46 @@ export default class JobResource extends Component<PropsType, StateType> {
       : "Failed";
   };
 
-  renderConfigSection = () => {
-    let { job } = this.props;
-    let commandString = job?.spec?.template?.spec?.containers[0]?.command?.join(
-      " "
-    );
-    let envArray = job?.spec?.template?.spec?.containers[0]?.env;
-    let envObject = {} as any;
-    envArray &&
-      envArray.forEach((env: any, i: number) => {
-        const secretName = _.get(env, "valueFrom.secretKeyRef.name");
-        envObject[env.name] = secretName
-          ? `PORTERSECRET_${secretName}`
-          : env.value;
-      });
-
-    // Handle no config to show
-    if (!commandString && _.isEmpty(envObject)) {
-      return;
+  const getSubtitle = () => {
+    if (props.job.status?.succeeded >= 1) {
+      return getCompletedReason();
     }
 
-    if (!this.state.configIsExpanded) {
-      return (
-        <ExpandConfigBar
-          onClick={() => this.setState({ configIsExpanded: true })}
-        >
-          <img src={plus} />
-          Show job config
-        </ExpandConfigBar>
-      );
-    } else {
-      let tag = job.spec.template.spec.containers[0].image.split(":")[1];
-      return (
-        <>
-          <ExpandConfigBar
-            onClick={() => this.setState({ configIsExpanded: false })}
-          >
-            <img src={closeRounded} />
-            Hide Job Config
-          </ExpandConfigBar>
-          <ConfigSection>
-            {commandString ? (
-              <>
-                Command: <Command>{commandString}</Command>
-              </>
-            ) : (
-              <DarkMatter size="-18px" />
-            )}
-            <Row>
-              Image Tag: <Command>{tag}</Command>
-            </Row>
-            {!_.isEmpty(envObject) && (
-              <>
-                <KeyValueArray
-                  envLoader={true}
-                  values={envObject}
-                  label="Environment variables:"
-                  disabled={true}
-                />
-                <DarkMatter />
-              </>
-            )}
-          </ConfigSection>
-        </>
-      );
-    }
-  };
-
-  renderLogsSection = () => {
-    if (this.state.expanded) {
-      return (
-        <>
-          {this.renderConfigSection()}
-          <JobLogsWrapper>
-            <Logs
-              selectedPod={this.state.pods[0]}
-              podError={!this.state.pods[0] ? "Pod no longer exists." : ""}
-              rawText={true}
-            />
-          </JobLogsWrapper>
-        </>
-      );
-    }
-
-    return;
-  };
-
-  getSubtitle = () => {
-    if (this.props.job.status?.succeeded >= 1) {
-      return this.getCompletedReason();
-    }
-
-    if (this.props.job.status?.failed >= 1) {
-      return this.getFailedReason();
+    if (props.job.status?.failed >= 1) {
+      return getFailedReason();
     }
 
     return "Running";
   };
 
-  renderStatus = () => {
-    if (this.props.deleting) {
-      return <Status color="#cc3d42">Deleting</Status>;
-    }
-
-    if (this.props.job.status?.succeeded >= 1) {
-      return <Status color="#38a88a">Succeeded</Status>;
-    }
-
-    if (this.props.job.status?.failed >= 1) {
-      return <Status color="#cc3d42">Failed</Status>;
-    }
-
-    return <Status color="#ffffff11">Running</Status>;
-  };
-
-  renderStopButton = () => {
-    if (this.props.readOnly) {
+  const renderStopButton = () => {
+    if (props.readOnly) {
       return null;
     }
 
-    if (!this.props.job.status?.succeeded && !this.props.job.status?.failed) {
-      // look for a sidecar container
-      if (this.props.job?.spec?.template?.spec?.containers.length == 2) {
-        return (
-          <i className="material-icons" onClick={this.stopJob}>
-            stop
-          </i>
-        );
-      }
+    if (isRunning(props.deleting, props.job, pods[0])) {
+      return (
+        <i className="material-icons" onClick={stopJob}>
+          stop
+        </i>
+      );
     }
+
+    return null;
   };
 
-  getImageTag = () => {
-    const container = this.props.job?.spec?.template?.spec?.containers[0];
+  const getImageTag = () => {
+    const container = props.job?.spec?.template?.spec?.containers[0];
     const tag = container?.image?.split(":")[1];
 
     if (!tag) {
       return "unknown";
     }
 
-    if (this.props.isDeployedFromGithub && tag !== "latest") {
+    if (props.isDeployedFromGithub && tag !== "latest") {
       return (
         <DynamicLink
-          to={`https://github.com/${this.props.repositoryUrl}/commit/${tag}`}
+          to={`https://github.com/${props.repositoryUrl}/commit/${tag}`}
           onClick={(e) => e.preventDefault()}
           target="_blank"
         >
@@ -300,10 +160,10 @@ export default class JobResource extends Component<PropsType, StateType> {
     return tag;
   };
 
-  getRevisionNumber = () => {
-    const revision = this.props.job?.metadata?.labels["helm.sh/revision"];
+  const getRevisionNumber = () => {
+    const revision = props.job?.metadata?.labels["helm.sh/revision"];
     let status: RevisionContainerProps["status"] = "current";
-    if (this.props.currentChartVersion > revision) {
+    if (props.currentChartVersion > revision) {
       status = "outdated";
     }
     return (
@@ -313,70 +173,59 @@ export default class JobResource extends Component<PropsType, StateType> {
     );
   };
 
-  render() {
-    let icon =
-      "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png";
-    let commandString = this.props.job?.spec?.template?.spec?.containers[0]?.command?.join(
-      " "
-    );
-
-    return (
-      <>
-        <StyledJob>
-          <MainRow onClick={() => this.props.expandJob(this.props.job)}>
+  const icon =
+    "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png";
+  const commandString = props.job?.spec?.template?.spec?.containers[0]?.command?.join(
+    " "
+  );
+
+  return (
+    <>
+      <StyledJob>
+        <MainRow onClick={() => props.expandJob(props.job)}>
+          <Flex>
+            <Icon src={icon && icon} />
+            <Description>
+              <Label>
+                Started at {readableDate(props.job.status?.startTime)}
+                <Dot>•</Dot>
+                <span>
+                  {props.isDeployedFromGithub ? "Commit: " : "Image tag:"}{" "}
+                  {getImageTag()}
+                </span>
+              </Label>
+              <Subtitle>{getSubtitle()}</Subtitle>
+            </Description>
+          </Flex>
+          <EndWrapper>
             <Flex>
-              <Icon src={icon && icon} />
-              <Description>
-                <Label>
-                  Started at {readableDate(this.props.job.status?.startTime)}
-                  <Dot>•</Dot>
-                  <span>
-                    {this.props.isDeployedFromGithub
-                      ? "Commit: "
-                      : "Image tag:"}{" "}
-                    {this.getImageTag()}
-                  </span>
-                </Label>
-                <Subtitle>{this.getSubtitle()}</Subtitle>
-              </Description>
+              {getRevisionNumber()}
+              <CommandString>{commandString}</CommandString>
             </Flex>
-            <EndWrapper>
-              <Flex>
-                {this.getRevisionNumber()}
-                <CommandString>{commandString}</CommandString>
-              </Flex>
-
-              {this.renderStatus()}
-              <MaterialIconTray disabled={false}>
-                {this.renderStopButton()}
-                {!this.props.readOnly && (
-                  <i
-                    className="material-icons"
-                    onClick={(e) => {
-                      e.stopPropagation();
-                      this.props.handleDelete();
-                    }}
-                  >
-                    delete
-                  </i>
-                )}
-                {/* <i
+
+            {renderStatus(props.deleting, props.job, pods[0])}
+            <MaterialIconTray disabled={false}>
+              {renderStopButton()}
+              {!props.readOnly && (
+                <i
                   className="material-icons"
-                  onClick={() => this.props.expandJob(this.props.job)}
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    props.handleDelete();
+                  }}
                 >
-                  open_in_new
-                </i> */}
-              </MaterialIconTray>
-            </EndWrapper>
-          </MainRow>
-          {this.renderLogsSection()}
-        </StyledJob>
-      </>
-    );
-  }
-}
+                  delete
+                </i>
+              )}
+            </MaterialIconTray>
+          </EndWrapper>
+        </MainRow>
+      </StyledJob>
+    </>
+  );
+};
 
-JobResource.contextType = Context;
+export default JobResource;
 
 type RevisionContainerProps = {
   status: "outdated" | "current";
@@ -398,49 +247,6 @@ const Dot = styled.div`
   color: #ffffff88;
 `;
 
-const Row = styled.div`
-  margin-top: 20px;
-`;
-
-const DarkMatter = styled.div<{ size?: string }>`
-  width: 100%;
-  margin-bottom: ${(props) => props.size || "-13px"};
-`;
-
-const Command = styled.span`
-  font-family: monospace;
-  color: #aaaabb;
-  margin-left: 7px;
-`;
-
-const ConfigSection = styled.div`
-  padding: 20px 30px;
-  font-size: 13px;
-  font-weight: 500;
-`;
-
-const ExpandConfigBar = styled.div`
-  display: flex;
-  align-items: center;
-  padding-left: 28px;
-  font-size: 13px;
-  height: 40px;
-  width: 100%;
-  background: #3f465288;
-  color: #ffffff;
-  user-select: none;
-  cursor: pointer;
-
-  > img {
-    width: 18px;
-    margin-right: 10px;
-  }
-
-  :hover {
-    background: #3f4652cc;
-  }
-`;
-
 const CommandString = styled.div`
   white-space: nowrap;
   overflow: hidden;
@@ -456,17 +262,6 @@ const EndWrapper = styled.div`
   align-items: center;
 `;
 
-const Status = styled.div<{ color: string }>`
-  padding: 5px 10px;
-  margin-right: 12px;
-  background: ${(props) => props.color};
-  font-size: 13px;
-  border-radius: 3px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-`;
-
 const Icon = styled.img`
   width: 30px;
   margin-right: 18px;
@@ -478,19 +273,6 @@ const Flex = styled.div`
   justify-content: center;
 `;
 
-const StartedText = styled.div`
-  position: relative;
-  text-decoration: none;
-  padding: 8px;
-  font-size: 14px;
-  font-family: "Work Sans", sans-serif;
-  color: #ffffff;
-  width: 80%;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
 const StyledJob = styled.div`
   display: flex;
   flex-direction: column;
@@ -558,10 +340,3 @@ const Subtitle = styled.div`
   align-items: center;
   padding-top: 5px;
 `;
-
-const JobLogsWrapper = styled.div`
-  max-height: 500px;
-  width: 100%;
-  background-color: black;
-  overflow-y: auto;
-`;

+ 112 - 43
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/LogsSection.tsx

@@ -20,6 +20,7 @@ import dayjs from "dayjs";
 import Loading from "components/Loading";
 import _ from "lodash";
 import { ChartType } from "shared/types";
+import Banner from "components/Banner";
 
 export type InitLogData = Partial<{
   podName: string;
@@ -118,11 +119,21 @@ const LogsSection: React.FC<Props> = ({
   const [selectedDate, setSelectedDate] = useState<Date | undefined>(
     initData.timestamp ? dayjs(initData.timestamp).toDate() : undefined
   );
+  const [notification, setNotification] = useState<string>();
+
+  const notify = (message: string) => {
+    setNotification(message);
+
+    setTimeout(() => {
+      setNotification(undefined);
+    }, 5000);
+  };
 
   const { loading, logs, refresh, moveCursor, paginationInfo } = useLogs(
     podFilter,
     currentChart.namespace,
     enteredSearchText,
+    notify,
     currentChart,
     selectedDate
   );
@@ -136,6 +147,7 @@ const LogsSection: React.FC<Props> = ({
       .getLogPodValues(
         "<TOKEN>",
         {
+          namespace: currentChart?.namespace,
           revision: initData.revision ?? currentChart.version.toString(),
           match_prefix: currentChart.name,
         },
@@ -152,7 +164,7 @@ const LogsSection: React.FC<Props> = ({
           setPodFilter(res.data[0]);
         }
       });
-  }, []);
+  }, [initData]);
 
   useEffect(() => {
     if (!loading && scrollToBottomRef.current && scrollToBottomEnabled) {
@@ -163,6 +175,16 @@ const LogsSection: React.FC<Props> = ({
     }
   }, [loading, logs, scrollToBottomRef, scrollToBottomEnabled]);
 
+  useEffect(() => {
+    if (initData.podName) {
+      setPodFilter(initData.podName);
+    }
+
+    if (initData.timestamp) {
+      setSelectedDate(dayjs(initData.timestamp).toDate());
+    }
+  }, [initData]);
+
   const renderLogs = () => {
     return logs?.map((log, i) => {
       return (
@@ -171,17 +193,22 @@ const LogsSection: React.FC<Props> = ({
           <span className="line-timestamp">
             {dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")}
           </span>
-          {log.line?.map((ansi, j) => {
-            if (ansi.clearLine) {
-              return null;
-            }
-
-            return (
-              <LogSpan key={[log.lineNumber, i, j].join(".")} ansi={ansi}>
-                {ansi.content.replace(/ /g, "\u00a0")}
-              </LogSpan>
-            );
-          })}
+          <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>
       );
     });
@@ -257,22 +284,23 @@ const LogsSection: React.FC<Props> = ({
             )}
           </Flex>
         </FlexRow>
-        <StyledLogsSection isFullscreen={isFullscreen}>
-          {loading || !logs.length ? (
-            <Loading message="Waiting for logs..." />
-          ) : (
-            <>
-              <LoadMoreButton
-                active={
-                  logs.length !== 0 && paginationInfo.previousCursor !== null
-                }
-                role="button"
-                onClick={onLoadPrevious}
-              >
-                Load Previous
-              </LoadMoreButton>
-              {renderLogs()}
-              {/* <Message>
+        <LogsSectionWrapper>
+          <StyledLogsSection isFullscreen={isFullscreen}>
+            {loading || !logs.length ? (
+              <Loading message="Waiting for logs..." />
+            ) : (
+              <>
+                <LoadMoreButton
+                  active={
+                    logs.length !== 0 && paginationInfo.previousCursor !== null
+                  }
+                  role="button"
+                  onClick={onLoadPrevious}
+                >
+                  Load Previous
+                </LoadMoreButton>
+                {renderLogs()}
+                {/* <Message>
             
             No matching logs found.
             <Highlight onClick={() => {}}>
@@ -280,17 +308,24 @@ const LogsSection: React.FC<Props> = ({
               Refresh
             </Highlight>
           </Message> */}
-              <LoadMoreButton
-                active={selectedDate && logs.length !== 0}
-                role="button"
-                onClick={() => moveCursor(Direction.forward)}
-              >
-                Load more
-              </LoadMoreButton>
-            </>
-          )}
-          <div ref={scrollToBottomRef} />
-        </StyledLogsSection>
+                <LoadMoreButton
+                  active={selectedDate && logs.length !== 0}
+                  role="button"
+                  onClick={() => moveCursor(Direction.forward)}
+                >
+                  Load more
+                </LoadMoreButton>
+              </>
+            )}
+            <div ref={scrollToBottomRef} />
+          </StyledLogsSection>
+          <NotificationWrapper
+            key={JSON.stringify(logs)}
+            active={!!notification}
+          >
+            <Banner>{notification}</Banner>
+          </NotificationWrapper>
+        </LogsSectionWrapper>
       </>
     );
   };
@@ -319,7 +354,7 @@ export default LogsSection;
 const BackButton = styled.div`
   display: flex;
   width: 30px;
-  z-index: 999;
+  z-index: 2;
   cursor: pointer;
   height: 30px;
   align-items: center;
@@ -523,6 +558,7 @@ const StyledLogsSection = styled.div<{ isFullscreen: boolean }>`
   animation-fill-mode: forwards;
   overflow-y: auto;
   overflow-wrap: break-word;
+  position: relative;
   @keyframes floatIn {
     from {
       opacity: 0;
@@ -565,12 +601,17 @@ const Log = styled.div`
   }
 `;
 
-const LogSpan = styled.span`
+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 }) =>
@@ -602,7 +643,7 @@ const ToggleOption = styled.div<{ selected: boolean; nudgeLeft?: boolean }>`
     props.nudgeLeft ? "0 5px 5px 0" : "5px 0 0 5px"};
   :hover {
     border: 1px solid #7a7b80;
-    z-index: 999;
+    z-index: 2;
   }
 `;
 
@@ -619,6 +660,34 @@ const ToggleButton = styled.div`
 const TimeIcon = styled.img<{ selected?: boolean }>`
   width: 16px;
   height: 16px;
-  z-index: 999;
+  z-index: 2;
   opacity: ${(props) => (props.selected ? "" : "50%")};
 `;
+
+const NotificationWrapper = styled.div<{ active?: boolean }>`
+  position: absolute;
+  bottom: 10px;
+  display: ${(props) => (props.active ? "flex" : "none")};
+  justify-content: center;
+  align-items: center;
+  left: 50%;
+  transform: translateX(-50%);
+  width: fit-content;
+  background: #101420;
+  z-index: 9999;
+
+  @keyframes bounceIn {
+    0% {
+      transform: translateZ(-1400px);
+      opacity: 0;
+    }
+    100% {
+      transform: translateZ(0);
+      opacity: 1;
+    }
+  }
+`;
+
+const LogsSectionWrapper = styled.div`
+  position: relative;
+`;

+ 27 - 7
dashboard/src/main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs.ts

@@ -17,7 +17,7 @@ export enum Direction {
   backward = "backward",
 }
 
-interface Log {
+export interface Log {
   line: AnserJsonEntry[];
   lineNumber: number;
   timestamp: string;
@@ -29,7 +29,7 @@ interface LogLine {
   time: string;
 }
 
-const parseLogs = (logs: string[] = []): Log[] => {
+export const parseLogs = (logs: string[] = []): Log[] => {
   return logs
     .filter(Boolean)
     .filter(isJSON)
@@ -58,6 +58,7 @@ export const useLogs = (
   currentPod: string,
   namespace: string,
   searchParam: string,
+  notify: (message: string) => void,
   currentChart: ChartType,
   // if setDate is set, results are not live
   setDate?: Date
@@ -287,6 +288,12 @@ export const useLogs = (
 
     updateLogs(initialLogs);
 
+    if (!isLive && !initialLogs.length) {
+      notify(
+        "You have no logs for this time period. Try with a different time range."
+      );
+    }
+
     closeWebsocket(websocketKey);
 
     setLoading(false);
@@ -311,10 +318,15 @@ export const useLogs = (
         Direction.backward
       );
 
-      updateLogs(
-        paginationInfo.previousCursor ? newLogs.slice(0, -1) : newLogs,
-        direction
-      );
+      const logsToUpdate = paginationInfo.previousCursor
+        ? newLogs.slice(0, -1)
+        : newLogs;
+
+      updateLogs(logsToUpdate, direction);
+
+      if (!logsToUpdate.length) {
+        notify("You have reached the beginning of the logs");
+      }
 
       setPaginationInfo((paginationInfo) => ({
         ...paginationInfo,
@@ -336,8 +348,16 @@ export const useLogs = (
         Direction.forward
       );
 
+      const logsToUpdate = paginationInfo.nextCursor
+        ? newLogs.slice(1)
+        : newLogs;
+
       // If previously we had next cursor set, it is likely that the log might have a duplicate entry so we ignore the first line
-      updateLogs(paginationInfo.nextCursor ? newLogs.slice(1) : newLogs);
+      updateLogs(logsToUpdate);
+
+      if (!logsToUpdate.length) {
+        notify("You are already at the latest logs");
+      }
 
       setPaginationInfo((paginationInfo) => ({
         ...paginationInfo,

+ 30 - 73
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx

@@ -38,8 +38,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   jobChart: currentChart,
   jobRun,
 }) => {
-  const [pods, setPods] = useState([]);
-  const [selectedPod, setSelectedPod] = useState("");
   const [controllerOptions, setControllerOptions] = useState([]);
   const [selectedController, setSelectedController] = useState(null);
   const [ingressOptions, setIngressOptions] = useState([]);
@@ -99,46 +97,21 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
       });
   }, [currentChart, currentCluster, currentProject]);
 
-  useEffect(() => {
-    getPods();
-  }, [selectedController]);
-
-  const getPods = () => {
-    const jobName = jobRun?.metadata?.name;
-    const selector = `job-name=${jobName}`;
-
-    setIsLoading((prev) => prev + 1);
-
-    api
-      .getMatchingPods(
-        "<token>",
-        {
-          namespace: selectedController?.metadata?.namespace,
-          selectors: [selector],
-        },
-        {
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then((res) => {
-        const pods = res?.data?.map((pod: any) => {
-          let name = pod?.metadata?.name;
-          return { value: name, label: name };
-        });
-
-        setPods(pods);
-        setSelectedPod(pods[0].value);
+  // prometheus has a limit of 11,000 data points to return per metric. we thus ensure that
+  // the resolution will not exceed 11,000 data points.
+  //
+  // This breaks down if the job runs for over 6 years.
+  const getJobResolution = (start: number, end: number) => {
+    let duration = end - start;
+    if (duration <= 3600) {
+      return "1s";
+    } else if (duration <= 54000) {
+      return "15s";
+    } else if (duration <= 216000) {
+      return "60s";
+    }
 
-        getMetrics();
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        return;
-      })
-      .finally(() => {
-        setIsLoading((prev) => prev - 1);
-      });
+    return "5h";
   };
 
   const getAutoscalingThreshold = async (
@@ -161,7 +134,7 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
           namespace: namespace,
           startrange: start,
           endrange: end,
-          resolution: resolutions[selectedRange],
+          resolution: getJobResolution(start, end),
           pods: [],
         },
         {
@@ -184,9 +157,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   };
 
   const getMetrics = async () => {
-    if (pods?.length == 0) {
-      return;
-    }
     try {
       let namespace = currentChart.namespace;
 
@@ -202,8 +172,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
         end = Math.round(new Date().getTime() / 1000);
       }
 
-      let podNames = [selectedPod] as string[];
-
       setIsLoading((prev) => prev + 1);
       setData([]);
 
@@ -211,14 +179,14 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
         "<token>",
         {
           metric: selectedMetric,
-          shouldsum: false,
-          kind: selectedController?.kind,
-          name: selectedController?.metadata.name,
+          shouldsum: true,
+          kind: "job",
+          name: jobRun?.metadata?.name,
           namespace: namespace,
           startrange: start,
           endrange: end,
-          resolution: resolutions[selectedRange],
-          pods: podNames,
+          resolution: getJobResolution(start, end),
+          // pods: podNames,
         },
         {
           id: currentProject.id,
@@ -226,13 +194,15 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
         }
       );
 
-      const metrics = new MetricNormalizer(
-        res.data,
-        selectedMetric as AvailableMetrics
-      );
+      if (res.data.length > 0) {
+        const metrics = new MetricNormalizer(
+          res.data,
+          selectedMetric as AvailableMetrics
+        );
 
-      // transform the metrics to expected form
-      setData(metrics.getParsedData());
+        // transform the metrics to expected form
+        setData(metrics.getParsedData());
+      }
     } catch (error) {
       setCurrentError(JSON.stringify(error));
     } finally {
@@ -241,16 +211,10 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
   };
 
   useEffect(() => {
-    if (selectedMetric && selectedRange && selectedPod && selectedController) {
+    if (selectedMetric && selectedRange && selectedController) {
       getMetrics();
     }
-  }, [
-    selectedMetric,
-    selectedRange,
-    selectedPod,
-    selectedController,
-    selectedIngress,
-  ]);
+  }, [selectedMetric, selectedRange, selectedController, selectedIngress]);
 
   const renderMetricsSettings = () => {
     if (showMetricsSettings && true) {
@@ -284,13 +248,6 @@ const JobMetricsSection: React.FunctionComponent<PropsType> = ({
               options={controllerOptions}
               width="100%"
             />
-            <SelectRow
-              label="Target Pod"
-              value={selectedPod}
-              setActiveValue={(x: any) => setSelectedPod(x)}
-              options={pods}
-              width="100%"
-            />
           </DropdownAlt>
         </>
       );

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

@@ -115,24 +115,20 @@ class Dashboard extends Component<PropsType, StateType> {
       );
     } else if (this.currentTab() === "create-cluster") {
       let helperText = "Create a cluster to link to this project";
-      let helperIcon = "info";
-      let helperColor = "white";
+      let helperType = "info";
       if (
-        this.context.hasBillingEnabled &&
-        this.context.usage.current.clusters !== 0 &&
-        this.context.usage.current.clusters >= this.context.usage.limit.clusters
+        true
       ) {
         helperText =
           "You need to update your billing to provision or connect a new cluster";
-        helperIcon = "warning";
-        helperColor = "#f5cb42";
+        helperType = "warning";
       }
       return (
         <>
-          <Banner color={helperColor}>
-            <i className="material-icons">{helperIcon}</i>
+          <Banner type={helperType} noMargin>
             {helperText}
           </Banner>
+          <Br />
           <ProvisionerSettings infras={this.state.infras} provisioner={true} />
         </>
       );

+ 1 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -28,7 +28,7 @@ type TabOption = {
   value: string;
 };
 
-const HIDDEN_CHARTS = ["porter-agent"];
+const HIDDEN_CHARTS = ["porter-agent", "loki"];
 
 type PropsType = RouteComponentProps & {};
 

+ 2 - 0
dashboard/src/main/home/modals/Modal.tsx

@@ -138,6 +138,7 @@ const StyledModal = styled.div`
   max-width: 80vw;
   height: ${(props: { width?: string; height?: string }) =>
     props.height ? props.height : "425px"};
+  max-height: calc(100vh - 30px);
   overflow: visible;
   padding: 25px 32px;
   z-index: 999;
@@ -145,6 +146,7 @@ const StyledModal = styled.div`
   border-radius: 10px;
   background: #202227;
   border: 1px solid #ffffff55;
+  overflow: auto;
   color: #ffffff;
   animation: floatInModal 0.5s 0s;
   @keyframes floatInModal {

+ 1 - 0
dashboard/src/shared/api.tsx

@@ -1987,6 +1987,7 @@ const getGitlabFolderContent = baseApi<
 
 const getLogPodValues = baseApi<
   {
+    namespace?: string;
     revision?: string;
     match_prefix?: string;
     start_range?: string;

+ 143 - 0
dashboard/src/shared/hooks/usePods.ts

@@ -0,0 +1,143 @@
+import { useEffect, useState } from "react";
+import api from "shared/api";
+import { NewWebsocketOptions, useWebsockets } from "./useWebsockets";
+
+interface Props {
+  selectors: string[];
+  namespace: string;
+  project_id: number;
+  cluster_id: number;
+  controller_kind?: string;
+  controller_name?: string;
+  // subscribed controls whether or not pods should be returned from this hook. for example, we
+  // use this hook to query a list of job runs, but only want to return pods (and live status)
+  // for job runs which are currently active. as we don't want to mess with conditional hooks,
+  // we simply toggle "subscribed" instead
+  subscribed?: boolean;
+}
+
+type UsePods = (props: Props) => [pods: any[], isLoading: boolean];
+
+export const usePods: UsePods = ({
+  selectors,
+  namespace,
+  project_id,
+  cluster_id,
+  controller_kind,
+  controller_name,
+  subscribed,
+}) => {
+  const [pods, setPods] = useState([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const setupWebsocket = () => {
+    let apiEndpoint = `/api/projects/${project_id}/clusters/${cluster_id}/pod/status?`;
+
+    if (selectors) {
+      for (let selector of selectors) {
+        apiEndpoint += `selectors=${selector}`;
+      }
+    }
+
+    const options: NewWebsocketOptions = {};
+
+    options.onopen = () => {
+      console.log("connected to websocket");
+    };
+
+    options.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
+
+      mergeAndUpdatePods(object);
+    };
+
+    options.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    options.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      closeWebsocket(apiEndpoint);
+    };
+
+    newWebsocket(apiEndpoint, apiEndpoint, options);
+    openWebsocket(apiEndpoint);
+  };
+
+  const mergeAndUpdatePods = (pod: any) => {
+    // find pods with the same name and namespace and overwrite them
+    let newPods = [...pods];
+    let assigned = false;
+
+    newPods.forEach((newPod, i) => {
+      if (
+        newPod.metadata.name == pod.metadata.name &&
+        newPod.metadata.namespace == pod.metadata.namespace
+      ) {
+        newPods[i] = pod;
+        assigned = true;
+      }
+    });
+
+    if (!assigned) {
+      newPods = [...newPods, pod];
+    }
+
+    setPods(newPods);
+  };
+
+  useEffect(() => {
+    if (!subscribed) {
+      return;
+    }
+    setIsLoading(true);
+    if (controller_kind == "job") {
+      api
+        .getJobPods(
+          "<token>",
+          {},
+          {
+            id: project_id,
+            name: controller_name,
+            cluster_id: cluster_id,
+            namespace: namespace,
+          }
+        )
+        .then((res) => {
+          setPods(res.data);
+          setIsLoading(false);
+        });
+    } else {
+      api
+        .getMatchingPods(
+          "<token>",
+          {
+            namespace: namespace,
+            selectors: selectors,
+          },
+          {
+            id: project_id,
+            cluster_id: cluster_id,
+          }
+        )
+        .then((res) => {
+          setPods(res.data);
+        });
+    }
+
+    setupWebsocket();
+
+    return () => closeAllWebsockets();
+  }, [project_id, cluster_id]);
+
+  return [pods, isLoading];
+};

+ 8 - 0
dashboard/src/shared/string_utils.ts

@@ -91,6 +91,14 @@ export const timeFrom = (
 };
 
 export const capitalize = (s: string) => {
+  if (!s) {
+    return "";
+  } else if (s.length == 0) {
+    return s;
+  } else if (s.length == 1) {
+    return s.charAt(0).toUpperCase();
+  }
+
   return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
 };
 

+ 9 - 3
internal/helm/agent.go

@@ -180,6 +180,7 @@ func (a *Agent) UpgradeRelease(
 	conf *UpgradeReleaseConfig,
 	values string,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues([]byte(values))
 
@@ -189,13 +190,14 @@ func (a *Agent) UpgradeRelease(
 
 	conf.Values = valuesYaml
 
-	return a.UpgradeReleaseByValues(conf, doAuth)
+	return a.UpgradeReleaseByValues(conf, doAuth, disablePullSecretsInjection)
 }
 
 // UpgradeReleaseByValues upgrades a release by unmarshaled yaml values
 func (a *Agent) UpgradeReleaseByValues(
 	conf *UpgradeReleaseConfig,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	// grab the latest release
 	rel, err := a.GetRelease(conf.Name, 0, true)
@@ -220,6 +222,7 @@ func (a *Agent) UpgradeReleaseByValues(
 		rel.Namespace,
 		conf.Registries,
 		doAuth,
+		disablePullSecretsInjection,
 	)
 
 	if err != nil {
@@ -287,7 +290,7 @@ func (a *Agent) UpgradeReleaseByValues(
 					return nil, fmt.Errorf("another operation (install/upgrade/rollback) is in progress. If this error persists, please wait for 60 seconds to force an upgrade")
 				}
 			}
-		} else if strings.Contains(err.Error(), "current release manifest contains removed kubernetes api(s)") {
+		} else if strings.Contains(err.Error(), "current release manifest contains removed kubernetes api(s)") || strings.Contains(err.Error(), "resource mapping not found for name") {
 			// ref: https://helm.sh/docs/topics/kubernetes_apis/#updating-api-versions-of-a-release-manifest
 			// in this case, we manually update the secret containing the new manifests
 			secretList, err := a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace).List(
@@ -383,6 +386,7 @@ func (a *Agent) InstallChartFromValuesBytes(
 	conf *InstallChartConfig,
 	values []byte,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues(values)
 
@@ -392,13 +396,14 @@ func (a *Agent) InstallChartFromValuesBytes(
 
 	conf.Values = valuesYaml
 
-	return a.InstallChart(conf, doAuth)
+	return a.InstallChart(conf, doAuth, disablePullSecretsInjection)
 }
 
 // InstallChart installs a new chart
 func (a *Agent) InstallChart(
 	conf *InstallChartConfig,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (*release.Release, error) {
 	cmd := action.NewInstall(a.ActionConfig)
 
@@ -423,6 +428,7 @@ func (a *Agent) InstallChart(
 		conf.Namespace,
 		conf.Registries,
 		doAuth,
+		disablePullSecretsInjection,
 	)
 
 	if err != nil {

+ 2 - 1
internal/helm/postrenderer.go

@@ -32,11 +32,12 @@ func NewPorterPostrenderer(
 	namespace string,
 	regs []*models.Registry,
 	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
 ) (postrender.PostRenderer, error) {
 	var dockerSecretsPostrenderer *DockerSecretsPostRenderer
 	var err error
 
-	if cluster != nil && agent != nil && regs != nil && len(regs) > 0 {
+	if !disablePullSecretsInjection && cluster != nil && agent != nil && regs != nil && len(regs) > 0 {
 		dockerSecretsPostrenderer, err = NewDockerSecretsPostRenderer(cluster, repo, agent, namespace, regs, doAuth)
 
 		if err != nil {

+ 12 - 3
internal/integrations/ci/actions/preview.go

@@ -17,6 +17,7 @@ type EnvOpts struct {
 	PorterToken                             string
 	GitRepoOwner, GitRepoName               string
 	EnvironmentName                         string
+	InstanceName                            string
 	ProjectID, ClusterID, GitInstallationID uint
 }
 
@@ -44,7 +45,7 @@ func SetupEnv(opts *EnvOpts) error {
 	// create porter token secret
 	err = createGithubSecret(
 		opts.Client,
-		getPorterTokenSecretName(opts.ProjectID),
+		getPreviewEnvSecretName(opts.ProjectID, opts.ClusterID, opts.InstanceName),
 		opts.PorterToken,
 		opts.GitRepoOwner,
 		opts.GitRepoName,
@@ -211,18 +212,26 @@ func DeleteEnv(opts *EnvOpts) error {
 	)
 }
 
+func getPreviewEnvSecretName(projectID, clusterID uint, instanceName string) string {
+	if instanceName != "" {
+		return fmt.Sprintf("PORTER_PREVIEW_%s_%d_%d", strings.ToUpper(instanceName), projectID, clusterID)
+	}
+
+	return fmt.Sprintf("PORTER_PREVIEW_%d_%d", projectID, clusterID)
+}
+
 func getPreviewApplyActionYAML(opts *EnvOpts) ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
 		getCreatePreviewEnvStep(
 			opts.ServerURL,
-			getPorterTokenSecretName(opts.ProjectID),
+			getPreviewEnvSecretName(opts.ProjectID, opts.ClusterID, opts.InstanceName),
 			opts.ProjectID,
 			opts.ClusterID,
 			opts.GitInstallationID,
 			opts.GitRepoOwner,
 			opts.GitRepoName,
-			"v0.2.0",
+			"v0.2.1",
 		),
 	}
 

+ 6 - 4
internal/integrations/preview/dep_resolver.go

@@ -34,10 +34,12 @@ func (r *dependencyResolver) Resolve() error {
 			r.graph[resource.Name] = append(r.graph[resource.Name], resource.DependsOn...)
 		}
 
-		err := r.depResolve(r.resources[0].Name)
+		for _, resource := range r.resources {
+			err := r.depResolve(resource.Name)
 
-		if err != nil {
-			return err
+			if err != nil {
+				return err
+			}
 		}
 	}
 
@@ -49,7 +51,7 @@ func (r *dependencyResolver) depResolve(name string) error {
 
 	for _, dep := range r.graph[name] {
 		if _, ok := r.graph[dep]; !ok {
-			return fmt.Errorf("no such resource as: '%s'", dep)
+			return fmt.Errorf("for resource '%s': invalid dependency '%s'", name, dep)
 		}
 
 		if _, ok := r.resolved[dep]; !ok {

+ 95 - 14
internal/integrations/preview/driver_validators.go

@@ -2,7 +2,9 @@ package preview
 
 import (
 	"fmt"
+	"strings"
 
+	"github.com/docker/distribution/reference"
 	"github.com/mitchellh/mapstructure"
 	"github.com/porter-dev/switchboard/pkg/types"
 	"k8s.io/apimachinery/pkg/util/validation"
@@ -40,7 +42,11 @@ func deployDriverValidator(resource *types.Resource) error {
 	}
 
 	if source.Repo == "" {
-		source.Repo = "https://charts.getporter.dev"
+		if source.Name == "web" || source.Name == "worker" || source.Name == "job" {
+			source.Repo = "https://charts.getporter.dev"
+		} else {
+			source.Repo = "https://chart-addons.getporter.dev"
+		}
 	}
 
 	if source.Repo == "https://charts.getporter.dev" {
@@ -60,12 +66,22 @@ func deployDriverValidator(resource *types.Resource) error {
 			return fmt.Errorf("for resource '%s': build method must be one of 'docker', 'pack', or 'registry'", resource.Name)
 		}
 
-		if appConfig.Build.Method == "docker" && appConfig.Build.Dockerfile == "" {
-			return fmt.Errorf("for resource '%s': dockerfile cannot be empty when using the 'docker' build method",
-				resource.Name)
-		} else if appConfig.Build.Method == "registry" && appConfig.Build.Image == "" {
-			return fmt.Errorf("for resource '%s': image cannot be empty when using the 'registry' build method",
-				resource.Name)
+		if appConfig.Build.Method == "registry" {
+			if appConfig.Build.Image == "" {
+				return fmt.Errorf("for resource '%s': image cannot be empty when using the 'registry' build method",
+					resource.Name)
+			} else if !strings.Contains(appConfig.Build.Image, "{") {
+				if len(strings.Split(appConfig.Build.Image, ":")) != 2 {
+					return fmt.Errorf("for resource '%s': image must be in the format 'image:tag'", resource.Name)
+				}
+
+				// check for valid image
+				_, err := reference.ParseNamed(appConfig.Build.Image)
+
+				if err != nil {
+					return fmt.Errorf("for resource '%s': error parsing image: %w", resource.Name, err)
+				}
+			}
 		}
 
 		for _, eg := range appConfig.EnvGroups {
@@ -104,6 +120,17 @@ func deployDriverValidator(resource *types.Resource) error {
 				}
 			}
 		}
+	} else if source.Repo == "https://chart-addons.getporter.dev" {
+		if len(resource.Config) > 0 {
+			if source.Name == "postgresql" {
+				err := validatePostgresChartValues(resource.Config)
+
+				if err != nil {
+					return fmt.Errorf("for resource '%s': error validating values for postgresql deployment: %w",
+						resource.Name, err)
+				}
+			}
+		}
 	}
 
 	return nil
@@ -147,12 +174,22 @@ func buildImageDriverValidator(resource *types.Resource) error {
 		return fmt.Errorf("for resource '%s': build method must be one of 'docker', 'pack', or 'registry'", resource.Name)
 	}
 
-	if driverConfig.Build.Method == "docker" && driverConfig.Build.Dockerfile == "" {
-		return fmt.Errorf("for resource '%s': dockerfile cannot be empty when using the 'docker' build method",
-			resource.Name)
-	} else if driverConfig.Build.Method == "registry" && driverConfig.Build.Image == "" {
-		return fmt.Errorf("for resource '%s': image cannot be empty when using the 'registry' build method",
-			resource.Name)
+	if driverConfig.Build.Method == "registry" {
+		if driverConfig.Build.Image == "" {
+			return fmt.Errorf("for resource '%s': image cannot be empty when using the 'registry' build method",
+				resource.Name)
+		} else if !strings.Contains(driverConfig.Build.Image, "{") {
+			if len(strings.Split(driverConfig.Build.Image, ":")) != 2 {
+				return fmt.Errorf("for resource '%s': image must be in the format 'image:tag'", resource.Name)
+			}
+
+			// check for valid image
+			_, err := reference.ParseNamed(driverConfig.Build.Image)
+
+			if err != nil {
+				return fmt.Errorf("for resource '%s': error parsing image: %w", resource.Name, err)
+			}
+		}
 	}
 
 	for _, eg := range driverConfig.EnvGroups {
@@ -202,13 +239,24 @@ func pushImageDriverValidator(resource *types.Resource) error {
 
 	if driverConfig.Push.Image == "" {
 		return fmt.Errorf("for resource '%s': image cannot be empty", resource.Name)
+	} else if !strings.Contains(driverConfig.Push.Image, "{") {
+		if len(strings.Split(driverConfig.Push.Image, ":")) != 2 {
+			return fmt.Errorf("for resource '%s': image must be in the format 'image:tag'", resource.Name)
+		}
+
+		// check for valid image
+		_, err := reference.ParseNamed(driverConfig.Push.Image)
+
+		if err != nil {
+			return fmt.Errorf("for resource '%s': error parsing image: %w", resource.Name, err)
+		}
 	}
 
 	return nil
 }
 
 func updateConfigDriverValidator(resource *types.Resource) error {
-	_, target, err := commonValidator(resource)
+	source, target, err := commonValidator(resource)
 
 	if err != nil {
 		return err
@@ -229,6 +277,14 @@ func updateConfigDriverValidator(resource *types.Resource) error {
 		}
 	}
 
+	if source.Repo == "" {
+		if source.Name == "web" || source.Name == "worker" || source.Name == "job" {
+			source.Repo = "https://charts.getporter.dev"
+		} else {
+			source.Repo = "https://chart-addons.getporter.dev"
+		}
+	}
+
 	driverConfig := &UpdateConfigDriverConfig{}
 
 	err = mapstructure.Decode(resource.Config, driverConfig)
@@ -253,6 +309,31 @@ func updateConfigDriverValidator(resource *types.Resource) error {
 		}
 	}
 
+	if len(driverConfig.Values) > 0 && source.Repo == "https://charts.getporter.dev" {
+		if source.Name == "web" {
+			err := validateWebChartValues(driverConfig.Values)
+
+			if err != nil {
+				return fmt.Errorf("for resource '%s': error validating values for web deployment: %w",
+					resource.Name, err)
+			}
+		} else if source.Name == "worker" {
+			err := validateWorkerChartValues(driverConfig.Values)
+
+			if err != nil {
+				return fmt.Errorf("for resource '%s': error validating values for worker deployment: %w",
+					resource.Name, err)
+			}
+		} else if source.Name == "job" {
+			err := validateJobChartValues(driverConfig.Values)
+
+			if err != nil {
+				return fmt.Errorf("for resource '%s': error validating values for job deployment: %w",
+					resource.Name, err)
+			}
+		}
+	}
+
 	return nil
 }
 

+ 31 - 18
internal/integrations/preview/schema_validate.go

@@ -5,19 +5,16 @@ import (
 	"fmt"
 
 	"github.com/santhosh-tekuri/jsonschema/v5"
+	_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
 )
 
 func validateWebChartValues(values map[string]interface{}) error {
-	webValuesSchema, err := schemas.ReadFile("embed/web.values.schema.json")
+	compiler := jsonschema.NewCompiler()
 
-	if err != nil {
-		return fmt.Errorf("error reading web chart values schema: %w", err)
-	}
-
-	scm, err := jsonschema.CompileString("web.values.schema.json", string(webValuesSchema))
+	scm, err := compiler.Compile("https://raw.githubusercontent.com/porter-dev/porter-charts/master/applications/web/validate.json")
 
 	if err != nil {
-		return fmt.Errorf("error compiling web chart values schema: %w", err)
+		return fmt.Errorf("error compiling job chart values schema: %w", err)
 	}
 
 	jsonBytes, err := json.Marshal(values)
@@ -36,16 +33,12 @@ func validateWebChartValues(values map[string]interface{}) error {
 }
 
 func validateWorkerChartValues(values map[string]interface{}) error {
-	workerValuesSchema, err := schemas.ReadFile("embed/worker.values.schema.json")
-
-	if err != nil {
-		return fmt.Errorf("error reading worker chart values schema: %w", err)
-	}
+	compiler := jsonschema.NewCompiler()
 
-	scm, err := jsonschema.CompileString("worker.values.schema.json", string(workerValuesSchema))
+	scm, err := compiler.Compile("https://raw.githubusercontent.com/porter-dev/porter-charts/master/applications/worker/validate.json")
 
 	if err != nil {
-		return fmt.Errorf("error compiling worker chart values schema: %w", err)
+		return fmt.Errorf("error compiling job chart values schema: %w", err)
 	}
 
 	jsonBytes, err := json.Marshal(values)
@@ -64,16 +57,36 @@ func validateWorkerChartValues(values map[string]interface{}) error {
 }
 
 func validateJobChartValues(values map[string]interface{}) error {
-	jobValuesSchema, err := schemas.ReadFile("embed/job.values.schema.json")
+	compiler := jsonschema.NewCompiler()
+
+	scm, err := compiler.Compile("https://raw.githubusercontent.com/porter-dev/porter-charts/master/applications/job/validate.json")
 
 	if err != nil {
-		return fmt.Errorf("error reading job chart values schema: %w", err)
+		return fmt.Errorf("error compiling job chart values schema: %w", err)
 	}
 
-	scm, err := jsonschema.CompileString("job.values.schema.json", string(jobValuesSchema))
+	jsonBytes, err := json.Marshal(values)
 
 	if err != nil {
-		return fmt.Errorf("error compiling job chart values schema: %w", err)
+		return fmt.Errorf("error marshalling values to JSON: %w", err)
+	}
+
+	var v interface{}
+
+	if err := json.Unmarshal(jsonBytes, &v); err != nil {
+		return fmt.Errorf("error unmarshalling values JSON to interface: %w", err)
+	}
+
+	return scm.Validate(v)
+}
+
+func validatePostgresChartValues(values map[string]interface{}) error {
+	compiler := jsonschema.NewCompiler()
+
+	scm, err := compiler.Compile("https://raw.githubusercontent.com/porter-dev/porter-charts/master/addons/postgresql/values.schema.json")
+
+	if err != nil {
+		return fmt.Errorf("error compiling postgres chart values schema: %w", err)
 	}
 
 	jsonBytes, err := json.Marshal(values)

+ 1 - 1
internal/integrations/preview/utils.go

@@ -11,7 +11,7 @@ type Source struct {
 }
 
 type Target struct {
-	AppName   string
+	AppName   string `mapstructure:"app_name"`
 	Project   uint
 	Cluster   uint
 	Namespace string

+ 0 - 4
internal/integrations/preview/validate.go

@@ -1,7 +1,6 @@
 package preview
 
 import (
-	"embed"
 	"errors"
 	"fmt"
 
@@ -10,9 +9,6 @@ import (
 	"k8s.io/apimachinery/pkg/util/validation"
 )
 
-//go:embed embed/*.schema.json
-var schemas embed.FS
-
 var (
 	ErrNoPorterYAMLFile    = errors.New("porter.yaml does not exist in the root of this repository")
 	ErrEmptyPorterYAMLFile = errors.New("porter.yaml is empty")

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

@@ -336,6 +336,7 @@ func GetPodValues(
 
 	vals["match_prefix"] = req.MatchPrefix
 	vals["revision"] = req.Revision
+	vals["namespace"] = req.Namespace
 
 	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
 		"http",

+ 5 - 0
internal/kubernetes/prometheus/metrics.go

@@ -211,6 +211,11 @@ func QueryPrometheus(
 	rawQuery, err := resp.DoRaw(context.TODO())
 
 	if err != nil {
+		// in this case, it's very likely that prometheus doesn't contain any data for the given labels
+		if strings.Contains(err.Error(), "rejected our request for an unknown reason") {
+			return []*promParsedSingletonQuery{}, nil
+		}
+
 		return nil, err
 	}
 

+ 8 - 1
internal/notifier/sendgrid/incident_notifier.go

@@ -2,6 +2,7 @@ package sendgrid
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -31,10 +32,16 @@ func (s *IncidentNotifier) NotifyNew(incident *types.Incident, url string) error
 
 	personalizations := make([]*mail.Personalization, 0)
 
+	resourceKind := "application"
+
+	if strings.ToLower(string(incident.InvolvedObjectKind)) == "job" {
+		resourceKind = "job"
+	}
+
 	templData := map[string]interface{}{
 		"incident_text": incident.Summary,
 		"app_url":       url,
-		"subject":       fmt.Sprintf("Your application %s crashed on Porter", incident.ReleaseName),
+		"subject":       fmt.Sprintf("Your %s %s crashed on Porter", resourceKind, incident.ReleaseName),
 		"preheader":     incident.Summary,
 		"created_at":    fmt.Sprintf("%s", incident.CreatedAt.Format("Jan 2, 2006 at 3:04pm (MST)")),
 	}

+ 9 - 1
internal/notifier/slack/incident_notifier.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/porter-dev/porter/api/types"
@@ -24,8 +25,15 @@ func NewIncidentNotifier(slackInts ...*integrations.SlackIntegration) *IncidentN
 func (s *IncidentNotifier) NotifyNew(incident *types.Incident, url string) error {
 	res := []*SlackBlock{}
 
+	resourceKind := "application"
+
+	if strings.ToLower(string(incident.InvolvedObjectKind)) == "job" {
+		resourceKind = "job"
+	}
+
 	topSectionMarkdwn := fmt.Sprintf(
-		":warning: Your application %s crashed on Porter. <%s|View the incident.>",
+		":warning: Your %s %s crashed on Porter. <%s|View the incident.>",
+		resourceKind,
 		"`"+incident.ReleaseName+"`",
 		url,
 	)

+ 13 - 5
internal/opa/config.yaml

@@ -97,18 +97,26 @@ porter_agent_pod:
   policies:
   - path: "./policies/pod/running.rego"
     name: "pod.running"
-porter_agent_redis_pod:
+porter_agent_loki_pod:
   kind: "pod"
   match:
     namespace: porter-agent-system
     labels:
-      app.kubernetes.io/component: "master"
-      app.kubernetes.io/instance: "porter-agent"
-      app.kubernetes.io/managed-by: "Helm"
-      app.kubernetes.io/name: "redis"
+      app: "loki"
+      name: "porter-agent-loki"
   policies:
   - path: "./policies/pod/running.rego"
     name: "pod.running"
+porter_agent_promtail_daemonset:
+  kind: "daemonset"
+  match:
+    namespace: porter-agent-system
+    labels:
+      app.kubernetes.io/instance: "porter-agent"
+      app.kubernetes.io/name: "promtail"
+  policies:
+  - path: "./policies/daemonset/running.rego"
+    name: "daemonset.running"
 certificates:
   kind: "crd_list"
   match:

+ 93 - 25
internal/opa/opa.go

@@ -36,6 +36,7 @@ const (
 	HelmRelease KubernetesBuiltInKind = "helm_release"
 	Pod         KubernetesBuiltInKind = "pod"
 	CRDList     KubernetesBuiltInKind = "crd_list"
+	Daemonset   KubernetesBuiltInKind = "daemonset"
 )
 
 type KubernetesOPAQueryCollection struct {
@@ -98,35 +99,44 @@ func (runner *KubernetesOPARunner) GetRecommendations(categories []string) ([]*O
 
 	res := make([]*OPARecommenderQueryResult, 0)
 
-	for _, name := range collectionNames {
-		// look up to determine if the name is registered
-		queryCollection, exists := runner.Policies[name]
+	// ping the cluster with a version check to make sure it's reachable - if not, return an error
+	_, err := runner.k8sAgent.Clientset.Discovery().ServerVersion()
 
-		if !exists {
-			return nil, fmt.Errorf("No policies for %s found", name)
-		}
+	if err != nil {
+		fmt.Printf("discovery check failed: %v\n", err.Error())
+	} else {
+		for _, name := range collectionNames {
+			// look up to determine if the name is registered
+			queryCollection, exists := runner.Policies[name]
 
-		var currResults []*OPARecommenderQueryResult
-		var err error
-
-		switch queryCollection.Kind {
-		case HelmRelease:
-			currResults, err = runner.runHelmReleaseQueries(name, queryCollection)
-		case Pod:
-			currResults, err = runner.runPodQueries(name, queryCollection)
-		case CRDList:
-			currResults, err = runner.runCRDListQueries(name, queryCollection)
-		default:
-			fmt.Printf("%s is not a supported query kind", queryCollection.Kind)
-			continue
-		}
+			if !exists {
+				return nil, fmt.Errorf("No policies for %s found", name)
+			}
 
-		if err != nil {
-			fmt.Printf("%s", err.Error())
-			continue
-		}
+			var currResults []*OPARecommenderQueryResult
+			var err error
+
+			switch queryCollection.Kind {
+			case HelmRelease:
+				currResults, err = runner.runHelmReleaseQueries(name, queryCollection)
+			case Pod:
+				currResults, err = runner.runPodQueries(name, queryCollection)
+			case CRDList:
+				currResults, err = runner.runCRDListQueries(name, queryCollection)
+			case Daemonset:
+				currResults, err = runner.runDaemonsetQueries(name, queryCollection)
+			default:
+				fmt.Printf("%s is not a supported query kind", queryCollection.Kind)
+				continue
+			}
 
-		res = append(res, currResults...)
+			if err != nil {
+				fmt.Printf("%s", err.Error())
+				continue
+			}
+
+			res = append(res, currResults...)
+		}
 	}
 
 	return res, nil
@@ -308,6 +318,64 @@ func (runner *KubernetesOPARunner) runPodQueries(name string, collection Kuberne
 	return res, nil
 }
 
+func (runner *KubernetesOPARunner) runDaemonsetQueries(name string, collection KubernetesOPAQueryCollection) ([]*OPARecommenderQueryResult, error) {
+	res := make([]*OPARecommenderQueryResult, 0)
+
+	lselArr := make([]string, 0)
+
+	for k, v := range collection.Match.Labels {
+		lselArr = append(lselArr, fmt.Sprintf("%s=%s", k, v))
+	}
+
+	lsel := strings.Join(lselArr, ",")
+
+	daemonsets, err := runner.k8sAgent.Clientset.AppsV1().DaemonSets(collection.Match.Namespace).List(context.Background(), v1.ListOptions{
+		LabelSelector: lsel,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, ds := range daemonsets.Items {
+		unstructuredDS, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&ds)
+
+		if err != nil {
+			return nil, err
+		}
+
+		for _, query := range collection.Queries {
+			results, err := query.Eval(
+				context.Background(),
+				rego.EvalInput(unstructuredDS),
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			if len(results) == 1 {
+				rawQueryRes := &rawQueryResult{}
+
+				err = mapstructure.Decode(results[0].Expressions[0].Value, rawQueryRes)
+
+				if err != nil {
+					return nil, err
+				}
+
+				res = append(res, rawQueryResToRecommenderQueryResult(
+					rawQueryRes,
+					fmt.Sprintf("daemonset/%s/%s", ds.Namespace, ds.Name),
+					name,
+					collection,
+				))
+			}
+		}
+	}
+
+	return res, nil
+}
+
 func (runner *KubernetesOPARunner) runCRDListQueries(name string, collection KubernetesOPAQueryCollection) ([]*OPARecommenderQueryResult, error) {
 	res := make([]*OPARecommenderQueryResult, 0)
 

+ 25 - 0
internal/opa/policies/daemonset/running.rego

@@ -0,0 +1,25 @@
+package daemonset.running
+
+import future.keywords.contains
+import future.keywords.every
+import future.keywords.if
+import future.keywords.in
+
+POLICY_ID := "daemonset_running"
+
+POLICY_VERSION := "v0.0.1"
+
+POLICY_SEVERITY := "high"
+
+POLICY_TITLE := sprintf("Daemonset %s in namespace %s should have all replicas available", [input.metadata.name, input.metadata.namespace])
+
+POLICY_SUCCESS_MESSAGE := sprintf("Success: daemonset has %d / %d pods running", [input.status.numberReady, input.status.desiredNumberScheduled])
+
+allow if {
+	input.status.numberReady == input.status.desiredNumberScheduled
+}
+
+FAILURE_MESSAGE contains msg1 if {
+	input.status.numberReady != input.status.desiredNumberScheduled
+	msg1 := sprintf("Daemonset %s only has %d out of %d pods running", [input.metadata.name, input.status.numberReady, input.status.desiredNumberScheduled])
+}

+ 2 - 2
internal/templater/helm/values/writer.go

@@ -42,7 +42,7 @@ func (w *TemplateWriter) Create(
 		Values:    vals,
 	}
 
-	_, err := w.Agent.InstallChart(conf, nil)
+	_, err := w.Agent.InstallChart(conf, nil, false)
 
 	if err != nil {
 		return nil, err
@@ -64,7 +64,7 @@ func (w *TemplateWriter) Update(
 		Values: vals,
 	}
 
-	_, err := w.Agent.UpgradeReleaseByValues(conf, nil)
+	_, err := w.Agent.UpgradeReleaseByValues(conf, nil, false)
 
 	if err != nil {
 		return nil, err

+ 3 - 2
provisioner/client/client.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"os"
 	"strings"
 	"time"
 
@@ -155,9 +156,9 @@ func (c *Client) postRequest(relPath string, data interface{}, response interfac
 
 		if i != int(retryCount)-1 {
 			if httpErr != nil {
-				fmt.Printf("Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
+				fmt.Fprintf(os.Stderr, "Error: %s (status code %d), retrying request...\n", httpErr.Error, httpErr.Code)
 			} else {
-				fmt.Printf("Error: %v, retrying request...\n", err)
+				fmt.Fprintf(os.Stderr, "Error: %v, retrying request...\n", err)
 			}
 		}
 	}

+ 75 - 0
scripts/dev-environment/CheckPreviewEnvLocal.sh

@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+
+env_file_path="docker/.env"
+ngrok_url=""
+
+command -v ngrok >/dev/null 2>&1 || { echo "[ERROR] ngrok is required to test Preview Environments locally" >&2; exit 1; }
+
+if [ -f "$env_file_path" ]; then
+    env_vars="$(cat "$env_file_path" | grep -v "^#" | sed -r "/^\s*$/d" | sed "s/\#.*//")"
+    IFS="="
+    serverURLSet=0
+    githubAppClientIDSet=0
+    githubAppClientSecretSet=0
+    githubAppWebhookSecretSet=0
+    githubAppNameSet=0
+    githubAppIDSet=0
+    githubAppSecretPathSet=0
+    githubIncomingWebhookSecretSet=0
+    while read -r k v; do
+        if [[ "$k" == "SERVER_URL" ]]; then
+            if [[ "$v" != *"ngrok.io"* ]]; then
+                echo "[ERROR] SERVER_URL must be set to an ngrok.io URL."
+                exit 1
+            fi
+
+            serverURLSet=1
+            ngrok_url="$v"
+        elif [[ "$k" == "GITHUB_APP_CLIENT_ID" ]]; then
+            githubAppClientIDSet=1
+        elif [[ "$k" == "GITHUB_APP_CLIENT_SECRET" ]]; then
+            githubAppClientSecretSet=1
+        elif [[ "$k" == "GITHUB_APP_WEBHOOK_SECRET" ]]; then
+            githubAppWebhookSecretSet=1
+        elif [[ "$k" == "GITHUB_APP_NAME" ]]; then
+            githubAppNameSet=1
+        elif [[ "$k" == "GITHUB_APP_ID" ]]; then
+            githubAppIDSet=1
+        elif [[ "$k" == "GITHUB_APP_SECRET_PATH" ]]; then
+            githubAppSecretPathSet=1
+        elif [[ "$k" == "GITHUB_INCOMING_WEBHOOK_SECRET" ]]; then
+            githubIncomingWebhookSecretSet=1
+        fi
+    done <<< "$env_vars"
+
+    if [[ "$serverURLSet" == "0" ]]; then
+        echo "[ERROR] SERVER_URL must be set to an ngrok.io URL."
+        exit 1
+    elif [[ "$githubAppClientIDSet" == 0 ]]; then
+        echo "[ERROR] GITHUB_APP_CLIENT_ID must be set"
+        exit 1
+    elif [[ "$githubAppClientSecretSet" == 0 ]]; then
+        echo "[ERROR] GITHUB_APP_CLIENT_SECRET must be set"
+        exit 1
+    elif [[ "$githubAppWebhookSecretSet" == 0 ]]; then
+        echo "[ERROR] GITHUB_APP_WEBHOOK_SECRET must be set"
+        exit 1
+    elif [[ "$githubAppNameSet" == 0 ]]; then
+        echo "[ERROR] GITHUB_APP_NAME must be set"
+        exit 1
+    elif [[ "$githubAppIDSet" == 0 ]]; then
+        echo "[ERROR] GITHUB_APP_ID must be set"
+        exit 1
+    elif [[ "$githubAppSecretPathSet" == 0 ]]; then
+        echo "[ERROR] GITHUB_APP_SECRET_PATH must be set"
+        exit 1
+    elif [[ "$githubIncomingWebhookSecretSet" == 0 ]]; then
+        echo "[ERROR] GITHUB_INCOMING_WEBHOOK_SECRET must be set"
+        exit 1
+    fi
+else
+    echo "[ERROR] docker/.env should be set with the required variables"
+    exit 1
+fi
+
+echo "[SUCCESS] All required variables are set. MAKE SURE your GitHub app has all URLs set to $ngrok_url."

+ 1 - 0
workers/jobs/recommender.go

@@ -204,6 +204,7 @@ func (n *recommender) Run() error {
 			Repo:                      n.repo,
 			DigitalOceanOAuth:         n.doConf,
 			AllowInClusterConnections: false,
+			Timeout:                   5 * time.Second,
 		})
 
 		if err != nil {