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

Merge branch 'master' of github.com-meehawk:porter-dev/porter into feat/event-list-logs-improvement

Soham Parekh 3 лет назад
Родитель
Сommit
f03987bf5f

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

+ 10 - 16
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))
@@ -157,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 {
@@ -177,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

+ 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")
 	},
 }
 

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

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

@@ -1,212 +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>
-    );
-  }
-
-  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;
-`;

+ 14 - 4
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;
 
@@ -625,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>
       );
     }
@@ -731,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);
 
@@ -885,7 +892,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     margin_left={"0px"}
                   />
                   */}
-                  <DeployStatusSection chart={currentChart} setLogData={renderLogsAtTimestamp} />
+                  <DeployStatusSection
+                    chart={currentChart}
+                    setLogData={renderLogsAtTimestamp}
+                  />
                   <LastDeployed>
                     <Dot>•</Dot>Last deployed
                     {" " + getReadableDate(currentChart.info.last_deployed)}

+ 54 - 6
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>

+ 72 - 31
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -16,6 +16,9 @@ import Logs from "../status/Logs";
 import { useRouting } from "shared/routing";
 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);
@@ -50,15 +53,70 @@ 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>;
 };
 
@@ -79,41 +137,22 @@ const ExpandedJobRun = ({
   const [currentTab, setCurrentTab] = useState<ExpandedJobRunTabs>(
     currentCluster.agent_integration_enabled ? "events" : "logs"
   );
-  const [pods, setPods] = useState<any>(null);
-  const [isLoading, setIsLoading] = useState(true);
   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"]);
@@ -256,7 +295,9 @@ const ExpandedJobRun = ({
         <InfoWrapper>
           <LastDeployed>
             {renderStatus(
+              false,
               run,
+              pods[0],
               run.status.completionTime
                 ? readableDate(run.status.completionTime)
                 : ""

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

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