فهرست منبع

Merge pull request #1740 from porter-dev/nico/expanded-chart-overhaul

[Improvement] Expanded job chart overhaul and revision section implementation
Nicolas Frati 4 سال پیش
والد
کامیت
6cda1f055c

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -10,7 +10,7 @@ import {
 } from "shared/types";
 import api from "shared/api";
 import { getQueryParam, pushFiltered } from "shared/routing";
-import ExpandedJobChart from "./ExpandedJobChart";
+import ExpandedJobChart, { ExpandedJobChartFC } from "./ExpandedJobChart";
 import ExpandedChart from "./ExpandedChart";
 import Loading from "components/Loading";
 import PageNotFound from "components/PageNotFound";
@@ -96,7 +96,7 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
       );
     } else if (currentChart && baseRoute === "jobs") {
       return (
-        <ExpandedJobChart
+        <ExpandedJobChartFC
           namespace={namespace}
           currentChart={currentChart}
           currentCluster={this.context.currentCluster}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 341 - 1075
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx


+ 15 - 23
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -14,17 +14,16 @@ import UpgradeChartModal from "main/home/modals/UpgradeChartModal";
 import { readableDate } from "shared/string_utils";
 
 type PropsType = WithAuthProps & {
-  showRevisions: boolean;
-  toggleShowRevisions: () => void;
   chart: ChartType;
   refreshChart: () => void;
   setRevision: (x: ChartType, isCurrent?: boolean) => void;
   forceRefreshRevisions: boolean;
   refreshRevisionsOff: () => void;
-  status: string;
   shouldUpdate: boolean;
   upgradeVersion: (version: string, cb: () => void) => void;
   latestVersion: string;
+  showRevisions?: boolean;
+  toggleShowRevisions?: () => void;
 };
 
 type StateType = {
@@ -33,6 +32,7 @@ type StateType = {
   upgradeVersion: string;
   loading: boolean;
   maxVersion: number;
+  expandRevisions: boolean;
 };
 
 // TODO: handle refresh when new revision is generated from an old revision
@@ -43,6 +43,7 @@ class RevisionSection extends Component<PropsType, StateType> {
     upgradeVersion: "",
     loading: false,
     maxVersion: 0, // Track most recent version even when previewing old revisions
+    expandRevisions: false,
   };
 
   refreshHistory = () => {
@@ -191,23 +192,6 @@ class RevisionSection extends Component<PropsType, StateType> {
     }
   };
 
-  renderStatus = (revision: ChartType) => {
-    if (
-      this.props.chart.version === revision.version &&
-      this.props.status == "loading"
-    ) {
-      return (
-        <div>
-          {this.props.status}
-          <LoadingGif src={loading} revision={true} />
-        </div>
-      );
-    } else if (this.props.chart.version === revision.version) {
-      return this.props.status;
-    }
-    return revision.info.status;
-  };
-
   renderRevisionList = () => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
       let isCurrent = revision.version === this.state.maxVersion;
@@ -263,7 +247,7 @@ class RevisionSection extends Component<PropsType, StateType> {
   };
 
   renderExpanded = () => {
-    if (this.props.showRevisions) {
+    if (this.state.expandRevisions) {
       return (
         <TableWrapper>
           <RevisionsTable>
@@ -324,7 +308,15 @@ class RevisionSection extends Component<PropsType, StateType> {
         <RevisionHeader
           showRevisions={this.props.showRevisions}
           isCurrent={isCurrent}
-          onClick={this.props.toggleShowRevisions}
+          onClick={() => {
+            if (typeof this.props.toggleShowRevisions === "function") {
+              this.props.toggleShowRevisions();
+            }
+            this.setState((prev) => ({
+              ...prev,
+              expandRevisions: !prev.expandRevisions,
+            }));
+          }}
         >
           <RevisionPreview>
             {isCurrent
@@ -354,7 +346,7 @@ class RevisionSection extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledRevisionSection showRevisions={this.props.showRevisions}>
+      <StyledRevisionSection showRevisions={this.state.expandRevisions}>
         {this.renderContents()}
         <ConfirmOverlay
           show={this.state.rollbackRevision && true}

+ 387 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ExpandedJobRun.tsx

@@ -0,0 +1,387 @@
+import React, { useContext, useEffect, useState } from "react";
+import { get, isEmpty } from "lodash";
+import styled from "styled-components";
+
+import backArrow from "assets/back_arrow.png";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import Loading from "components/Loading";
+import TabRegion from "components/TabRegion";
+import TitleSection from "components/TitleSection";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import DeploymentType from "../DeploymentType";
+import JobMetricsSection from "../metrics/JobMetricsSection";
+import Logs from "../status/Logs";
+import { useRouting } from "shared/routing";
+
+const readableDate = (s: string) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} on ${date}`;
+};
+
+const renderStatus = (job: any, time: string) => {
+  if (job.status?.succeeded >= 1) {
+    return <Status color="#38a88a">Succeeded {time}</Status>;
+  }
+
+  if (job.status?.failed >= 1) {
+    return (
+      <Status color="#cc3d42">
+        Failed {time}
+        {job.status.conditions.length > 0 &&
+          `: ${job.status.conditions[0].reason}`}
+      </Status>
+    );
+  }
+
+  return <Status color="#ffffff11">Running</Status>;
+};
+
+const ExpandedJobRun = ({
+  currentChart,
+  jobRun,
+  onClose,
+}: {
+  currentChart: ChartType;
+  jobRun: any;
+  onClose: () => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [currentTab, setCurrentTab] = useState<
+    "logs" | "metrics" | "config" | string
+  >("logs");
+  const [pods, setPods] = useState<any>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const { pushQueryParams } = useRouting();
+
+  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"]);
+    };
+  }, []);
+
+  const renderConfigSection = (job: any) => {
+    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 <Placeholder>No config was found.</Placeholder>;
+    }
+
+    let tag = job.spec.template.spec.containers[0].image.split(":")[1];
+    return (
+      <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>
+    );
+  };
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <StyledExpandedChart>
+      <HeaderWrapper>
+        <BackButton onClick={() => onClose()}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection icon={currentChart.chart.metadata.icon} iconWidth="33px">
+          {chart.name} <Gray>at {readableDate(run.status.startTime)}</Gray>
+        </TitleSection>
+
+        <InfoWrapper>
+          <LastDeployed>
+            {renderStatus(
+              run,
+              run.status.completionTime
+                ? readableDate(run.status.completionTime)
+                : ""
+            )}
+            <TagWrapper>
+              Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
+            </TagWrapper>
+            <DeploymentType currentChart={currentChart} />
+          </LastDeployed>
+        </InfoWrapper>
+      </HeaderWrapper>
+      <BodyWrapper>
+        <TabRegion
+          currentTab={currentTab}
+          setCurrentTab={(x: string) => setCurrentTab(x)}
+          options={[
+            {
+              label: "Logs",
+              value: "logs",
+            },
+            {
+              label: "Metrics",
+              value: "metrics",
+            },
+            {
+              label: "Config",
+              value: "config",
+            },
+          ]}
+        >
+          {currentTab === "logs" && (
+            <JobLogsWrapper>
+              <Logs
+                selectedPod={pods[0]}
+                podError={!pods[0] ? "Pod no longer exists." : ""}
+                rawText={true}
+              />
+            </JobLogsWrapper>
+          )}
+          {currentTab === "config" && <>{renderConfigSection(run)}</>}
+          {currentTab === "metrics" && (
+            <JobMetricsSection jobChart={currentChart} jobRun={run} />
+          )}
+        </TabRegion>
+      </BodyWrapper>
+    </StyledExpandedChart>
+  );
+};
+
+export default ExpandedJobRun;
+
+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 30px;
+  font-size: 13px;
+  font-weight: 500;
+  width: 100%;
+  border-radius: 8px;
+  background: #ffffff08;
+`;
+
+const JobLogsWrapper = styled.div`
+  min-height: 450px;
+  height: 55vh;
+  width: 100%;
+  border-radius: 8px;
+  background-color: black;
+  overflow-y: auto;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 10px;
+  background: ${(props) => props.color};
+  font-size: 13px;
+  border-radius: 3px;
+  height: 25px;
+  color: #ffffff;
+  margin-bottom: -3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Gray = styled.div`
+  color: #ffffff44;
+  margin-left: 15px;
+  font-weight: 400;
+  font-size: 18px;
+`;
+
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const Placeholder = styled.div`
+  min-height: 400px;
+  height: 50vh;
+  padding: 30px;
+  padding-bottom: 70px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const BodyWrapper = styled.div`
+  position: relative;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin: 24px 0px 17px 0px;
+  height: 20px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 0;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  height: 25px;
+  font-size: 12px;
+  display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 100%;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454a;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  overflow-y: auto;
+  padding-bottom: 120px;
+  flex-direction: column;
+  overflow: visible;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 8 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -11,6 +11,10 @@ type PropsType = WithAuthProps & {
   jobs: any[];
   setJobs: (job: any) => void;
   expandJob: any;
+  currentChartVersion: number;
+  latestChartVersion: number;
+  isDeployedFromGithub: boolean;
+  repositoryUrl?: string;
 };
 
 type StateType = {
@@ -62,6 +66,10 @@ class JobList extends Component<PropsType, StateType> {
                     "delete",
                   ])
                 }
+                isDeployedFromGithub={this.props.isDeployedFromGithub}
+                repositoryUrl={this.props.repositoryUrl}
+                currentChartVersion={this.props.currentChartVersion}
+                latestChartVersion={this.props.latestChartVersion}
               />
             );
           })}

+ 75 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -8,6 +8,7 @@ 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";
@@ -18,6 +19,10 @@ type PropsType = {
   deleting: boolean;
   readOnly?: boolean;
   expandJob: any;
+  currentChartVersion: number;
+  latestChartVersion: number;
+  isDeployedFromGithub: boolean;
+  repositoryUrl?: string;
 };
 
 type StateType = {
@@ -256,6 +261,40 @@ export default class JobResource extends Component<PropsType, StateType> {
     }
   };
 
+  getImageTag = () => {
+    const container = this.props.job?.spec?.template?.spec?.containers[0];
+    const tag = container?.image?.split(":")[1];
+
+    if (!tag) {
+      return "unknown";
+    }
+
+    if (this.props.isDeployedFromGithub && tag !== "latest") {
+      return (
+        <DynamicLink
+          to={`https://github.com/${this.props.repositoryUrl}/commit/${tag}`}
+          onClick={(e) => e.preventDefault()}
+          target="_blank"
+        ></DynamicLink>
+      );
+    }
+
+    return tag;
+  };
+
+  getRevisionNumber = () => {
+    const revision = this.props.job?.metadata?.labels["helm.sh/revision"];
+    let status: RevisionContainerProps["status"] = "current";
+    if (this.props.currentChartVersion > revision) {
+      status = "outdated";
+    }
+    return (
+      <RevisionContainer status={status}>
+        Revision No - {revision || "unknown"}
+      </RevisionContainer>
+    );
+  };
+
   render() {
     let icon =
       "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png";
@@ -272,12 +311,23 @@ export default class JobResource extends Component<PropsType, StateType> {
               <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>
             </Flex>
             <EndWrapper>
-              <CommandString>{commandString}</CommandString>
+              <Flex>
+                {this.getRevisionNumber()}
+                <CommandString>{commandString}</CommandString>
+              </Flex>
+
               {this.renderStatus()}
               <MaterialIconTray disabled={false}>
                 {this.renderStopButton()}
@@ -310,6 +360,26 @@ export default class JobResource extends Component<PropsType, StateType> {
 
 JobResource.contextType = Context;
 
+type RevisionContainerProps = {
+  status: "outdated" | "current";
+};
+
+const RevisionContainer = styled.span<RevisionContainerProps>`
+  margin-right: 15px;
+  ${({ status }) => {
+    if (status === "outdated") {
+      return "color: rgb(245, 203, 66);";
+    }
+    return "";
+  }}
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+  margin-left: 9px;
+  color: #ffffff88;
+`;
+
 const Row = styled.div`
   margin-top: 20px;
 `;
@@ -458,6 +528,10 @@ const Label = styled.div`
   color: #ffffff;
   font-size: 13px;
   font-weight: 500;
+  display: flex;
+  > span {
+    color: #ffffff88;
+  }
 `;
 
 const Subtitle = styled.div`

+ 8 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx

@@ -15,6 +15,10 @@ interface Props {
   jobs: any;
   handleSaveValues: any;
   expandJob: any;
+  currentChartVersion: number;
+  latestChartVersion: number;
+  isDeployedFromGithub: boolean;
+  repositoryUrl?: string;
   chartName: string;
   isLoading: boolean;
 }
@@ -69,6 +73,10 @@ const TempJobList: React.FC<Props> = (props) => {
         jobs={props.jobs}
         setJobs={props.setJobs}
         expandJob={props.expandJob}
+        isDeployedFromGithub={props.isDeployedFromGithub}
+        repositoryUrl={props.repositoryUrl}
+        currentChartVersion={props.currentChartVersion}
+        latestChartVersion={props.latestChartVersion}
       />
       <ConnectToJobInstructionsModal
         show={showConnectionModal}

+ 345 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts

@@ -0,0 +1,345 @@
+import { set } from "lodash";
+import { useContext, useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import { ChartType } from "shared/types";
+import yaml from "js-yaml";
+import { usePrevious } from "shared/hooks/usePrevious";
+import { useRouting } from "shared/routing";
+
+const PORTER_IMAGE_TEMPLATES = [
+  "porterdev/hello-porter-job",
+  "porterdev/hello-porter-job:latest",
+  "public.ecr.aws/o1j4x7p4/hello-porter-job",
+  "public.ecr.aws/o1j4x7p4/hello-porter-job:latest",
+];
+
+export const useJobs = (chart: ChartType) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [jobs, setJobs] = useState([]);
+  const jobsRef = useRef([]);
+  const [hasPorterImageTemplate, setHasPorterImageTemplate] = useState(true);
+  const [selectedJob, setSelectedJob] = useState(null);
+  const [status, setStatus] = useState<"loading" | "ready">("loading");
+  const [triggerRunStatus, setTriggerRunStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const previousChart = usePrevious(chart, null);
+
+  const { pushQueryParams, getQueryParam } = useRouting();
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const sortJobsAndSave = (newJobs: any[]) => {
+    // Set job run from URL if needed
+    const urlParams = new URLSearchParams(location.search);
+    const urlJob = urlParams.get("job");
+
+    const getTime = (job: any) => {
+      return new Date(job?.status?.startTime).getTime();
+    };
+
+    newJobs.sort((job1, job2) => {
+      // if (job1.metadata.name === urlJob) {
+      //   this.setJobRun(job1);
+      // } else if (job2.metadata.name === urlJob) {
+      //   this.setJobRun(job2);
+      // }
+
+      return getTime(job2) - getTime(job1);
+    });
+
+    let latestImageDetected =
+      newJobs[0]?.spec?.template?.spec?.containers[0]?.image;
+    if (!PORTER_IMAGE_TEMPLATES.includes(latestImageDetected)) {
+      // this.setState({ jobs, newestImage, imageIsPlaceholder: false });
+      setHasPorterImageTemplate(false);
+    }
+    jobsRef.current = newJobs;
+    setJobs(newJobs);
+  };
+
+  const mergeNewJob = (newJob: any) => {
+    let newJobs = [...jobsRef.current];
+    const existingJobIndex = newJobs.findIndex((currentJob) => {
+      return (
+        currentJob.metadata?.name === newJob.metadata?.name &&
+        currentJob.metadata?.namespace === newJob.metadata?.namespace
+      );
+    });
+
+    if (existingJobIndex > -1) {
+      newJobs.splice(existingJobIndex, 1, newJob);
+    } else {
+      newJobs.push(newJob);
+    }
+    sortJobsAndSave(newJobs);
+  };
+
+  const removeJob = (deletedJob: any) => {
+    let newJobs = jobsRef.current.filter((job: any) => {
+      return deletedJob.metadata?.name !== job.metadata?.name;
+    });
+
+    sortJobsAndSave([...newJobs]);
+  };
+
+  const setupCronJobWebsocket = () => {
+    const releaseName = chart.name;
+    const releaseNamespace = chart.namespace;
+    if (!releaseName || !releaseNamespace) {
+      return;
+    }
+
+    const websocketId = `cronjob-websocket-${releaseName}`;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/cronjob/status`;
+
+    const config: NewWebsocketOptions = {
+      onopen: console.log,
+      onmessage: (evt: MessageEvent) => {
+        const event = JSON.parse(evt.data);
+        const object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setHasPorterImageTemplate((prevValue) => {
+          // if imageIsPlaceholder is true update the newestImage and imageIsPlaceholder fields
+
+          if (event.event_type !== "ADD" && event.event_type !== "UPDATE") {
+            return prevValue;
+          }
+
+          if (!hasPorterImageTemplate) {
+            return prevValue;
+          }
+
+          if (!event.Object?.metadata?.annotations) {
+            return prevValue;
+          }
+
+          // filter job belonging to chart
+          const relNameAnnotation =
+            event.Object?.metadata?.annotations["meta.helm.sh/release-name"];
+          const relNamespaceAnnotation =
+            event.Object?.metadata?.annotations[
+              "meta.helm.sh/release-namespace"
+            ];
+
+          if (
+            releaseName !== relNameAnnotation ||
+            releaseNamespace !== relNamespaceAnnotation
+          ) {
+            return prevValue;
+          }
+
+          const newestImage =
+            event.Object?.spec?.jobTemplate?.spec?.template?.spec?.containers[0]
+              ?.image;
+
+          if (!PORTER_IMAGE_TEMPLATES.includes(newestImage)) {
+            return false;
+          }
+
+          return true;
+        });
+      },
+      onclose: console.log,
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketId);
+      },
+    };
+
+    newWebsocket(websocketId, endpoint, config);
+    openWebsocket(websocketId);
+  };
+
+  const setupJobWebsocket = () => {
+    const chartVersion = `${chart?.chart?.metadata?.name}-${chart?.chart?.metadata?.version}`;
+
+    const websocketId = `job-websocket-${chart.name}`;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/job/status`;
+
+    const config: NewWebsocketOptions = {
+      onopen: console.log,
+      onmessage: (evt: MessageEvent) => {
+        const event = JSON.parse(evt.data);
+
+        const chartLabel = event.Object?.metadata?.labels["helm.sh/chart"];
+        const releaseLabel =
+          event.Object?.metadata?.labels["meta.helm.sh/release-name"];
+
+        if (chartLabel !== chartVersion || releaseLabel !== chart.name) {
+          return;
+        }
+
+        // if event type is add or update, merge with existing jobs
+        if (event.event_type === "ADD" || event.event_type === "UPDATE") {
+          mergeNewJob(event.Object);
+          return;
+        }
+
+        if (event.event_type === "DELETE") {
+          // filter job belonging to chart
+          removeJob(event.Object);
+        }
+      },
+      onclose: console.log,
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(websocketId);
+      },
+    };
+    newWebsocket(websocketId, endpoint, config);
+    openWebsocket(websocketId);
+  };
+
+  const loadJobFromurl = () => {
+    const jobName = getQueryParam("job");
+
+    const job: any = jobs.find((tmpJob) => tmpJob.metadata.name === jobName);
+
+    if (!job) {
+      return;
+    }
+
+    setSelectedJob(job);
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (!chart) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    if (
+      previousChart?.name === chart?.name &&
+      previousChart?.namespace === chart?.namespace
+    ) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    setStatus("loading");
+    const newestImage = chart?.config?.image?.repository;
+
+    setHasPorterImageTemplate(PORTER_IMAGE_TEMPLATES.includes(newestImage));
+
+    api
+      .getJobs(
+        "<token>",
+        {},
+        {
+          id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+          namespace: chart.namespace,
+          release_name: chart.name,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          sortJobsAndSave(res.data);
+          setStatus("ready");
+          setupJobWebsocket();
+          setupCronJobWebsocket();
+        }
+      });
+    return () => {
+      isSubscribed = false;
+    };
+  }, [chart]);
+
+  useEffect(() => {
+    if (!jobs.length) {
+      return;
+    }
+
+    loadJobFromurl();
+  }, [jobs]);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  const runJob = () => {
+    setTriggerRunStatus("loading");
+    const config = chart.config;
+    const values = {};
+
+    for (let key in config) {
+      set(values, key, config[key]);
+    }
+
+    set(values, "paused", false);
+
+    const yamlValues = yaml.dump(
+      {
+        ...values,
+      },
+      { forceQuotes: true }
+    );
+
+    api
+      .upgradeChartValues(
+        "<token>",
+        {
+          values: yamlValues,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        setTriggerRunStatus("successful");
+        setTimeout(() => setTriggerRunStatus(""), 500);
+      })
+      .catch((err) => {
+        let parsedErr = err?.response?.data?.error;
+
+        if (parsedErr) {
+          err = parsedErr;
+        }
+
+        // this.setState({
+        //   saveValuesStatus: parsedErr,
+        // });
+        setTriggerRunStatus("Couldn't trigger a new run for this job.");
+        setTimeout(() => setTriggerRunStatus(""), 500);
+        setCurrentError(parsedErr);
+      });
+  };
+
+  const handleSetSelectedJob = (job: any) => {
+    setSelectedJob(job);
+    pushQueryParams({ job: job?.metadata?.name });
+  };
+
+  return {
+    jobs,
+    hasPorterImageTemplate,
+    status,
+    triggerRunStatus,
+    runJob,
+    selectedJob,
+    setSelectedJob: handleSetSelectedJob,
+  };
+};

+ 0 - 3
dashboard/src/main/home/modals/UpgradeChartModal.tsx

@@ -84,9 +84,6 @@ ${note.note}
   render() {
     return (
       <StyledUpgradeChartModal>
-        <CloseButton onClick={this.props.closeModal}>
-          <CloseButtonImg src={close} />
-        </CloseButton>
         {this.renderContent()}
         <SaveButton
           disabled={false}

+ 295 - 0
dashboard/src/shared/hooks/useChart.ts

@@ -0,0 +1,295 @@
+import yaml from "js-yaml";
+import { useContext, useEffect, useState } from "react";
+import { useRouteMatch } from "react-router";
+import api from "shared/api";
+import { onlyInLeft } from "shared/array_utils";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { ChartType, ChartTypeWithExtendedConfig } from "shared/types";
+
+export const useChart = (oldChart: ChartType, closeChart: () => void) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [chart, setChart] = useState<ChartTypeWithExtendedConfig>(null);
+  const { url: matchUrl } = useRouteMatch();
+
+  const [status, setStatus] = useState<"ready" | "loading" | "deleting">(
+    "loading"
+  );
+
+  const [saveStatus, setSaveStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const { pushFiltered, getQueryParam, pushQueryParams } = useRouting();
+
+  useEffect(() => {
+    const { namespace, name: chartName } = oldChart;
+    setStatus("loading");
+
+    const revision = getQueryParam("chart_revision");
+
+    api
+      .getChart<ChartTypeWithExtendedConfig>(
+        "token",
+        {},
+        {
+          id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+          namespace,
+          name: chartName,
+          revision: Number(revision) ? Number(revision) : 0,
+        }
+      )
+      .then((res) => {
+        if (res?.data) {
+          setChart(res.data);
+        }
+      })
+      .finally(() => {
+        setStatus("ready");
+      });
+  }, [oldChart, currentCluster, currentProject]);
+
+  /**
+   * Upgrade chart version
+   */
+  const upgradeChart = async () => {
+    // convert current values to yaml
+    let valuesYaml = yaml.dump({
+      ...(chart.config as Object),
+    });
+
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values: valuesYaml,
+          version: chart.latest_version,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      window.analytics.track("Chart Upgraded", {
+        chart: chart.name,
+        values: valuesYaml,
+      });
+    } catch (err) {
+      let parsedErr = err?.response?.data?.error;
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+      setCurrentError(parsedErr);
+
+      window.analytics.track("Failed to Upgrade Chart", {
+        chart: chart.name,
+        values: valuesYaml,
+        error: err,
+      });
+    }
+  };
+
+  /**
+   * Delete/Uninstall chart
+   */
+  const deleteChart = async () => {
+    try {
+      await api.uninstallTemplate(
+        "<token>",
+        {},
+        {
+          namespace: chart.namespace,
+          name: chart.name,
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      setStatus("ready");
+      closeChart();
+      return;
+    } catch (error) {
+      console.log(error);
+      throw new Error("Couldn't uninstall the chart");
+    }
+  };
+
+  /**
+   * Update chart values
+   */
+  const updateChart = async (
+    processValues:
+      | ((chart: ChartType) => string)
+      | ((chart: ChartType, oldChart?: ChartType) => string)
+  ) => {
+    setSaveStatus("loading");
+    const values = processValues(chart, oldChart);
+
+    const oldSyncedEnvGroups = oldChart.config?.container?.env?.synced || [];
+    const newSyncedEnvGroups = chart.config?.container?.env?.synced || [];
+
+    const deletedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      oldSyncedEnvGroups,
+      newSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addedEnvGroups = onlyInLeft<{
+      keys: Array<any>;
+      name: string;
+      version: number;
+    }>(
+      newSyncedEnvGroups,
+      oldSyncedEnvGroups,
+      (oldVal, newVal) => oldVal.name === newVal.name
+    );
+
+    const addApplicationToEnvGroupPromises = addedEnvGroups.map(
+      (envGroup: any) => {
+        return api.addApplicationToEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: chart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: chart.namespace,
+          }
+        );
+      }
+    );
+
+    try {
+      await Promise.all(addApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't sync the env group to the application, please try again."
+      );
+    }
+
+    const removeApplicationToEnvGroupPromises = deletedEnvGroups.map(
+      (envGroup: any) => {
+        return api.removeApplicationFromEnvGroup(
+          "<token>",
+          {
+            name: envGroup?.name,
+            app_name: chart.name,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            namespace: chart.namespace,
+          }
+        );
+      }
+    );
+    try {
+      await Promise.all(removeApplicationToEnvGroupPromises);
+    } catch (error) {
+      setCurrentError(
+        "We coudln't remove the synced env group from the application, please try again."
+      );
+    }
+
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      setSaveStatus("successful");
+      setTimeout(() => setSaveStatus(""), 500);
+    } catch (err) {
+      let parsedErr = err?.response?.data?.error;
+
+      if (!parsedErr) {
+        parsedErr = err;
+      }
+      setCurrentError(parsedErr);
+      setSaveStatus("Couldn't process the request.");
+      // throw new Error(parsedErr);
+    }
+  };
+
+  /**
+   * Refresh the chart data
+   */
+  const refreshChart = async () => {
+    try {
+      const newChart = await api
+        .getChart(
+          "<token>",
+          {},
+          {
+            name: chart.name,
+            revision: 0,
+            namespace: chart.namespace,
+            cluster_id: currentCluster.id,
+            id: currentProject.id,
+          }
+        )
+        .then((res) => res.data);
+
+      pushQueryParams({
+        chart_version: newChart.version,
+      });
+
+      setChart(newChart);
+    } catch (error) {}
+  };
+
+  const loadChartWithSpecificRevision = async (revision: number) => {
+    try {
+      const newChart = await api
+        .getChart(
+          "<token>",
+          {},
+          {
+            name: chart.name,
+            revision: revision,
+            namespace: chart.namespace,
+            cluster_id: currentCluster.id,
+            id: currentProject.id,
+          }
+        )
+        .then((res) => res.data);
+
+      pushQueryParams({
+        chart_revision: newChart.version,
+      });
+
+      setChart(newChart);
+    } catch (error) {}
+  };
+
+  return {
+    chart,
+    status,
+    saveStatus,
+    upgradeChart,
+    deleteChart,
+    updateChart,
+    refreshChart,
+    loadChartWithSpecificRevision,
+  };
+};

+ 34 - 0
dashboard/src/shared/hooks/useEffectDebugger.ts

@@ -0,0 +1,34 @@
+import { useEffect } from "react";
+import { usePrevious } from "./usePrevious";
+
+export const useEffectDebugger = (
+  effectHook: any,
+  dependencies: any,
+  dependencyNames: any = []
+) => {
+  const previousDeps = usePrevious(dependencies, []);
+
+  const changedDeps = dependencies.reduce(
+    (accum: any, dependency: any, index: any) => {
+      if (dependency !== previousDeps[index]) {
+        const keyName = dependencyNames[index] || index;
+        return {
+          ...accum,
+          [keyName]: {
+            before: previousDeps[index],
+            after: dependency,
+          },
+        };
+      }
+
+      return accum;
+    },
+    {}
+  );
+
+  if (Object.keys(changedDeps).length) {
+    console.log("[use-effect-debugger] ", changedDeps);
+  }
+
+  useEffect(effectHook, dependencies);
+};

+ 9 - 0
dashboard/src/shared/hooks/usePrevious.ts

@@ -0,0 +1,9 @@
+import { useEffect, useRef } from "react";
+
+export const usePrevious = (value: any, initialValue: any) => {
+  const ref = useRef(initialValue);
+  useEffect(() => {
+    ref.current = value;
+  });
+  return ref.current;
+};

+ 13 - 3
dashboard/src/shared/routing.tsx

@@ -30,12 +30,19 @@ export const PorterUrls = [
 ];
 
 // TODO: consolidate with pushFiltered
-export const pushQueryParams = (props: any, params: any) => {
+export const pushQueryParams = (
+  props: any,
+  params: any,
+  removedParams?: string[]
+) => {
   let { location, history } = props;
   const urlParams = new URLSearchParams(location.search);
   Object.keys(params)?.forEach((key: string) => {
     params[key] && urlParams.set(key, params[key]);
   });
+
+  removedParams?.map((deletedParam) => urlParams.delete(deletedParam));
+
   history.push({
     pathname: location.pathname,
     search: urlParams.toString(),
@@ -80,8 +87,11 @@ export const useRouting = () => {
   const history = useHistory();
 
   return {
-    pushQueryParams: (params: { [key: string]: unknown }) => {
-      return pushQueryParams({ location, history }, params);
+    pushQueryParams: (
+      params: { [key: string]: unknown },
+      removedParams?: string[]
+    ) => {
+      return pushQueryParams({ location, history }, params, removedParams);
     },
     pushFiltered: (
       pathname: string,

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است