Przeglądaj źródła

Merge branch 'master' into nafees/preview-env-improvements

Mohammed Nafees 4 lat temu
rodzic
commit
39c649f540

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

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

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

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

+ 17 - 2
api/server/handlers/kube_events/create.go

@@ -90,7 +90,9 @@ func (c *CreateKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	w.WriteHeader(http.StatusCreated)
 
-	if strings.ToLower(string(request.EventType)) == "critical" && strings.ToLower(request.ResourceType) == "pod" {
+	if strings.ToLower(string(request.EventType)) == "critical" &&
+		strings.ToLower(request.ResourceType) == "pod" &&
+		request.Message != "Unable to determine the root cause of the error" {
 		agent, err := c.GetAgent(r, cluster, request.Namespace)
 
 		if err != nil {
@@ -106,6 +108,19 @@ func (c *CreateKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+func mapKubeEventToMessage(event *types.CreateKubeEventRequest) string {
+	if strings.HasSuffix(event.Reason, "RunContainerError") {
+		if strings.Contains(event.Message, "exec:") {
+			return fmt.Sprintf("Application launch error: %s\n",
+				strings.Split(strings.SplitAfter(event.Message, "exec: ")[1], ": unknown")[0])
+		}
+	} else if strings.HasSuffix(event.Reason, "ImagePullBackOff") {
+		return "Deployment error: The application image could not be pulled from the registry"
+	}
+
+	return event.Message
+}
+
 func notifyPodCrashing(
 	config *config.Config,
 	agent *kubernetes.Agent,
@@ -236,7 +251,7 @@ func notifyPodCrashing(
 			ClusterName: cluster.Name,
 			Name:        event.OwnerName,
 			Namespace:   event.Namespace,
-			Info:        fmt.Sprintf("%s:%s", event.Reason, event.Message),
+			Info:        mapKubeEventToMessage(event),
 			URL: fmt.Sprintf(
 				"%s/applications/%s/%s/%s?project_id=%d",
 				config.ServerConf.ServerURL,

+ 68 - 0
api/server/handlers/namespace/get_previous_logs.go

@@ -0,0 +1,68 @@
+package namespace
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPreviousLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPreviousLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetPreviousLogsHandler {
+	return &GetPreviousLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPreviousLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetPreviousPodLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPodName)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	logs, err := agent.GetPreviousPodLogs(namespace, name, request.Container)
+
+	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
+		http.NotFound(w, r)
+		return
+	}
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.GetPreviousPodLogsResponse = types.GetPreviousPodLogsResponse{
+		PrevLogs: logs,
+	}
+
+	c.WriteResult(w, r, res)
+}

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

@@ -53,7 +53,7 @@ func (c *StreamPodLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	err = agent.GetPodLogs(namespace, name, request.Previous, request.Container, safeRW)
+	err = agent.GetPodLogs(namespace, name, request.Container, safeRW)
 
 	if targetErr := kubernetes.IsNotFoundError; errors.Is(err, targetErr) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 9 - 0
api/server/handlers/project/get_usage.go

@@ -28,6 +28,15 @@ func NewProjectGetUsageHandler(
 func (p *ProjectGetUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
+	if !p.Config().ServerConf.UsageTrackingEnabled {
+		p.WriteResult(w, r, &types.GetProjectUsageResponse{
+			Limit:      types.EnterprisePlan,
+			IsExceeded: false,
+		})
+
+		return
+	}
+
 	res := &types.GetProjectUsageResponse{}
 
 	currUsage, limit, usageCache, err := usage.GetUsage(&usage.GetUsageOpts{

+ 34 - 0
api/server/router/namespace.go

@@ -328,6 +328,40 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pod/{name}/previous_logs
+	getPreviousLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/pod/{%s}/previous_logs",
+					relPath,
+					types.URLParamPodName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getPreviousLogsHandler := namespace.NewGetPreviousLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getPreviousLogsEndpoint,
+		Handler:  getPreviousLogsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/jobs/{name}/pods -> jobs.NewGetPodsHandler
 	getJobPodsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

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

@@ -240,7 +240,7 @@ func registerRoutes(config *config.Config, routes []*Route) {
 			atomicGroup.Use(websocketMw.Middleware)
 		}
 
-		if route.Endpoint.Metadata.CheckUsage {
+		if route.Endpoint.Metadata.CheckUsage && config.ServerConf.UsageTrackingEnabled {
 			usageMW := middleware.NewUsageMiddleware(config, route.Endpoint.Metadata.UsageMetric)
 
 			atomicGroup.Use(usageMW.Middleware)

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

@@ -14,6 +14,8 @@ type ServerConf struct {
 	// this to `PORTER_TOKEN_<INSTANCE_NAME>_<PROJECT_ID>`
 	InstanceName string `env:"INSTANCE_NAME"`
 
+	UsageTrackingEnabled bool `env:"USAGE_TRACKING_ENABLED,default=false"`
+
 	Port                 int           `env:"SERVER_PORT,default=8080"`
 	StaticFilePath       string        `env:"STATIC_FILE_PATH,default=/porter/static"`
 	CookieName           string        `env:"COOKIE_NAME,default=porter"`

+ 8 - 1
api/types/namespace.go

@@ -114,5 +114,12 @@ type DeleteConfigMapRequest struct {
 
 type GetPodLogsRequest struct {
 	Container string `schema:"container_name"`
-	Previous  bool   `schema:"previous"`
+}
+
+type GetPreviousPodLogsRequest struct {
+	Container string `schema:"container_name"`
+}
+
+type GetPreviousPodLogsResponse struct {
+	PrevLogs []string `json:"previous_logs"`
 }

+ 40 - 24
cli/cmd/apply.go

@@ -289,6 +289,19 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 		tag = commit.Sha[:7]
 	}
 
+	// if the method is registry and a tag is defined, we use the provided tag
+	if appConfig.Build.Method == "registry" {
+		imageSpl := strings.Split(appConfig.Build.Image, ":")
+
+		if len(imageSpl) == 2 {
+			tag = imageSpl[1]
+		}
+
+		if tag == "" {
+			tag = "latest"
+		}
+	}
+
 	sharedOpts := &deploy.SharedOpts{
 		ProjectID:       d.target.Project,
 		ClusterID:       d.target.Cluster,
@@ -414,40 +427,43 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 		return nil, err
 	}
 
-	buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
-		UseNewConfig: true,
-		NewConfig:    appConf.Values,
-	})
+	// if the build method is registry, we do not trigger a build
+	if appConf.Build.Method != "registry" {
+		buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
+			UseNewConfig: true,
+			NewConfig:    appConf.Values,
+		})
 
-	if err != nil {
-		return nil, err
-	}
+		if err != nil {
+			return nil, err
+		}
 
-	err = updateAgent.SetBuildEnv(buildEnv)
+		err = updateAgent.SetBuildEnv(buildEnv)
 
-	if err != nil {
-		return nil, err
-	}
+		if err != nil {
+			return nil, err
+		}
 
-	var buildConfig *types.BuildConfig
+		var buildConfig *types.BuildConfig
 
-	if appConf.Build.Builder != "" {
-		buildConfig = &types.BuildConfig{
-			Builder:    appConf.Build.Builder,
-			Buildpacks: appConf.Build.Buildpacks,
+		if appConf.Build.Builder != "" {
+			buildConfig = &types.BuildConfig{
+				Builder:    appConf.Build.Builder,
+				Buildpacks: appConf.Build.Buildpacks,
+			}
 		}
-	}
 
-	err = updateAgent.Build(buildConfig)
+		err = updateAgent.Build(buildConfig)
 
-	if err != nil {
-		return nil, err
-	}
+		if err != nil {
+			return nil, err
+		}
 
-	err = updateAgent.Push()
+		err = updateAgent.Push()
 
-	if err != nil {
-		return nil, err
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	err = updateAgent.UpdateImageAndValues(appConf.Values)

+ 33 - 0
cli/cmd/root.go

@@ -1,9 +1,16 @@
 package cmd
 
 import (
+	"context"
+	"fmt"
 	"os"
+	"runtime"
+	"strings"
+	"time"
 
+	"github.com/Masterminds/semver/v3"
 	"github.com/fatih/color"
+	"github.com/google/go-github/v41/github"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/spf13/cobra"
 	"k8s.io/client-go/util/homedir"
@@ -25,6 +32,32 @@ func Execute() {
 
 	rootCmd.PersistentFlags().AddFlagSet(defaultFlagSet)
 
+	if Version != "dev" {
+		ghClient := github.NewClient(nil)
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		defer cancel()
+		release, _, err := ghClient.Repositories.GetLatestRelease(ctx, "porter-dev", "porter")
+		if err == nil {
+			release.GetURL()
+			// we do not care for an error here because we do not want to block the user here
+			constraint, err := semver.NewConstraint(fmt.Sprintf("> %s", strings.TrimPrefix(Version, "v")))
+			if err == nil {
+				latestRelease, err := semver.NewVersion(strings.TrimPrefix(release.GetTagName(), "v"))
+				if err == nil {
+					if constraint.Check(latestRelease) {
+						color.New(color.FgYellow).Fprint(os.Stderr, "A new version of the porter CLI is available. Run the following to update: ")
+						if runtime.GOOS == "darwin" {
+							color.New(color.FgYellow, color.Bold).Fprintln(os.Stderr, "brew install porter-dev/porter/porter")
+						} else {
+							color.New(color.FgYellow, color.Bold).Fprintln(os.Stderr, "/bin/bash -c \"$(curl -fsSL https://install.porter.run)\"")
+						}
+						color.New(color.FgYellow).Fprintf(os.Stderr, "View CLI installation and upgrade docs at https://docs.porter.run/cli/installation\n\n")
+					}
+				}
+			}
+		}
+	}
+
 	if err := rootCmd.Execute(); err != nil {
 		color.New(color.FgRed).Println(err)
 		os.Exit(1)

+ 1 - 1
cli/cmd/version.go

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

+ 25 - 23
dashboard/src/main/home/Home.tsx

@@ -27,7 +27,6 @@ import discordLogo from "../../assets/discord.svg";
 import Onboarding from "./onboarding/Onboarding";
 import ModalHandler from "./ModalHandler";
 import { NewProjectFC } from "./new-project/NewProject";
-import { BuildpackSelection } from "components/repo-selector/ActionDetails";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -253,28 +252,31 @@ class Home extends Component<PropsType, StateType> {
 
     if (prevProps.currentProject?.id !== this.props.currentProject?.id) {
       this.checkOnboarding();
-      this.checkIfProjectHasBilling(this?.context?.currentProject?.id)
-        .then((isBillingEnabled) => {
-          if (isBillingEnabled) {
-            api
-              .getUsage(
-                "<token>",
-                {},
-                { project_id: this.context?.currentProject?.id }
-              )
-              .then((res) => {
-                const usage = res.data;
-                this.context.setUsage(usage);
-                if (usage.exceeded) {
-                  this.context.setCurrentModal("UsageWarningModal", {
-                    usage,
-                  });
-                }
-              })
-              .catch(console.log);
-          }
-        })
-        .catch(console.log);
+
+      if (!process.env.DISABLE_BILLING) {
+        this.checkIfProjectHasBilling(this?.context?.currentProject?.id)
+          .then((isBillingEnabled) => {
+            if (isBillingEnabled) {
+              api
+                .getUsage(
+                  "<token>",
+                  {},
+                  { project_id: this.context?.currentProject?.id }
+                )
+                .then((res) => {
+                  const usage = res.data;
+                  this.context.setUsage(usage);
+                  if (usage.exceeded) {
+                    this.context.setCurrentModal("UsageWarningModal", {
+                      usage,
+                    });
+                  }
+                })
+                .catch(console.log);
+            }
+          })
+          .catch(console.log);
+      }
     }
 
     if (

+ 331 - 328
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -1,82 +1,69 @@
-import React, { Component, useEffect, useRef, useState } from "react";
+import React, {
+  Component,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
 import * as Anser from "anser";
 import api from "shared/api";
-import { useWebsockets } from "shared/hooks/useWebsockets";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
 
 const MAX_LOGS = 1000;
 
-type PropsType = {
-  selectedPod: any;
-  podError: string;
-  rawText?: boolean;
-};
-
-type StateType = {
-  logs: [number, Anser.AnserJsonEntry[]][];
-  numLogs: number;
-  ws: any;
-  scroll: boolean;
-  currentTab: string;
-  getPreviousLogs: boolean;
+type SelectedPodType = {
+  spec: {
+    [key: string]: any;
+    containers: {
+      [key: string]: any;
+      name: string;
+    }[];
+  };
+  metadata: {
+    name: string;
+    namespace: string;
+  };
+  status: {
+    phase: string;
+  };
 };
 
-export default class Logs extends Component<PropsType, StateType> {
-  private numLogs: React.RefObject<number>;
-
-  state = {
-    logs: [] as [number, Anser.AnserJsonEntry[]][],
-    numLogs: 0,
-    ws: null as any,
-    scroll: true,
-    currentTab: "Application",
-    getPreviousLogs: false,
-  };
+const LogsFC: React.FC<{
+  selectedPod: SelectedPodType;
+  podError: string;
+  rawText?: boolean;
+}> = ({ selectedPod, podError, rawText }) => {
+  const {
+    logs,
+    previousLogs,
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    refresh,
+  } = useLogs(selectedPod);
 
-  ws = null as any;
-  parentRef = React.createRef<HTMLDivElement>();
+  const [showPreviousLogs, setShowPreviousLogs] = useState<boolean>(false);
 
-  getPodStatus = (status: any) => {
-    if (
-      status?.phase === "Pending" &&
-      status?.containerStatuses !== undefined
-    ) {
-      return status.containerStatuses[0].state.waiting.reason;
-    } else if (status?.phase === "Pending") {
-      return "Pending";
-    }
+  const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(true);
 
-    if (status?.phase === "Failed") {
-      return "failed";
-    }
+  const wrapperRef = useRef<HTMLDivElement>();
 
-    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 scrollToBottom = (smooth: boolean) => {
+    if (!wrapperRef.current) {
+      return;
     }
-  };
 
-  scrollToBottom = (smooth: boolean) => {
     if (smooth) {
-      this.parentRef.current.lastElementChild.scrollIntoView({
+      wrapperRef.current.lastElementChild.scrollIntoView({
         behavior: "smooth",
         block: "nearest",
         inline: "start",
       });
     } else {
-      this.parentRef.current.lastElementChild.scrollIntoView({
+      wrapperRef.current.lastElementChild.scrollIntoView({
         behavior: "auto",
         block: "nearest",
         inline: "start",
@@ -84,18 +71,22 @@ export default class Logs extends Component<PropsType, StateType> {
     }
   };
 
-  renderLogs = () => {
-    let { selectedPod, podError } = this.props;
+  useEffect(() => {
+    if (isScrollToBottomEnabled) {
+      scrollToBottom(true);
+    }
+  }, [isScrollToBottomEnabled, logs]);
 
+  const renderLogs = () => {
     if (podError && podError != "") {
-      return <Message>{this.props.podError}</Message>;
+      return <Message>{podError}</Message>;
     }
 
     if (!selectedPod?.metadata?.name) {
       return <Message>Please select a pod to view its logs.</Message>;
     }
 
-    if (selectedPod?.status.phase === "Succeeded" && !this.props.rawText) {
+    if (selectedPod?.status.phase === "Succeeded" && !rawText) {
       return (
         <Message>
           ⌛ This job has been completed. You can now delete this job.
@@ -104,31 +95,34 @@ export default class Logs extends Component<PropsType, StateType> {
     }
 
     if (
-      this.getPodStatus(selectedPod.status) === "failed" &&
-      this.state.logs.length === 0
+      showPreviousLogs &&
+      Array.isArray(previousLogs) &&
+      previousLogs.length
     ) {
-      return (
-        <Message>
-          No logs to display from this pod.
-          <Highlight
-            onClick={() => {
-              this.setState({ getPreviousLogs: true }, () => {
-                this.refreshLogs();
-              });
-            }}
-          >
-            <i className="material-icons">autorenew</i>
-            Get logs from crashed pod
-          </Highlight>
-        </Message>
-      );
+      return previousLogs?.map((log, i) => {
+        return (
+          <Log key={i}>
+            {log.map((ansi, j) => {
+              if (ansi.clearLine) {
+                return null;
+              }
+
+              return (
+                <LogSpan key={i + "." + j} ansi={ansi}>
+                  {ansi.content.replace(/ /g, "\u00a0")}
+                </LogSpan>
+              );
+            })}
+          </Log>
+        );
+      });
     }
 
-    if (this.state.logs.length == 0) {
+    if (!Array.isArray(logs) || logs?.length === 0) {
       return (
         <Message>
           No logs to display from this pod.
-          <Highlight onClick={this.refreshLogs}>
+          <Highlight onClick={refresh}>
             <i className="material-icons">autorenew</i>
             Refresh
           </Highlight>
@@ -136,17 +130,16 @@ export default class Logs extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.logs.map((log, i) => {
-      const key = log[0];
+    return logs?.map((log, i) => {
       return (
-        <Log key={key}>
-          {this.state.logs[i][1].map((ansi, j) => {
+        <Log key={i}>
+          {log.map((ansi, j) => {
             if (ansi.clearLine) {
               return null;
             }
 
             return (
-              <LogSpan key={key + "." + j} ansi={ansi}>
+              <LogSpan key={i + "." + j} ansi={ansi}>
                 {ansi.content.replace(/ /g, "\u00a0")}
               </LogSpan>
             );
@@ -156,278 +149,288 @@ export default class Logs extends Component<PropsType, StateType> {
     });
   };
 
-  setupWebsocket = () => {
-    let { currentCluster, currentProject } = this.context;
-    let { selectedPod } = this.props;
-    if (!selectedPod?.metadata?.name) return;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-    const currentTab = this.state.currentTab;
-    if (currentTab === "Application") {
-      this.ws = new WebSocket(
-        `${protocol}://${window.location.host}/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?previous=${this.state.getPreviousLogs}`
-      );
-    } else {
-      this.ws = new WebSocket(
-        `${protocol}://${window.location.host}/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?container_name=${currentTab}&previous=${this.state.getPreviousLogs}`
-      );
-    }
+  const renderContent = () => (
+    <>
+      <Wrapper ref={wrapperRef}>{renderLogs()}</Wrapper>
+      <LogTabs>
+        {containers.map((containerName, _i, arr) => {
+          return (
+            <Tab
+              key={containerName}
+              onClick={() => {
+                setCurrentContainer(containerName);
+              }}
+              clicked={currentContainer === containerName}
+            >
+              {arr.length > 1 ? containerName : "Application"}
+            </Tab>
+          );
+        })}
+        <Tab
+          onClick={() => {
+            setCurrentContainer("system");
+          }}
+          clicked={currentContainer == "system"}
+        >
+          System
+        </Tab>
+      </LogTabs>
+      <Options>
+        <Scroll
+          onClick={() => {
+            setIsScrollToBottomEnabled(!isScrollToBottomEnabled);
+            if (isScrollToBottomEnabled) {
+              scrollToBottom(true);
+            }
+          }}
+        >
+          <input
+            type="checkbox"
+            checked={isScrollToBottomEnabled}
+            onChange={() => {}}
+          />
+          Scroll to Bottom
+        </Scroll>
+        {Array.isArray(previousLogs) && previousLogs.length > 0 && (
+          <Scroll
+            onClick={() => {
+              setShowPreviousLogs(!showPreviousLogs);
+            }}
+          >
+            <input
+              type="checkbox"
+              checked={showPreviousLogs}
+              onChange={() => {}}
+            />
+            Show previous Logs
+          </Scroll>
+        )}
+        <Refresh
+          onClick={() => {
+            // this.refreshLogs();
+            console.log("Refresh logs");
+            refresh();
+          }}
+        >
+          <i className="material-icons">autorenew</i>
+          Refresh
+        </Refresh>
+      </Options>
+    </>
+  );
+
+  if (!containers?.length) {
+    return null;
+  }
 
-    this.ws.onopen = () => { };
+  if (rawText) {
+    return <LogStreamAlt>{renderContent()}</LogStreamAlt>;
+  }
 
-    this.ws.onmessage = (evt: MessageEvent) => {
-      let ansiLog = Anser.ansiToJson(evt.data);
+  return <LogStream>{renderContent()}</LogStream>;
+};
 
-      let logs = this.state.logs;
-      logs.push([this.state.numLogs, ansiLog]);
+export default LogsFC;
 
-      // this is technically not as efficient as things could be
-      // if there are performance issues, a deque can be used in place of a list
-      // for storing logs
-      if (logs.length > MAX_LOGS) {
-        logs.shift();
-      }
+const useLogs = (currentPod: SelectedPodType) => {
+  const currentPodName = useRef<string>();
 
-      this.setState(
-        (prev) => {
-          return {
-            logs: prev.logs,
-            numLogs: prev.numLogs + 1,
-          };
-        },
-        () => {
-          if (this.state.scroll) {
-            this.scrollToBottom(false);
-          }
-        }
-      );
-    };
+  const { currentCluster, currentProject } = useContext(Context);
+  const [containers, setContainers] = useState<string[]>([]);
+  const [currentContainer, setCurrentContainer] = useState<string>("");
+  const [logs, setLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
 
-    this.ws.onerror = (err: ErrorEvent) => { };
+  const [prevLogs, setPrevLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
 
-    this.ws.onclose = () => { };
-  };
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    getWebsocket,
+    closeWebsocket,
+  } = useWebsockets();
 
-  refreshLogs = () => {
-    let { selectedPod } = this.props;
-    if (
-      this.ws &&
-      typeof this.state.currentTab === "string" &&
-      this.state.currentTab != "System"
-    ) {
-      this.ws.close();
-      this.ws = null;
-      this.setState({ logs: [] });
-      this.setupWebsocket();
-    } else if (this.state.currentTab == "System") {
-      this.retrieveEvents(selectedPod);
-    }
-  };
+  const getSystemLogs = async () => {
+    const events = await api
+      .getPodEvents(
+        "<token>",
+        {},
+        {
+          name: currentPod?.metadata?.name,
+          namespace: currentPod?.metadata?.namespace,
+          cluster_id: currentCluster?.id,
+          id: currentProject?.id,
+        }
+      )
+      .then((res) => res.data);
 
-  componentDidUpdate = (prevProps: any, prevState: any) => {
-    if (prevState.currentTab !== this.state.currentTab) {
-      let { selectedPod } = this.props;
+    let processedLogs = [] as Anser.AnserJsonEntry[][];
 
-      this.ws?.close();
+    events.items.forEach((evt: any) => {
+      let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
+      let ansiLog = Anser.ansiToJson(
+        `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
+      );
+      processedLogs.push(ansiLog);
+    });
 
-      this.setState({ logs: [] });
+    // SET LOGS FOR SYSTEM
+    setLogs((prevState) => ({
+      ...prevState,
+      system: processedLogs,
+    }));
+  };
 
-      if (this.state.currentTab == "System") {
-        this.retrieveEvents(selectedPod);
-        return;
-      }
+  const getContainerPreviousLogs = async (containerName: string) => {
+    try {
+      const logs = await api
+        .getPreviousLogsForContainer<{ previous_logs: string[] }>(
+          "<token>",
+          {
+            container_name: containerName,
+          },
+          {
+            pod_name: currentPod?.metadata?.name,
+            namespace: currentPod?.metadata?.namespace,
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+          }
+        )
+        .then((res) => res.data);
+      // Process logs
+      const processedLogs: Anser.AnserJsonEntry[][] = logs.previous_logs.map(
+        (currentLog) => {
+          let ansiLog = Anser.ansiToJson(currentLog);
+          return ansiLog;
+        }
+      );
 
-      this.setState({ getPreviousLogs: false });
-      this.setupWebsocket();
-      this.scrollToBottom(false);
-    }
+      setPrevLogs((pl) => ({
+        ...pl,
+        [containerName]: processedLogs,
+      }));
+    } catch (error) {}
   };
 
-  retrieveEvents = (selectedPod: any) => {
-    api
-      .getPodEvents(
-        "<token>",
-        {},
-        {
-          name: selectedPod?.metadata?.name,
-          namespace: selectedPod?.metadata?.namespace,
-          cluster_id: this.context.currentCluster.id,
-          id: this.context.currentProject.id,
-        }
-      )
-      .then((res) => {
-        let logs = [] as [number, Anser.AnserJsonEntry[]][];
-        // TODO: column view
-        // logs.push(Anser.ansiToJson("\u001b[33;5;196mEvent Type\u001b[0m \t || \t \u001b[43m\u001b[34m\tReason\t\u001b[0m \t ||\tMessage"))
-
-        res.data.items.forEach((evt: any) => {
-          let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
-          let ansiLog = Anser.ansiToJson(
-            `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
-          );
-          logs.push([logs.length, ansiLog]);
+  const setupWebsocket = (containerName: string, websocketKey: string) => {
+    if (!currentPod?.metadata?.name) return;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${currentPod?.metadata?.namespace}/pod/${currentPod?.metadata?.name}/logs?container_name=${containerName}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        let ansiLog = Anser.ansiToJson(evt.data);
+        setLogs((logs) => {
+          const tmpLogs = { ...logs };
+          let containerLogs = tmpLogs[containerName] || [];
+
+          containerLogs.push(ansiLog);
+          // this is technically not as efficient as things could be
+          // if there are performance issues, a deque can be used in place of a list
+          // for storing logs
+          if (containerLogs.length > MAX_LOGS) {
+            containerLogs.shift();
+          }
+
+          return {
+            ...logs,
+            [containerName]: containerLogs,
+          };
         });
-        this.setState({ logs: logs });
-        console.log(res);
-      })
-      .catch((err) => {
-        console.log(err);
-      });
+      },
+      onclose: () => {
+        console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, endpoint, config);
+    openWebsocket(websocketKey);
   };
 
-  componentDidMount() {
-    let { selectedPod } = this.props;
+  const refresh = () => {
+    const websocketKey = `${currentPodName.current}-${currentContainer}-websocket`;
+    closeWebsocket(websocketKey);
 
-    if (selectedPod?.spec?.containers?.length > 1) {
-      const firstContainer = selectedPod?.spec?.containers[0];
-      this.setState({ currentTab: firstContainer?.name }, () => {
-        this.setupWebsocket();
-        this.scrollToBottom(false);
-      });
-      return;
-    }
+    setPrevLogs((prev) => ({ ...prev, [currentContainer]: [] }));
+    setLogs((prev) => ({ ...prev, [currentContainer]: [] }));
 
-    if (this.state.currentTab == "Application") {
-      this.setupWebsocket();
-      this.scrollToBottom(false);
+    if (!Array.isArray(containers)) {
       return;
     }
 
-    this.retrieveEvents(selectedPod);
-  }
-
-  componentWillUnmount() {
-    if (this.ws) {
-      this.ws.close();
+    if (currentContainer === "system") {
+      getSystemLogs();
+    } else {
+      getContainerPreviousLogs(currentContainer);
+      setupWebsocket(currentContainer, websocketKey);
     }
-  }
-
-  renderContainerTabs = () => {
-    const containers = this.props.selectedPod?.spec?.containers;
+  };
 
-    if (!Array.isArray(containers) || containers?.length <= 1) {
-      return (
-        <Tab
-          onClick={() => {
-            this.setState({ currentTab: "Application" });
-          }}
-          clicked={this.state.currentTab == "Application"}
-        >
-          Application
-        </Tab>
-      );
+  useEffect(() => {
+    console.log("Selected pod updated");
+    if (currentPod?.metadata?.name === currentPodName.current) {
+      return () => {
+        closeAllWebsockets();
+      };
     }
+    currentPodName.current = currentPod?.metadata?.name;
+    const currentContainers =
+      currentPod?.spec?.containers?.map((container) => container?.name) || [];
+
+    setContainers(currentContainers);
+    setCurrentContainer(currentContainers[0]);
+    return () => {
+      closeAllWebsockets();
+    };
+  }, [currentPod]);
 
-    return (
-      <>
-        {containers.map((container: any) => {
-          return (
-            <Tab
-              key={container.name}
-              onClick={() => {
-                this.setState({ currentTab: container.name });
-              }}
-              clicked={this.state.currentTab == container.name}
-            >
-              {container.name}
-            </Tab>
-          );
-        })}
-      </>
-    );
-  };
+  // Retrieve all previous logs for containers
+  useEffect(() => {
+    closeAllWebsockets();
 
-  render() {
-    if (this.props.rawText) {
-      return (
-        <LogStreamAlt>
-          <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
-          <LogTabs>
-            {this.renderContainerTabs()}
-            <Tab
-              onClick={() => {
-                this.setState({ currentTab: "System" });
-              }}
-              clicked={this.state.currentTab == "System"}
-            >
-              System
-            </Tab>
-          </LogTabs>
-          <Options>
-            <Scroll
-              onClick={() => {
-                this.setState({ scroll: !this.state.scroll }, () => {
-                  if (this.state.scroll) {
-                    this.scrollToBottom(true);
-                  }
-                });
-              }}
-            >
-              <input
-                type="checkbox"
-                checked={this.state.scroll}
-                onChange={() => { }}
-              />
-              Scroll to Bottom
-            </Scroll>
-            <Refresh
-              onClick={() => {
-                this.refreshLogs();
-              }}
-            >
-              <i className="material-icons">autorenew</i>
-              Refresh
-            </Refresh>
-          </Options>
-        </LogStreamAlt>
-      );
+    setPrevLogs({});
+    setLogs({});
+
+    if (!Array.isArray(containers)) {
+      return;
     }
 
-    return (
-      <LogStream>
-        <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
-        <LogTabs>
-          {this.renderContainerTabs()}
-          <Tab
-            onClick={() => {
-              this.setState({ currentTab: "System" });
-            }}
-            clicked={this.state.currentTab == "System"}
-          >
-            System
-          </Tab>
-        </LogTabs>
-        <Options>
-          <Scroll
-            onClick={() => {
-              this.setState({ scroll: !this.state.scroll }, () => {
-                if (this.state.scroll) {
-                  this.scrollToBottom(true);
-                }
-              });
-            }}
-          >
-            <input
-              type="checkbox"
-              checked={this.state.scroll}
-              onChange={() => { }}
-            />
-            Scroll to Bottom
-          </Scroll>
-          <Refresh
-            onClick={() => {
-              this.refreshLogs();
-            }}
-          >
-            <i className="material-icons">autorenew</i>
-            Refresh
-          </Refresh>
-        </Options>
-      </LogStream>
-    );
-  }
-}
+    getSystemLogs();
+    containers.forEach((containerName) => {
+      const websocketKey = `${currentPodName.current}-${containerName}-websocket`;
 
-Logs.contextType = Context;
+      getContainerPreviousLogs(containerName);
+
+      if (!getWebsocket(websocketKey)) {
+        setupWebsocket(containerName, websocketKey);
+      }
+    });
+  }, [containers]);
+
+  const currentLogs = useMemo(() => {
+    return logs[currentContainer] || [];
+  }, [currentContainer, logs]);
+
+  const currentPreviousLogs = useMemo(() => {
+    return prevLogs[currentContainer] || [];
+  }, [currentContainer, prevLogs]);
+
+  return {
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    logs: currentLogs,
+    previousLogs: currentPreviousLogs,
+    refresh,
+  };
+};
 
 const Highlight = styled.div`
   display: flex;
@@ -447,7 +450,7 @@ const Scroll = styled.div`
   align-items: center;
   display: flex;
   cursor: pointer;
-  width: 145px;
+  width: max-content;
   height: 100%;
 
   :hover {

+ 6 - 25
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -28,40 +28,21 @@ export default class ClusterInstructionsModal extends Component<
       case 0:
         return (
           <Placeholder>
-            1. To install the Porter CLI, first retrieve the latest binary:
+            1. To install the Porter CLI, run the following in your terminal:
             <Code>
-              &#123;
-              <br />
-              name=$(curl -s
-              https://api.github.com/repos/porter-dev/porter/releases/latest |
-              grep "browser_download_url.*/porter_.*_Darwin_x86_64\.zip" | cut
-              -d ":" -f 2,3 | tr -d \")
-              <br />
-              name=$(basename $name)
-              <br />
-              curl -L
-              https://github.com/porter-dev/porter/releases/latest/download/$name
-              --output $name
-              <br />
-              unzip -a $name
-              <br />
-              rm $name
-              <br />
-              &#125;
+              /bin/bash -c "$(curl -fsSL https://install.porter.run)"
             </Code>
-            2. Move the file into your bin:
+            Alternatively, on macOS you can use Homebrew:
             <Code>
-              chmod +x ./porter
-              <br />
-              sudo mv ./porter /usr/local/bin/porter
+              brew install porter-dev/porter/porter
             </Code>
-            3. Log in to the Porter CLI:
+            2. Log in to the Porter CLI:
             <Code>
               porter config set-host {location.protocol + "//" + location.host}
               <br />
               porter auth login
             </Code>
-            4. Configure the Porter CLI and link your current context:
+            3. Configure the Porter CLI and link your current context:
             <Code>
               porter config set-project {this.context.currentProject.id}
               <br />

+ 7 - 26
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_ConnectExternalCluster.tsx

@@ -48,7 +48,7 @@ const ConnectExternalCluster: React.FC<Props> = ({
           }
         }
       });
-    } catch (error) {}
+    } catch (error) { }
   };
 
   useEffect(() => {
@@ -64,45 +64,26 @@ const ConnectExternalCluster: React.FC<Props> = ({
       case 0:
         return (
           <Placeholder>
-            1. To install the Porter CLI, first retrieve the latest binary:
+            1. To install the Porter CLI, run the following in your terminal:
             <Code>
-              &#123;
-              <br />
-              name=$(curl -s
-              https://api.github.com/repos/porter-dev/porter/releases/latest |
-              grep "browser_download_url.*/porter_.*_Darwin_x86_64\.zip" | cut
-              -d ":" -f 2,3 | tr -d \")
-              <br />
-              name=$(basename $name)
-              <br />
-              curl -L
-              https://github.com/porter-dev/porter/releases/latest/download/$name
-              --output $name
-              <br />
-              unzip -a $name
-              <br />
-              rm $name
-              <br />
-              &#125;
+              /bin/bash -c "$(curl -fsSL https://install.porter.run)"
             </Code>
-            2. Move the file into your bin:
+            Alternatively, on macOS you can use Homebrew:
             <Code>
-              chmod +x ./porter
-              <br />
-              sudo mv ./porter /usr/local/bin/porter
+              brew install porter-dev/porter/porter
             </Code>
           </Placeholder>
         );
       case 1:
         return (
           <Placeholder>
-            3. Log in to the Porter CLI:
+            2. Log in to the Porter CLI:
             <Code>
               porter config set-host {location.protocol + "//" + location.host}
               <br />
               porter auth login
             </Code>
-            4. Configure the Porter CLI and link your current context:
+            3. Configure the Porter CLI and link your current context:
             <Code>
               porter config set-project {project.id}
               <br />

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

@@ -1326,6 +1326,22 @@ const getCanCreateProject = baseApi<{}, {}>(
   () => "/api/can_create_project"
 );
 
+const getPreviousLogsForContainer = baseApi<
+  {
+    container_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    pod_name: string;
+  }
+>(
+  "GET",
+  ({ cluster_id, namespace, pod_name: name, project_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pod/${name}/previous_logs`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1458,4 +1474,5 @@ export default {
   getLogBuckets,
   getLogBucketLogs,
   getCanCreateProject,
+  getPreviousLogsForContainer,
 };

+ 1 - 2
dashboard/src/shared/common.tsx

@@ -20,8 +20,7 @@ export const integrationList: any = {
     buttonText: "Add a Cluster",
   },
   repo: {
-    icon:
-      "https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png",
+    icon: "https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png",
     label: "Git Repository",
     buttonText: "Link a Github Account",
   },

+ 2 - 1
dashboard/src/shared/hooks/useWebsockets.ts

@@ -99,7 +99,7 @@ export const useWebsockets = () => {
   /**
    * Close specific websocket
    */
-  const closeWebsocket = (id: string, code?: number, reason?: string) => {
+  const closeWebsocket = (id: string, code: number =  4000, reason: string = "User closed the websocket connection") => {
     const ws = websocketMap.current[id];
 
     if (!ws) {
@@ -108,6 +108,7 @@ export const useWebsockets = () => {
     }
 
     ws.close(code, reason);
+    websocketMap.current[id] = null;
   };
 
   /**

+ 70 - 7
internal/kubernetes/agent.go

@@ -554,7 +554,7 @@ func (a *Agent) DeletePod(namespace string, name string) error {
 }
 
 // GetPodLogs streams real-time logs from a given pod.
-func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool, selectedContainer string, rw *websocket.WebsocketSafeReadWriter) error {
+func (a *Agent) GetPodLogs(namespace string, name string, selectedContainer string, rw *websocket.WebsocketSafeReadWriter) error {
 	// get the pod to read in the list of contains
 	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
 		context.Background(),
@@ -568,11 +568,9 @@ func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool,
 		return fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
 	}
 
-	if !showPreviousLogs {
-		// see if container is ready and able to open a stream. If not, wait for container
-		// to be ready.
-		err, _ = a.waitForPod(pod)
-	}
+	// see if container is ready and able to open a stream. If not, wait for container
+	// to be ready.
+	err, _ = a.waitForPod(pod)
 
 	if err != nil && goerrors.Is(err, IsNotFoundError) {
 		return IsNotFoundError
@@ -593,7 +591,6 @@ func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool,
 		Follow:    true,
 		TailLines: &tails,
 		Container: container,
-		Previous:  showPreviousLogs,
 	}
 
 	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
@@ -656,6 +653,72 @@ func (a *Agent) GetPodLogs(namespace string, name string, showPreviousLogs bool,
 	}
 }
 
+// GetPodLogs streams real-time logs from a given pod.
+func (a *Agent) GetPreviousPodLogs(namespace string, name string, selectedContainer string) ([]string, error) {
+	// get the pod to read in the list of contains
+	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil && errors.IsNotFound(err) {
+		return nil, IsNotFoundError
+	} else if err != nil {
+		return nil, fmt.Errorf("Cannot get logs from pod %s: %s", name, err.Error())
+	}
+
+	container := pod.Spec.Containers[0].Name
+
+	if len(selectedContainer) > 0 {
+		container = selectedContainer
+	}
+
+	tails := int64(400)
+
+	// follow logs
+	podLogOpts := v1.PodLogOptions{
+		Follow:    true,
+		TailLines: &tails,
+		Container: container,
+		Previous:  true,
+	}
+
+	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(context.TODO())
+
+	// in the case of bad request errors, such as if the pod is stuck in "ContainerCreating",
+	// we'd like to pass this through to the client.
+	if err != nil && strings.Contains(err.Error(), "not found") {
+		return nil, IsNotFoundError
+	}
+
+	if err != nil && errors.IsBadRequest(err) {
+		return nil, &BadRequestError{err.Error()}
+	} else if err != nil {
+		return nil, fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
+	}
+
+	defer podLogs.Close()
+
+	r := bufio.NewReader(podLogs)
+	var logs []string
+
+	for {
+		line, err := r.ReadString('\n')
+		logs = append(logs, line)
+
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			return nil, err
+		}
+	}
+
+	return logs, nil
+}
+
 // StopJobWithJobSidecar sends a termination signal to a job running with a sidecar
 func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
 	jobPods, err := a.GetJobPods(namespace, name)