Przeglądaj źródła

expanded job chart view

Alexander Belanger 5 lat temu
rodzic
commit
1b72769521

+ 17 - 9
dashboard/src/components/values-form/InputArray.tsx

@@ -6,6 +6,7 @@ type PropsType = {
   values: string[];
   setValues: (x: string[]) => void;
   width?: string;
+  disabled?: boolean;
 };
 
 type StateType = {};
@@ -19,6 +20,20 @@ export default class InputArray extends Component<PropsType, StateType> {
     return arr;
   };
 
+  renderDeleteButton = (values: string[], i: number) => {
+    if (!this.props.disabled) {
+      return (<DeleteButton
+      onClick={() => {
+        let v = [...values];
+        v.splice(i, 1);
+        this.props.setValues(v);
+      }}
+    >
+      <i className="material-icons">cancel</i>
+    </DeleteButton>)
+    }
+  }
+
   renderInputList = (values: string[]) => {
     return (
       <>
@@ -34,16 +49,9 @@ export default class InputArray extends Component<PropsType, StateType> {
                   v[i] = e.target.value;
                   this.props.setValues(v);
                 }}
+                disabled={this.props.disabled}
               />
-              <DeleteButton
-                onClick={() => {
-                  let v = [...values];
-                  v.splice(i, 1);
-                  this.props.setValues(v);
-                }}
-              >
-                <i className="material-icons">cancel</i>
-              </DeleteButton>
+              {this.renderDeleteButton(values, i)}
             </InputWrapper>
           );
         })}

+ 30 - 19
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -6,6 +6,7 @@ type PropsType = {
   values: any;
   setValues: (x: any) => void;
   width?: string;
+  disabled?: boolean;
 };
 
 type StateType = {
@@ -33,6 +34,22 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     return obj;
   };
 
+  renderDeleteButton = (i: number) => {
+    if (!this.props.disabled) {
+      return (<DeleteButton
+        onClick={() => {
+          this.state.values.splice(i, 1);
+          this.setState({ values: this.state.values });
+
+          let obj = this.valuesToObject();
+          this.props.setValues(obj);
+        }}
+      >
+        <i className="material-icons">cancel</i>
+      </DeleteButton>)
+    }
+  }
+
   renderInputList = () => {
     return (
       <>
@@ -50,6 +67,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
+                disabled={this.props.disabled}
               />
               <Spacer />
               <Input
@@ -63,18 +81,9 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
+                disabled={this.props.disabled}
               />
-              <DeleteButton
-                onClick={() => {
-                  this.state.values.splice(i, 1);
-                  this.setState({ values: this.state.values });
-
-                  let obj = this.valuesToObject();
-                  this.props.setValues(obj);
-                }}
-              >
-                <i className="material-icons">cancel</i>
-              </DeleteButton>
+              {this.renderDeleteButton(i)}
             </InputWrapper>
           );
         })}
@@ -87,14 +96,16 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
       <StyledInputArray>
         <Label>{this.props.label}</Label>
         {this.state.values.length === 0 ? <></> : this.renderInputList()}
-        <AddRowButton
-          onClick={() => {
-            this.state.values.push({ key: "", value: "" });
-            this.setState({ values: this.state.values });
-          }}
-        >
-          <i className="material-icons">add</i> Add Row
-        </AddRowButton>
+        {this.props.disabled ? <></> : 
+          <AddRowButton
+            onClick={() => {
+              this.state.values.push({ key: "", value: "" });
+              this.setState({ values: this.state.values });
+            }}
+          >
+            <i className="material-icons">add</i> Add Row
+          </AddRowButton>
+        }
       </StyledInputArray>
     );
   }

+ 8 - 0
dashboard/src/components/values-form/ValuesForm.tsx

@@ -20,6 +20,7 @@ type PropsType = {
   metaState?: any;
   setMetaState?: any;
   handleEnvChange?: (x: any) => void;
+  disabled?: boolean;
 };
 
 type StateType = any;
@@ -90,6 +91,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 }
               }}
               label={item.label}
+              disabled={this.props.disabled}
             />
           );
         case "array-input":
@@ -101,6 +103,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 this.props.setMetaState({ [key]: x });
               }}
               label={item.label}
+              disabled={this.props.disabled}
             />
           );
         case "string-input":
@@ -118,6 +121,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
           );
         case "string-input-password":
@@ -136,6 +140,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
           );
         case "number-input":
@@ -161,6 +166,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
           );
         case "select":
@@ -206,6 +212,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
           );
         case "base-64-password":
@@ -223,6 +230,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
           );
         default:

+ 12 - 3
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -8,7 +8,7 @@ import { ChartType, ClusterType } from "shared/types";
 import ChartList from "./chart/ChartList";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
-import ExpandedChart from "./expanded-chart/ExpandedChart";
+import ExpandedJobChart from "./expanded-chart/ExpandedJobChart";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
@@ -93,14 +93,23 @@ class ClusterDashboard extends Component<PropsType, StateType> {
 
     if (this.state.currentChart) {
       return (
-        <ExpandedChart
+        // <ExpandedChart
+        //   namespace={this.state.namespace}
+        //   currentCluster={this.props.currentCluster}
+        //   currentChart={this.state.currentChart}
+        //   setCurrentChart={(x: ChartType | null) =>
+        //     this.setState({ currentChart: x })
+        //   }
+        //   isMetricsInstalled={this.state.isMetricsInstalled}
+        //   setSidebar={setSidebar}
+        // />
+        <ExpandedJobChart
           namespace={this.state.namespace}
           currentCluster={this.props.currentCluster}
           currentChart={this.state.currentChart}
           setCurrentChart={(x: ChartType | null) =>
             this.setState({ currentChart: x })
           }
-          isMetricsInstalled={this.state.isMetricsInstalled}
           setSidebar={setSidebar}
         />
       );

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

@@ -159,17 +159,20 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
     ws.onmessage = (evt: MessageEvent) => {
       let event = JSON.parse(evt.data);
-      let object = event.Object;
-      object.metadata.kind = event.Kind;
 
-      if (!this.state.controllers[object.metadata.uid]) return;
+      if (event.event_type == "UPDATE") {
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
 
-      this.setState({
-        controllers: {
-          ...this.state.controllers,
-          [object.metadata.uid]: object
-        }
-      });
+        if (!this.state.controllers[object.metadata.uid]) return;
+
+        this.setState({
+          controllers: {
+            ...this.state.controllers,
+            [object.metadata.uid]: object
+          }
+        });
+      }
     };
 
     ws.onclose = () => {

+ 739 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -0,0 +1,739 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import yaml from "js-yaml";
+import close from "assets/close.png";
+import _ from "lodash";
+
+import {
+  ResourceType,
+  ChartType,
+  StorageType,
+  ClusterType
+} from "shared/types";
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import SaveButton from "components/SaveButton";
+import ConfirmOverlay from "components/ConfirmOverlay";
+import Loading from "components/Loading";
+import StatusIndicator from "components/StatusIndicator";
+import TabRegion from "components/TabRegion";
+import JobList from "./jobs/JobList"
+import ExpandableResource from "components/ExpandableResource";
+import SettingsSection from "./SettingsSection";
+import ValuesWrapper from "components/values-form/ValuesWrapper";
+import ValuesForm from "components/values-form/ValuesForm";
+
+
+type PropsType = {
+  namespace: string;
+  currentChart: ChartType;
+  currentCluster: ClusterType;
+  setCurrentChart: (x: ChartType | null) => void;
+  setSidebar: (x: boolean) => void;
+};
+
+type StateType = {
+  loading: boolean;
+  jobs: any[];
+  tabOptions: any[];
+  tabContents: any;
+  currentTab: string | null;
+  websockets: Record<string, any>;
+  showDeleteOverlay: boolean;
+  deleting: boolean;
+  saveValuesStatus: string | null;
+};
+
+export default class ExpandedJobChart extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    jobs: [] as any[],
+    tabOptions: [] as any[],
+    tabContents: [] as any,
+    currentTab: null as string | null,
+    websockets: {} as Record<string, any>,
+    showDeleteOverlay: false,
+    deleting: false,
+    saveValuesStatus: null as string | null,
+  };
+
+  // Retrieve full chart data (includes form and values)
+  getChartData = (chart: ChartType) => {
+    let { currentProject } = this.context;
+    let { currentCluster, currentChart, setCurrentChart } = this.props;
+
+    this.setState({ loading: true });
+    api
+      .getChart(
+        "<token>",
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret
+        },
+        {
+          name: chart.name,
+          revision: chart.version,
+          id: currentProject.id
+        }
+      )
+      .then(res => {
+        setCurrentChart(res.data);
+        this.setState({ loading: false });
+      })
+      .catch(console.log);
+  };
+
+  refreshChart = () => this.getChartData(this.props.currentChart);
+
+  mergeNewJob = (newJob : any) => {
+      let jobs = this.state.jobs
+      let exists = false
+      jobs.forEach((job : any, i : number, self : any[]) => {
+          if (job.metadata?.name == newJob.metadata?.name && job.metadata?.namespace == newJob.metadata?.namespace) {
+              console.log("GOT MATCH")
+              self[i] = newJob
+              exists = true
+          }
+      })
+
+      if (!exists) {
+          console.log("NEED TO PUSH", newJob)
+        jobs.push(newJob)
+      }
+
+      this.sortJobsAndSave(jobs)
+  }
+
+  setupJobWebsocket = (chart: ChartType) => {
+      let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`
+
+    let { currentCluster, currentProject } = this.context;
+    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let ws = new WebSocket(
+      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
+    );
+    ws.onopen = () => {
+      console.log("connected to websocket");
+    };
+
+    ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
+
+      // if event type is add or update, merge with existing jobs
+      if (event.event_type == "ADD" || event.event_type == "UPDATE") {
+          // filter job belonging to chart
+          let chartLabel = event.Object?.metadata?.labels["helm.sh/chart"]
+
+          if (chartLabel && chartLabel == chartVersion) {
+            this.mergeNewJob(event.Object)
+          }
+      }
+    };
+
+    ws.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    ws.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      ws.close();
+    };
+
+    return ws;
+  };
+
+  handleSaveValues = (config? : any) => {
+    let { currentCluster, setCurrentError, currentProject } = this.context;
+    this.setState({ saveValuesStatus: "loading" });
+
+    let conf : string
+
+    if (!config) {
+        conf = yaml.dump({
+            ...this.props.currentChart.config,
+          });
+    } else {
+        // Convert dotted keys to nested objects
+        let values = {};
+
+        for (let key in config) {
+        _.set(values, key, config[key]);
+        }
+
+        // Weave in preexisting values and convert to yaml
+        conf = yaml.dump({
+        ...(this.props.currentChart.config as Object),
+        ...values
+        });
+    }
+
+    api
+      .upgradeChartValues(
+        "<token>",
+        {
+          namespace: this.props.currentChart.namespace,
+          storage: StorageType.Secret,
+          values: conf,
+        },
+        {
+          id: currentProject.id,
+          name: this.props.currentChart.name,
+          cluster_id: currentCluster.id
+        }
+      )
+      .then(res => {
+        this.setState({ saveValuesStatus: "successful" });
+        this.refreshChart();
+      })
+      .catch(err => {
+        console.log(err);
+        this.setState({ saveValuesStatus: "error" });
+        setCurrentError(err)
+      });
+  };
+
+  getJobs = async (chart: ChartType) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api.getJobs(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          chart: `${chart.chart.metadata.name}-${chart.chart.metadata.version}`,
+          namespace: chart.namespace,
+        }
+      ).then(res => {
+          // sort jobs by started timestamp
+          this.sortJobsAndSave(res.data)
+      })
+      .catch(err => setCurrentError(err));
+  }
+
+  sortJobsAndSave = (jobs : any[]) => {
+    jobs.sort((job1, job2) => {
+        let date1 : Date = new Date(job1.status?.startTime)
+        let date2 : Date = new Date(job2.status?.startTime)
+
+        return date2.getTime() - date1.getTime()
+    })
+
+    console.log("JOBS ARE", jobs)
+
+    this.setState({ jobs })
+  }
+
+  renderTabContents = () => {
+    let currentTab = this.state.currentTab
+    let { currentChart } = this.props;
+
+    switch (currentTab) {
+      case "jobs":
+        return (
+            <TabWrapper>
+                <JobList jobs={this.state.jobs} />
+                <SaveButton
+                    text="Rerun Job"
+                    onClick={this.handleSaveValues}
+                    status={this.state.saveValuesStatus}
+                    makeFlush={true}
+                />
+            </TabWrapper>
+          );
+      case "settings":
+        return (
+            <SettingsSection
+              currentChart={this.props.currentChart}
+              refreshChart={this.refreshChart}
+              setShowDeleteOverlay={(x: boolean) =>
+                this.setState({ showDeleteOverlay: x })
+              }
+            />
+          );
+      default:
+            if (this.state.tabOptions && currentTab && currentTab.includes("@")) {
+              return (
+                <ValuesWrapper
+                  formTabs={this.state.tabOptions}
+                  onSubmit={this.handleSaveValues}
+                  saveValuesStatus={this.state.saveValuesStatus}
+                  isInModal={true}
+                  currentTab={currentTab}
+                >
+                  {(metaState: any, setMetaState: any) => {
+                    return this.state.tabOptions.map((tab: any, i: number) => {
+                      // If tab is current, render
+                      if (tab.value === currentTab) {
+                        return (
+                          <ValuesForm
+                            key={i}
+                            metaState={metaState}
+                            setMetaState={setMetaState}
+                            sections={tab.sections}
+                            disabled={true}
+                          />
+                        );
+                      }
+                    });
+                  }}
+                </ValuesWrapper>
+              );
+            }
+    }
+  };
+
+  updateTabs() {
+    let formData = this.props.currentChart.form;
+    let tabOptions = [] as any[];
+
+    // Append universal tabs
+    tabOptions.push({ label: "Jobs", value: "jobs" });
+
+    if (formData) {
+        formData.tabs.map((tab: any, i: number) => {
+          tabOptions.push({
+            value: "@" + tab.name,
+            label: tab.label,
+            sections: tab.sections,
+            context: tab.context
+          });
+        });
+      }
+
+    tabOptions.push({ label: "Settings", value: "settings" });
+
+    // Filter tabs if previewing an old revision
+    this.setState({ tabOptions });
+  }
+
+  renderIcon = () => {
+    let { currentChart } = this.props;
+
+    if (
+      currentChart.chart.metadata.icon &&
+      currentChart.chart.metadata.icon !== ""
+    ) {
+      return <Icon src={currentChart.chart.metadata.icon} />;
+    } else {
+      return <i className="material-icons">tonality</i>;
+    }
+  };
+
+  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}`;
+  };
+
+  getChartStatus = (chartStatus: string) => {
+      return "jobs"
+
+    // if (chartStatus === "deployed") {
+    //   for (var uid in this.state.controllers) {
+    //     let value = this.state.controllers[uid];
+    //     let available = this.getAvailability(value.metadata.kind, value);
+    //     let progressing = true;
+
+    //     this.state.controllers[uid]?.status?.conditions?.forEach(
+    //       (condition: any) => {
+    //         if (
+    //           condition.type == "Progressing" &&
+    //           condition.status == "False" &&
+    //           condition.reason == "ProgressDeadlineExceeded"
+    //         ) {
+    //           progressing = false;
+    //         }
+    //       }
+    //     );
+
+    //     if (!available && progressing) {
+    //       return "loading";
+    //     } else if (!available && !progressing) {
+    //       return "failed";
+    //     }
+    //   }
+    //   return "deployed";
+    // }
+    // return chartStatus;
+  };
+
+  componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+    let { currentChart } = this.props;
+
+    window.analytics.track("Opened Chart", {
+      chart: currentChart.name
+    });
+
+    this.getChartData(currentChart);
+    this.getJobs(currentChart);
+    this.updateTabs();
+    this.setupJobWebsocket(currentChart)
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.currentChart !== prevProps.currentChart) {
+      this.updateTabs();
+    }
+  }
+
+//   componentWillUnmount() {
+//     if (this.state.websockets) {
+//       this.state.websockets.forEach((ws: WebSocket) => {
+//         ws.close();
+//       });
+//     }
+//   }
+
+  handleUninstallChart = () => {
+    let { currentProject, currentCluster } = this.context;
+    let { currentChart } = this.props;
+    this.setState({ deleting: true });
+    api
+      .uninstallTemplate(
+        "<token>",
+        {},
+        {
+          namespace: currentChart.namespace,
+          storage: StorageType.Secret,
+          name: currentChart.name,
+          id: currentProject.id,
+          cluster_id: currentCluster.id
+        }
+      )
+      .then(res => {
+        this.setState({ showDeleteOverlay: false });
+        this.props.setCurrentChart(null);
+      })
+      .catch(console.log);
+  };
+
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return (
+        <DeleteOverlay>
+          <Loading />
+        </DeleteOverlay>
+      );
+    }
+  };
+
+  render() {
+    let { currentChart, setCurrentChart } = this.props;
+    let chart = currentChart;
+    let status = this.getChartStatus(chart.info.status);
+
+    return (
+      <>
+        <CloseOverlay onClick={() => setCurrentChart(null)} />
+        <StyledExpandedChart>
+          <ConfirmOverlay
+            show={this.state.showDeleteOverlay}
+            message={`Are you sure you want to delete ${currentChart.name}?`}
+            onYes={this.handleUninstallChart}
+            onNo={() => this.setState({ showDeleteOverlay: false })}
+          />
+          {this.renderDeleteOverlay()}
+
+          <HeaderWrapper>
+            <TitleSection>
+              <Title>
+                <IconWrapper>{this.renderIcon()}</IconWrapper>
+                {chart.name}
+              </Title>
+              <InfoWrapper>
+                <LastDeployed>
+                  {this.state.jobs.length} jobs run <Dot>•</Dot>Last run
+                  {" " + this.readableDate(chart.info.last_deployed)}
+                </LastDeployed>
+              </InfoWrapper>
+
+              <TagWrapper>
+                Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+
+            <CloseButton onClick={() => setCurrentChart(null)}>
+              <CloseButtonImg src={close} />
+            </CloseButton>
+          </HeaderWrapper>
+
+          <TabRegion
+            currentTab={this.state.currentTab}
+            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+            options={this.state.tabOptions}
+            color={null}
+          >
+            {this.renderTabContents()}
+          </TabRegion>
+        </StyledExpandedChart>
+      </>
+    );
+  }
+}
+
+ExpandedJobChart.contextType = Context;
+
+const TabWrapper = styled.div`
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+`
+
+const DeleteOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  flex-direction: column;
+  background: rgb(0, 0, 0, 0.73);
+  opacity: 0;
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Bolded = styled.div`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+`;
+
+const Url = styled.a`
+  display: block;
+  margin-left: 2px;
+  font-size: 13px;
+  margin-top: 16px;
+  user-select: all;
+  margin-bottom: -5px;
+  user-select: text;
+  display: flex;
+  align-items: center;
+
+  > i {
+    font-size: 15px;
+    margin-right: 10px;
+  }
+`;
+
+const TabButton = styled.div`
+  position: absolute;
+  right: 0px;
+  height: 30px;
+  background: linear-gradient(to right, #26282f00, #26282f 20%);
+  padding-left: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: ${(props: { devOpsMode: boolean }) =>
+    props.devOpsMode ? "#aaaabb" : "#aaaabb55"};
+  margin-left: 35px;
+  border-radius: 20px;
+  text-shadow: 0px 0px 8px
+    ${(props: { devOpsMode: boolean }) =>
+      props.devOpsMode ? "#ffffff66" : "none"};
+  cursor: pointer;
+  :hover {
+    color: ${(props: { devOpsMode: boolean }) =>
+      props.devOpsMode ? "" : "#aaaabb99"};
+  }
+
+  > i {
+    font-size: 17px;
+    margin-right: 9px;
+  }
+`;
+
+const CloseOverlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const HeaderWrapper = styled.div``;
+
+const Dot = styled.div`
+  margin-right: 9px;
+  margin-left: 9px; 
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin: 16px 0px 8px 0px; 
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 0;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  position: absolute;
+  right: 0px;
+  bottom: 0px; 
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  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 Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  font-size: 16px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  margin-right: 12px;
+
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+`;
+
+const TitleSection = styled.div`
+  width: 100%;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: calc(100% - 50px);
+  height: calc(100% - 50px);
+  background: red;
+  z-index: 0;
+  position: absolute;
+  top: 25px;
+  left: 25px;
+  border-radius: 10px;
+  background: #26272f;
+  box-shadow: 0 5px 12px 4px #00000033;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  padding: 25px;
+  display: flex;
+  flex-direction: column;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const ResourceList = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+  border-radius: 5px;
+  overflow: hidden;
+`;

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

@@ -0,0 +1,51 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import _ from "lodash";
+import { Context } from "shared/Context";
+import JobResource from "./JobResource";
+
+type PropsType = {
+    jobs: any[];
+};
+
+type StateType = {};
+
+export default class JobList extends Component<PropsType, StateType> {
+  render() {
+    return (
+        <JobListWrapper>
+            <StyledJobList>
+          {this.props.jobs.map((job: any, i: number) => {
+            return (
+              <JobResource
+                key={i}
+                job={job}
+              />
+            );
+          })}
+        </StyledJobList>
+        </JobListWrapper>
+        
+      );
+  }
+}
+
+JobList.contextType = Context;
+
+const JobListWrapper = styled.div`
+  width: 100%;
+  height: calc(100% - 70px);
+  position: relative;
+  font-size: 13px;
+  padding: 0px;
+  user-select: text;
+  border-radius: 5px;
+  overflow: hidden;
+`
+
+const StyledJobList = styled.div`
+  overflow-y: scroll;
+  width: 100%;
+  height: 100%;
+`;

+ 238 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -0,0 +1,238 @@
+import React, { MouseEvent, Component } from "react";
+import styled from "styled-components";
+
+import { ChartType, StorageType } from "shared/types";
+import { Context } from "shared/Context";
+import StatusIndicator from "components/StatusIndicator";
+
+import api from "shared/api";
+import Logs from "../status/Logs"
+
+type PropsType = {
+    job: any,
+};
+
+type StateType = {
+    expanded: boolean;
+    pods: any[];
+};
+
+export default class JobResource extends Component<PropsType, StateType> {
+    state = {
+        expanded: false,
+        pods: [] as any[],
+    }
+
+  expandJob = (event: MouseEvent) => {
+    if (event) {
+        event.stopPropagation();
+      }
+
+      this.getPods(() => {
+        this.setState({ expanded: !this.state.expanded })
+      })
+  }
+
+  getPods = (callback : () => void) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api.getJobPods(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          name: this.props.job.metadata?.name,
+          namespace: this.props.job.metadata?.namespace,
+        }
+      ).then(res => {
+          this.setState({ pods: res.data })
+          callback()
+      })
+      .catch(err => setCurrentError(JSON.stringify(err)));
+  }
+
+  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}`;
+  };
+
+  getCompletedReason = () => {
+      let completeCondition : any
+
+      // get the completed reason from the status 
+      this.props.job.status?.conditions?.forEach((condition: any, i: number) => {
+          if (condition.type == "Complete") {
+              completeCondition = condition
+          }
+      })
+
+      return completeCondition.reason || `Completed at ${this.readableDate(completeCondition.lastTransitionTime)}`
+  }
+
+  getFailedReason = () => {
+    let failedCondition : any
+
+    // get the completed reason from the status 
+    this.props.job.status?.conditions?.forEach((condition: any, i: number) => {
+        if (condition.type == "Failed") {
+            failedCondition = condition
+        }
+    })
+
+    return `Failed at ${this.readableDate(failedCondition.lastTransitionTime)}`
+}
+
+  renderLogsSection = () => {
+      if (this.state.expanded) {
+          return <JobLogsWrapper>
+              <Logs 
+                selectedPod={this.state.pods[0]}
+                podError={""}
+                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()
+      }
+
+      return "Running"
+  }
+
+  render() {
+    let icon = "https://user-images.githubusercontent.com/65516095/111258413-4e2c3800-85f3-11eb-8a6a-88e03460f8fe.png"
+
+    return (
+      <StyledJob>
+        <MainRow onClick={this.expandJob}>
+          <Flex>
+            <Icon src={icon && icon} />
+            <Description>
+              <Label>Started at {this.readableDate(this.props.job.status?.startTime)}</Label>
+              <Subtitle>{this.getSubtitle()}</Subtitle>
+            </Description>
+          </Flex>
+          <MaterialIconTray disabled={false}>
+            {/* <i className="material-icons"
+            onClick={this.editButtonOnClick}>mode_edit</i> */}
+            <i className="material-icons" onClick={this.expandJob}>
+              {this.state.expanded ? "expand_less" : "expand_more"}
+            </i>
+          </MaterialIconTray>
+        </MainRow>
+        {this.renderLogsSection()}
+      </StyledJob>
+    );
+  }
+}
+
+JobResource.contextType = Context;
+
+const Icon = styled.img`
+  width: 30px;
+  margin-right: 18px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  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;
+  background: #2b2e36;
+  cursor: pointer;
+  margin-bottom: 20px;
+  border-radius: 5px;
+  border: 1px solid #ffffff0a;
+
+  :hover {
+    border: 1px solid #ffffff3c;
+  }
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+`;
+
+const MaterialIconTray = styled.div`
+  max-width: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Description = styled.div`
+  display: flex;
+  flex-direction: column;
+  margin: 0;
+  padding: 0;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const Subtitle = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  padding-top: 5px;
+`;
+
+const JobLogsWrapper = styled.div`
+  height: 400px; 
+  width: 100%;
+  background-color: black;
+`

+ 14 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -5,6 +5,7 @@ import { Context } from "shared/Context";
 type PropsType = {
   selectedPod: any;
   podError: string;
+  rawText?: boolean;
 };
 
 type StateType = {
@@ -37,11 +38,12 @@ export default class Logs extends Component<PropsType, StateType> {
 
   renderLogs = () => {
     let { selectedPod } = this.props;
+
     if (!selectedPod?.metadata?.name) {
       return <Message>Please select a pod to view its logs.</Message>;
     }
 
-    if (selectedPod?.status.phase === "Succeeded") {
+    if (selectedPod?.status.phase === "Succeeded" && !this.props.rawText) {
       return (
         <Message>
           ⌛ This job has been completed. You can now delete this job.
@@ -113,6 +115,12 @@ export default class Logs extends Component<PropsType, StateType> {
   }
 
   render() {
+    if (this.props.rawText) {
+      return <LogStreamAlt>
+      <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+      </LogStreamAlt>
+    }
+
     return (
       <LogStream>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
@@ -217,6 +225,11 @@ const LogStream = styled.div`
   overflow-wrap: break-word;
 `;
 
+const LogStreamAlt = styled(LogStream)`
+  width: 100%;
+  max-width: 100%;
+`
+
 const Message = styled.div`
   display: flex;
   height: 100%;

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

@@ -416,6 +416,24 @@ const getInvites = baseApi<{}, { id: number }>("GET", pathParams => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
+const getJobs = baseApi<
+{
+  cluster_id: number;
+},
+{ chart: string; namespace: string; id: number }
+>("GET", pathParams => {
+return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/${pathParams.chart}/jobs`;
+});
+
+const getJobPods = baseApi<
+{
+  cluster_id: number;
+},
+{ name: string; namespace: string; id: number }
+>("GET", pathParams => {
+return `/api/projects/${pathParams.id}/k8s/jobs/${pathParams.namespace}/${pathParams.name}/pods`;
+});
+
 const getMatchingPods = baseApi<
   {
     cluster_id: number;
@@ -722,6 +740,8 @@ export default {
   getInfra,
   getIngress,
   getInvites,
+  getJobs,
+  getJobPods,
   getMatchingPods,
   getMetrics,
   getNamespaces,

+ 61 - 1
internal/kubernetes/agent.go

@@ -49,7 +49,7 @@ type Agent struct {
 }
 
 type Message struct {
-	EventType string
+	EventType string `json:"event_type"`
 	Object    interface{}
 	Kind      string
 }
@@ -66,6 +66,38 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// ListJobsByLabel lists jobs in a namespace matching a label
+func (a *Agent) ListJobsByLabel(namespace, labelKey, labelVal string) ([]batchv1.Job, error) {
+	resp, err := a.Clientset.BatchV1().Jobs(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("%s=%s", labelKey, labelVal),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Items, nil
+}
+
+// GetJobPods lists all pods belonging to a job in a namespace
+func (a *Agent) GetJobPods(namespace, jobName string) ([]v1.Pod, error) {
+	resp, err := a.Clientset.CoreV1().Pods(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("%s=%s", "job-name", jobName),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Items, nil
+}
+
 // GetIngress gets ingress given the name and namespace
 func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
 	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
@@ -150,7 +182,9 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 		TailLines: &tails,
 	}
 	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
 	podLogs, err := req.Stream(context.TODO())
+
 	if err != nil {
 		return fmt.Errorf("Cannot open log stream for pod %s", name)
 	}
@@ -222,6 +256,8 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 		informer = factory.Apps().V1().ReplicaSets().Informer()
 	case "daemonset":
 		informer = factory.Apps().V1().DaemonSets().Informer()
+	case "job":
+		informer = factory.Batch().V1().Jobs().Informer()
 	}
 
 	stopper := make(chan struct{})
@@ -240,6 +276,30 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 				return
 			}
 		},
+		AddFunc: func(obj interface{}) {
+			msg := Message{
+				EventType: "ADD",
+				Object:    obj,
+				Kind:      strings.ToLower(kind),
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		DeleteFunc: func(obj interface{}) {
+			msg := Message{
+				EventType: "DELETE",
+				Object:    obj,
+				Kind:      strings.ToLower(kind),
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
 	})
 
 	go func() {

+ 102 - 1
server/api/k8s_handler.go

@@ -76,7 +76,6 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // TODO: Refactor repeated calls.
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
-
 	// get session to retrieve correct kubeconfig
 	_, err := app.Store.Get(r, app.ServerConf.CookieName)
 
@@ -262,6 +261,108 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleListJobsByChart lists all jobs belonging to a specific Helm chart
+func (app *App) HandleListJobsByChart(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	chart := chi.URLParam(r, "chart")
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	jobs, err := agent.ListJobsByLabel(namespace, "helm.sh/chart", chart)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(jobs); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}
+
+// HandleListJobPods lists all pods belonging to a specific job
+func (app *App) HandleListJobPods(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	name := chi.URLParam(r, "name")
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	pods, err := agent.GetJobPods(namespace, name)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(pods); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}
+
 // HandleStreamControllerStatus test calls
 // TODO: Refactor repeated calls.
 func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {

+ 28 - 0
server/router/router.go

@@ -1140,6 +1140,34 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/{namespace}/{chart}/jobs",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleListJobsByChart, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/jobs/{namespace}/{name}/pods",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleListJobPods, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/k8s/{namespace}/ingress/{name}",