فهرست منبع

Merge pull request #2526 from porter-dev/belanger/fix-job-status

Show pod status when job is still running or pod is pending
abelanger5 3 سال پیش
والد
کامیت
35e220016d

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