ソースを参照

jobs fe last mile + slight expanded chart state refactor

jusrhee 5 年 前
コミット
7ac99571eb
33 ファイル変更1584 行追加185 行削除
  1. BIN
      dashboard/src/assets/monojob.png
  2. BIN
      dashboard/src/assets/monoweb.png
  3. 1 1
      dashboard/src/components/repo-selector/ContentsList.tsx
  4. 19 9
      dashboard/src/components/values-form/InputArray.tsx
  5. 34 19
      dashboard/src/components/values-form/KeyValueArray.tsx
  6. 8 1
      dashboard/src/components/values-form/ValuesForm.tsx
  7. 20 17
      dashboard/src/components/values-form/ValuesWrapper.tsx
  8. 7 1
      dashboard/src/main/home/Home.tsx
  9. 41 32
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  10. 20 3
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  11. 41 44
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  12. 660 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  13. 1 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  14. 65 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  15. 276 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  16. 16 11
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  17. 0 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  18. 23 8
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  19. 1 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  20. 2 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  21. 0 1
      dashboard/src/main/home/integrations/create-integration/GCRForm.tsx
  22. 10 4
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  23. 0 1
      dashboard/src/main/home/project-settings/InviteList.tsx
  24. 0 1
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  25. 6 6
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  26. 1 1
      dashboard/src/main/home/sidebar/Drawer.tsx
  27. 90 9
      dashboard/src/main/home/sidebar/Sidebar.tsx
  28. 20 0
      dashboard/src/shared/api.tsx
  29. 5 1
      dashboard/src/shared/routing.tsx
  30. 72 1
      internal/kubernetes/agent.go
  31. 115 7
      server/api/k8s_handler.go
  32. 28 0
      server/router/router.go
  33. 2 1
      services/deploy_init_container/start.sh

BIN
dashboard/src/assets/monojob.png


BIN
dashboard/src/assets/monoweb.png


+ 1 - 1
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -49,7 +49,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
   updateContents = () => {
     let { currentProject } = this.context;
     let { actionConfig, branch } = this.props;
-    console.log(this.state.currentDir);
+
     // Get branch contents
     api
       .getBranchContents(

+ 19 - 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,22 @@ 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 +51,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>
           );
         })}

+ 34 - 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,24 @@ 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 +69,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 +83,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 +98,18 @@ 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 - 1
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":
@@ -128,7 +132,6 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               type="password"
               value={this.getInputValue(item)}
               setValue={(x: string) => {
-                console.log("string input", x);
                 if (item.settings && item.settings.unit && x !== "") {
                   x = x + item.settings.unit;
                 }
@@ -136,6 +139,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 +165,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 +211,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 +229,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
           );
         default:

+ 20 - 17
dashboard/src/components/values-form/ValuesWrapper.tsx

@@ -13,6 +13,7 @@ type PropsType = {
   saveValuesStatus?: string | null;
   isInModal?: boolean;
   currentTab?: string; // For resetting state when flipping b/w tabs in ExpandedChart
+  renderSaveButton?: boolean;
 };
 
 type StateType = any;
@@ -110,24 +111,26 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
   };
 
   renderButton = () => {
-    let { formTabs, currentTab } = this.props;
-    let tab = formTabs.find(
-      (t: any) => t.name === currentTab || t.value === currentTab
-    );
-    if (tab && tab.context && tab.context.type === "helm/values") {
-      return (
-        <SaveButton
-          disabled={this.isDisabled() || this.props.disabled}
-          text="Deploy"
-          onClick={() => this.props.onSubmit(this.state)}
-          status={
-            this.isDisabled()
-              ? "Missing required fields"
-              : this.props.saveValuesStatus
-          }
-          makeFlush={true}
-        />
+    if (this.props.renderSaveButton) {
+      let { formTabs, currentTab } = this.props;
+      let tab = formTabs.find(
+        (t: any) => t.name === currentTab || t.value === currentTab
       );
+      if (tab && tab.context && tab.context.type === "helm/values") {
+        return (
+          <SaveButton
+            disabled={this.isDisabled() || this.props.disabled}
+            text="Deploy"
+            onClick={() => this.props.onSubmit(this.state)}
+            status={
+              this.isDisabled()
+                ? "Missing required fields"
+                : this.props.saveValuesStatus
+            }
+            makeFlush={true}
+          />
+        );
+      }
     }
   };
 

+ 7 - 1
dashboard/src/main/home/Home.tsx

@@ -280,6 +280,7 @@ class Home extends Component<PropsType, StateType> {
         <ClusterDashboard
           currentCluster={currentCluster}
           setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
+          currentView={this.props.currentRoute}
           // setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
       </DashboardWrapper>
@@ -288,8 +289,13 @@ class Home extends Component<PropsType, StateType> {
 
   renderContents = () => {
     let currentView = this.props.currentRoute;
+
     if (this.context.currentProject && currentView !== "new-project") {
-      if (currentView === "cluster-dashboard") {
+      if (
+        currentView === "cluster-dashboard" ||
+        currentView === "applications" ||
+        currentView === "jobs"
+      ) {
         return this.renderDashboard();
       } else if (currentView === "dashboard") {
         return (

+ 41 - 32
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -1,14 +1,18 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import gradient from "assets/gradient.jpg";
+import monojob from "assets/monojob.png";
+import monoweb from "assets/monoweb.png";
 
 import { Context } from "shared/Context";
 import { ChartType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
 
 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";
@@ -16,6 +20,7 @@ import api from "shared/api";
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   setSidebar: (x: boolean) => void;
+  currentView: PorterUrl;
 };
 
 type StateType = {
@@ -55,7 +60,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   }
 
   componentDidUpdate(prevProps: PropsType) {
-    localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
     if (prevProps.currentCluster !== this.props.currentCluster) {
       this.setState({
@@ -66,40 +70,43 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         currentChart: null,
       });
     }
+
+    if (prevProps.currentView !== this.props.currentView) {
+      this.setState({
+        namespace: "default",
+        sortType: "Newest",
+        currentChart: null,
+      });
+    }
   }
 
   renderDashboardIcon = () => {
-    if (false) {
-      let { currentCluster } = this.props;
-      return (
-        <DashboardIcon>
-          <DashboardImage src={gradient} />
-          <Overlay>
-            {currentCluster && currentCluster.name[0].toUpperCase()}
-          </Overlay>
-        </DashboardIcon>
-      );
+    if (this.props.currentView === "jobs") {
+      return <Img src={monojob} />;
+    } else {
+      return <Img src={monoweb} />;
     }
-
-    return (
-      <DashboardIcon>
-        <i className="material-icons">device_hub</i>
-      </DashboardIcon>
-    );
   };
 
   renderContents = () => {
-    let { currentCluster, setSidebar } = this.props;
-
-    if (this.state.currentChart) {
+    let { currentCluster, setSidebar, currentView } = this.props;
+    if (this.state.currentChart && currentView === "jobs") {
+      return (
+        <ExpandedJobChart
+          namespace={this.state.namespace}
+          currentCluster={this.props.currentCluster}
+          currentChart={this.state.currentChart}
+          closeChart={() => this.setState({ currentChart: null })}
+          setSidebar={setSidebar}
+        />
+      );
+    } else if (this.state.currentChart) {
       return (
         <ExpandedChart
           namespace={this.state.namespace}
           currentCluster={this.props.currentCluster}
           currentChart={this.state.currentChart}
-          setCurrentChart={(x: ChartType | null) =>
-            this.setState({ currentChart: x })
-          }
+          closeChart={() => this.setState({ currentChart: null })}
           isMetricsInstalled={this.state.isMetricsInstalled}
           setSidebar={setSidebar}
         />
@@ -110,13 +117,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       <div>
         <TitleSection>
           {this.renderDashboardIcon()}
-          <Title>{currentCluster.name}</Title>
-          <i
-            className="material-icons"
-            onClick={() => this.context.setCurrentModal("UpdateClusterModal")}
-          >
-            more_vert
-          </i>
+          <Title>{currentView}</Title>
         </TitleSection>
 
         <InfoSection>
@@ -126,7 +127,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </InfoLabel>
           </TopRow>
           <Description>
-            Cluster dashboard for {currentCluster.name}.
+            {currentView === "jobs"
+              ? `An overview of past and current jobs for ${currentCluster.name}.`
+              : `An overview of web services and workers running on ${currentCluster.name}.`}
           </Description>
         </InfoSection>
 
@@ -149,6 +152,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         </ControlRow>
 
         <ChartList
+          currentView={currentView}
           currentCluster={currentCluster}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
@@ -183,7 +187,7 @@ const TopRow = styled.div`
 `;
 
 const Description = styled.div`
-  color: #ffffff;
+  color: #aaaabb;
   margin-top: 13px;
   margin-left: 2px;
   font-size: 13px;
@@ -311,6 +315,10 @@ const DashboardIcon = styled.div`
   }
 `;
 
+const Img = styled.img`
+  width: 30px;
+`;
+
 const Title = styled.div`
   font-size: 20px;
   font-weight: 500;
@@ -320,6 +328,7 @@ const Title = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
+  text-transform: capitalize;
 `;
 
 const TitleSection = styled.div`

+ 20 - 3
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -4,6 +4,7 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import api from "shared/api";
 import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
 
 import Chart from "./Chart";
 import Loading from "components/Loading";
@@ -13,6 +14,7 @@ type PropsType = {
   namespace: string;
   sortType: string;
   setCurrentChart: (c: ChartType) => void;
+  currentView: PorterUrl;
 };
 
 type StateType = {
@@ -63,6 +65,19 @@ export default class ChartList extends Component<PropsType, StateType> {
       )
       .then((res) => {
         let charts = res.data || [];
+
+        // filter charts based on the current view
+        let { currentView } = this.props;
+
+        charts = charts.filter((chart: ChartType) => {
+          return (
+            (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+            ((currentView == "applications" ||
+              currentView == "cluster-dashboard") &&
+              chart.chart.metadata.name != "job")
+          );
+        });
+
         if (this.props.sortType == "Newest") {
           charts.sort((a: any, b: any) =>
             Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
@@ -223,7 +238,8 @@ export default class ChartList extends Component<PropsType, StateType> {
     if (
       prevProps.currentCluster !== this.props.currentCluster ||
       prevProps.namespace !== this.props.namespace ||
-      prevProps.sortType !== this.props.sortType
+      prevProps.sortType !== this.props.sortType ||
+      prevProps.currentView !== this.props.currentView
     ) {
       this.updateCharts(this.getControllers);
     }
@@ -247,8 +263,9 @@ export default class ChartList extends Component<PropsType, StateType> {
     } else if (charts.length === 0) {
       return (
         <Placeholder>
-          <i className="material-icons">category</i> No charts found in this
-          namespace.
+          <i className="material-icons">category</i> No
+          {this.props.currentView === "jobs" ? ` jobs` : ` charts`} found in
+          this namespace.
         </Placeholder>
       );
     }

+ 41 - 44
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -31,12 +31,13 @@ type PropsType = {
   namespace: string;
   currentChart: ChartType;
   currentCluster: ClusterType;
-  setCurrentChart: (x: ChartType | null) => void;
+  closeChart: () => void;
   setSidebar: (x: boolean) => void;
   isMetricsInstalled: boolean;
 };
 
 type StateType = {
+  currentChart: ChartType;
   loading: boolean;
   showRevisions: boolean;
   components: ResourceType[];
@@ -57,6 +58,7 @@ type StateType = {
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
   state = {
+    currentChart: this.props.currentChart,
     loading: true,
     showRevisions: false,
     components: [] as ResourceType[],
@@ -78,7 +80,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   // Retrieve full chart data (includes form and values)
   getChartData = (chart: ChartType) => {
     let { currentProject } = this.context;
-    let { currentCluster, currentChart, setCurrentChart } = this.props;
+    let { currentCluster, currentChart } = this.props;
 
     this.setState({ loading: true });
     api
@@ -96,8 +98,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        setCurrentChart(res.data);
-        this.setState({ loading: false });
+        this.setState({ currentChart: res.data, loading: false });
       })
       .catch(console.log);
   };
@@ -125,7 +126,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           }
         )
         .then((res) => {
-          res.data.forEach(async (c: any) => {
+          res.data?.forEach(async (c: any) => {
             await new Promise((nextController: (res?: any) => void) => {
               c.metadata.kind = c.kind;
               this.setState(
@@ -159,17 +160,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 = () => {
@@ -193,7 +197,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   updateResources = () => {
     let { currentCluster, currentProject } = this.context;
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
 
     api
       .getChartComponents(
@@ -218,7 +222,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       .catch(console.log);
   };
 
-  refreshChart = () => this.getChartData(this.props.currentChart);
+  refreshChart = () => this.getChartData(this.state.currentChart);
 
   onSubmit = (rawValues: any) => {
     let { currentProject, currentCluster, setCurrentError } = this.context;
@@ -232,7 +236,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
     // Weave in preexisting values and convert to yaml
     let valuesYaml = yaml.dump({
-      ...(this.props.currentChart.config as Object),
+      ...(this.state.currentChart.config as Object),
       ...values,
     });
 
@@ -242,13 +246,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       .upgradeChartValues(
         "<token>",
         {
-          namespace: this.props.currentChart.namespace,
+          namespace: this.state.currentChart.namespace,
           storage: StorageType.Secret,
           values: valuesYaml,
         },
         {
           id: currentProject.id,
-          name: this.props.currentChart.name,
+          name: this.state.currentChart.name,
           cluster_id: currentCluster.id,
         }
       )
@@ -259,14 +263,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         });
 
         window.analytics.track("Chart Upgraded", {
-          chart: this.props.currentChart.name,
+          chart: this.state.currentChart.name,
           values: valuesYaml,
         });
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
         window.analytics.track("Failed to Upgrade Chart", {
-          chart: this.props.currentChart.name,
+          chart: this.state.currentChart.name,
           values: valuesYaml,
           error: err,
         });
@@ -282,22 +286,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       saveValuesStatus,
       tabOptions,
     } = this.state;
-    let { currentChart, setSidebar } = this.props;
+    let { setSidebar } = this.props;
+    let { currentChart } = this.state;
     let chart = currentChart;
 
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "status":
-        let activeJobs = Object.values(this.state.controllers)[0]?.status
-          .active;
-        let selectors = activeJobs?.map((job: any) => {
-          return `job-name=${job.name},controller-uid=${job.uid}`;
-        });
-
-        if (chart.chart.metadata.name == "job") {
-          return <StatusSection currentChart={chart} selectors={selectors} />;
-        }
         return <StatusSection currentChart={chart} />;
       case "settings":
         return (
@@ -341,6 +337,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               saveValuesStatus={this.state.saveValuesStatus}
               isInModal={true}
               currentTab={currentTab}
+              renderSaveButton={true}
             >
               {(metaState: any, setMetaState: any) => {
                 return tabOptions.map((tab: any, i: number) => {
@@ -364,7 +361,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
 
   updateTabs() {
-    let formData = this.props.currentChart.form;
+    let formData = this.state.currentChart.form;
     let tabOptions = [] as any[];
 
     // Generate form tabs if form.yaml exists
@@ -430,7 +427,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
 
   renderIcon = () => {
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
 
     if (
       currentChart.chart.metadata.icon &&
@@ -496,7 +493,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   componentDidMount() {
     let { currentCluster, currentProject } = this.context;
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
 
     window.analytics.track("Opened Chart", {
       chart: currentChart.name,
@@ -541,7 +538,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               {
                 id: currentProject.id,
                 name: ingressName,
-                namespace: `${this.props.currentChart.namespace}`,
+                namespace: `${this.state.currentChart.namespace}`,
               }
             )
             .then((res) => {
@@ -575,8 +572,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   }
 
   componentWillUnmount() {
-    if (this.state.websockets) {
-      this.state.websockets.forEach((ws: WebSocket) => {
+    if (this.state.websockets?.length > 0) {
+      this.state.websockets?.forEach((ws: WebSocket) => {
         ws.close();
       });
     }
@@ -594,7 +591,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       let serviceName = null as string;
       let serviceNamespace = null as string;
 
-      this.state.components.forEach((c: any) => {
+      this.state.components?.forEach((c: any) => {
         if (c.Kind == "Service") {
           serviceName = c.Name;
           serviceNamespace = c.Namespace;
@@ -612,7 +609,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   handleUninstallChart = () => {
     let { currentProject, currentCluster } = this.context;
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
     this.setState({ deleting: true });
     api
       .uninstallTemplate(
@@ -628,7 +625,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       )
       .then((res) => {
         this.setState({ showDeleteOverlay: false });
-        this.props.setCurrentChart(null);
+        this.props.closeChart();
       })
       .catch(console.log);
   };
@@ -644,13 +641,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { currentChart, setCurrentChart } = this.props;
+    let { closeChart } = this.props;
+    let { currentChart } = this.state;
     let chart = currentChart;
     let status = this.getChartStatus(chart.info.status);
 
     return (
       <>
-        <CloseOverlay onClick={() => setCurrentChart(null)} />
+        <CloseOverlay onClick={closeChart} />
         <StyledExpandedChart>
           <ConfirmOverlay
             show={this.state.showDeleteOverlay}
@@ -686,7 +684,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               </TagWrapper>
             </TitleSection>
 
-            <CloseButton onClick={() => setCurrentChart(null)}>
+            <CloseButton onClick={closeChart}>
               <CloseButtonImg src={close} />
             </CloseButton>
 
@@ -944,7 +942,6 @@ const CloseButtonImg = styled.img`
 const StyledExpandedChart = styled.div`
   width: calc(100% - 50px);
   height: calc(100% - 50px);
-  background: red;
   z-index: 0;
   position: absolute;
   top: 25px;

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

@@ -0,0 +1,660 @@
+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 { 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 TabRegion from "components/TabRegion";
+import JobList from "./jobs/JobList";
+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;
+  closeChart: () => void;
+  setSidebar: (x: boolean) => void;
+};
+
+type StateType = {
+  currentChart: ChartType;
+  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 = {
+    currentChart: this.props.currentChart,
+    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 } = 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) => {
+        this.setState({ currentChart: res.data, loading: false });
+      })
+      .catch(console.log);
+  };
+
+  refreshChart = () => this.getChartData(this.state.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
+      ) {
+        self[i] = newJob;
+        exists = true;
+      }
+    });
+
+    if (!exists) {
+      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"];
+        let releaseLabel =
+          event.Object?.metadata?.labels["meta.helm.sh/release-name"];
+
+        if (
+          chartLabel &&
+          releaseLabel &&
+          chartLabel == chartVersion &&
+          releaseLabel == chart.name
+        ) {
+          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.state.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.state.currentChart.config as Object),
+        ...values,
+      });
+    }
+
+    api
+      .upgradeChartValues(
+        "<token>",
+        {
+          namespace: this.state.currentChart.namespace,
+          storage: StorageType.Secret,
+          values: conf,
+        },
+        {
+          id: currentProject.id,
+          name: this.state.currentChart.name,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ saveValuesStatus: "successful" });
+        this.refreshChart();
+      })
+      .catch((err) => {
+        console.log(err);
+        this.setState({ saveValuesStatus: "error" });
+        setCurrentError(JSON.stringify(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,
+          release_name: chart.name,
+        }
+      )
+      .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;
+
+    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.state.currentChart}
+            refreshChart={this.refreshChart}
+            setShowDeleteOverlay={(x: boolean) =>
+              this.setState({ showDeleteOverlay: x })
+            }
+          />
+        );
+      default:
+        if (this.state.tabOptions && currentTab && currentTab.includes("@")) {
+          return (
+            <TabWrapper>
+              <ValuesWrapper
+                formTabs={this.state.tabOptions}
+                onSubmit={this.handleSaveValues}
+                saveValuesStatus={this.state.saveValuesStatus}
+                isInModal={true}
+                currentTab={currentTab}
+                renderSaveButton={false}
+              >
+                {(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>
+              <SaveButton
+                text="Rerun Job"
+                onClick={() => this.handleSaveValues()}
+                status={this.state.saveValuesStatus}
+                makeFlush={true}
+              />
+            </TabWrapper>
+          );
+        }
+    }
+  };
+
+  updateTabs() {
+    let formData = this.state.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.state;
+
+    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}`;
+  };
+
+  componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+    let { currentChart } = this.state;
+
+    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.state;
+    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.closeChart();
+      })
+      .catch(console.log);
+  };
+
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return (
+        <DeleteOverlay>
+          <Loading />
+        </DeleteOverlay>
+      );
+    }
+  };
+
+  render() {
+    let { closeChart } = this.props;
+    let { currentChart } = this.state;
+    let chart = currentChart;
+
+    return (
+      <>
+        <CloseOverlay onClick={closeChart} />
+        <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>
+                  Run {this.state.jobs.length} times <Dot>•</Dot>Last run
+                  {" " + this.readableDate(chart.info.last_deployed)}
+                </LastDeployed>
+              </InfoWrapper>
+
+              <TagWrapper>
+                Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+
+            <CloseButton onClick={closeChart}>
+              <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 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: 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`
+  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);
+  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);
+    }
+  }
+`;

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

@@ -72,7 +72,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
         { id: currentProject.id, name: this.props.currentChart.name }
       )
       .then((res) => {
-        console.log(res.data);
         this.setState({
           action: res.data.git_action_config,
           webhookToken: res.data.webhook_token,
@@ -287,7 +286,7 @@ const Wrapper = styled.div`
 
 const StyledSettingsSection = styled.div`
   width: 100%;
-  height: calc(100% - 60px);
+  height: calc(100% - 65px);
   background: #ffffff11;
   padding: 0 35px;
   padding-bottom: 50px;

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

@@ -0,0 +1,65 @@
+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> {
+  renderJobList = () => {
+    if (this.props.jobs.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i>
+          There are no jobs currently running.
+        </Placeholder>
+      );
+    } else {
+      return (
+        <>
+          {this.props.jobs.map((job: any, i: number) => {
+            return <JobResource key={i} job={job} />;
+          })}
+        </>
+      );
+    }
+  };
+
+  render() {
+    return <JobListWrapper>{this.renderJobList()}</JobListWrapper>;
+  }
+}
+
+JobList.contextType = Context;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+  }
+`;
+
+const JobListWrapper = styled.div`
+  width: 100%;
+  height: calc(100% - 65px);
+  position: relative;
+  font-size: 13px;
+  padding: 0px;
+  user-select: text;
+  border-radius: 5px;
+  overflow-y: auto;
+`;

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

@@ -0,0 +1,276 @@
+import React, { MouseEvent, Component } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+
+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 = () => {
+    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 failedCondition
+      ? `Failed at ${this.readableDate(failedCondition.lastTransitionTime)}`
+      : "Failed";
+  };
+
+  renderLogsSection = () => {
+    if (this.state.expanded) {
+      return (
+        <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();
+    }
+
+    return "Running";
+  };
+
+  renderStatus = () => {
+    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>;
+  };
+
+  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>
+          <EndWrapper>
+            {this.renderStatus()}
+            <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>
+          </EndWrapper>
+        </MainRow>
+        {this.renderLogsSection()}
+      </StyledJob>
+    );
+  }
+}
+
+JobResource.contextType = Context;
+
+const EndWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 10px;
+  margin-right: 20px;
+  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;
+`;
+
+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;
+  overflow: hidden;
+  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: 13px;
+  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: 250px;
+  width: 100%;
+  background-color: black;
+  overflow-y: auto;
+`;

+ 16 - 11
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -8,6 +8,7 @@ import { Context } from "shared/Context";
 import { ChartType, StorageType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
+import Loading from "components/Loading";
 import SelectRow from "components/values-form/SelectRow";
 import AreaChart, { MetricsData } from "./AreaChart";
 
@@ -525,17 +526,21 @@ export default class MetricsSection extends Component<PropsType, StateType> {
             />
           </RangeWrapper>
         </MetricsHeader>
-        <ParentSize>
-          {({ width, height }) => (
-            <AreaChart
-              data={this.state.data}
-              width={width}
-              height={height - 10}
-              resolution={this.state.selectedRange}
-              margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
-            />
-          )}
-        </ParentSize>
+        {this.state.data.length === 0 ? (
+          <Loading />
+        ) : (
+          <ParentSize>
+            {({ width, height }) => (
+              <AreaChart
+                data={this.state.data}
+                width={width}
+                height={height - 10}
+                resolution={this.state.selectedRange}
+                margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+              />
+            )}
+          </ParentSize>
+        )}
       </StyledMetricsSection>
     );
   }

+ 0 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -67,7 +67,6 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       )
       .then((res) => {
         let pods = res?.data?.map((pod: any) => {
-          console.log(pod?.metadata?.namespace);
           return {
             namespace: pod?.metadata?.namespace,
             name: pod?.metadata?.name,
@@ -119,7 +118,6 @@ export default class ControllerTab extends Component<PropsType, StateType> {
           c.status?.desiredNumberScheduled || 0,
         ];
       case "job":
-        console.log(c);
         return [1, 1];
     }
   };

+ 23 - 8
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 = {
@@ -36,12 +37,17 @@ export default class Logs extends Component<PropsType, StateType> {
   };
 
   renderLogs = () => {
-    let { selectedPod } = this.props;
+    let { selectedPod, podError } = this.props;
+
+    if (podError && podError != "") {
+      return <Message>{this.props.podError}</Message>;
+    }
+
     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.
@@ -50,11 +56,7 @@ export default class Logs extends Component<PropsType, StateType> {
     }
 
     if (this.state.logs.length == 0) {
-      return (
-        <Message>
-          {this.props.podError || "No logs to display from this pod."}
-        </Message>
-      );
+      return <Message>No logs to display from this pod.</Message>;
     }
     return this.state.logs.map((log, i) => {
       return <Log key={i}>{log}</Log>;
@@ -64,7 +66,7 @@ export default class Logs extends Component<PropsType, StateType> {
   setupWebsocket = () => {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
-    if (!selectedPod.metadata?.name) return;
+    if (!selectedPod?.metadata?.name) return;
     let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
     this.ws = new WebSocket(
       `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
@@ -113,6 +115,14 @@ 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 +227,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%;

+ 1 - 1
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -63,7 +63,7 @@ class Templates extends Component<PropsType, StateType> {
         <TemplateBlock
           onClick={() => {
             this.context.setCurrentCluster(cluster);
-            this.props.history.push("cluster-dashboard");
+            this.props.history.push("applications");
           }}
           key={i}
         >

+ 2 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -23,6 +23,7 @@ const tabOptions = [
   { label: "Project Overview", value: "overview" },
   { label: "Provisioner Status", value: "provisioner" },
 ];
+
 // TODO: rethink this list, should be coupled with tabOptions
 const tabOptionStrings = ["overview", "provisioner"];
 
@@ -174,7 +175,7 @@ const TopRow = styled.div`
 `;
 
 const Description = styled.div`
-  color: #ffffff;
+  color: #aaaabb;
   margin-top: 13px;
   margin-left: 2px;
   font-size: 13px;

+ 0 - 1
dashboard/src/main/home/integrations/create-integration/GCRForm.tsx

@@ -80,7 +80,6 @@ export default class GCRForm extends Component<PropsType, StateType> {
         )
       )
       .then((res) => {
-        console.log(res.data);
         this.props.closeForm();
       })
       .catch(this.catchError);

+ 10 - 4
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -117,7 +117,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           RELEASE_NAMESPACE: chartNamespace,
         }
       )
-      .then((res) => console.log(res.data))
+      .then((res) => console.log(""))
       .catch(console.log);
   };
 
@@ -153,8 +153,10 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard
+          let dst =
+            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
           setTimeout(() => {
-            this.props.history.push("cluster-dashboard");
+            this.props.history.push(dst);
           }, 500);
           window.analytics.track("Deployed Add-on", {
             name: this.props.currentTemplate.name,
@@ -286,7 +288,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard with namespace
           setTimeout(() => {
-            this.props.history.push("cluster-dashboard");
+            let dst =
+              this.props.currentTemplate.name === "job"
+                ? "jobs"
+                : "applications";
+            this.props.history.push(dst);
           }, 1000);
         });
         /*
@@ -388,6 +394,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         }
         saveValuesStatus={this.getStatus()}
         disabled={this.submitIsDisabled()}
+        renderSaveButton={true}
       >
         {(metaState: any, setMetaState: any) => {
           return this.props.form?.tabs.map((tab: any, i: number) => {
@@ -726,7 +733,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(cluster: string) => {
               this.context.setCurrentCluster(this.state.clusterMap[cluster]);
               this.updateNamespaces(this.state.clusterMap[cluster].id);
-              console.log(this.state.clusterMap[cluster]);
               this.setState({ selectedCluster: cluster });
             }}
             options={this.state.clusterOptions}

+ 0 - 1
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -321,7 +321,6 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   padding: 0 15px;
   margin-top: 13px;
   text-align: left;
-  background: red;
   float: left;
   margin-left: 0;
   justify-content: center;

+ 0 - 1
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -139,7 +139,6 @@ const DeleteButton = styled.div`
   padding: 0 15px;
   margin-top: 10px;
   text-align: left;
-  background: red;
   float: left;
   margin-left: 0;
   justify-content: center;

+ 6 - 6
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -141,9 +141,9 @@ class ClusterSection extends Component<PropsType, StateType> {
 
     if (clusters.length > 0) {
       return (
-        <ClusterSelector isSelected={this.props.isSelected}>
+        <ClusterSelector isSelected={false}>
           <LinkWrapper
-            onClick={() => this.props.history.push("cluster-dashboard")}
+            onClick={() => this.context.setCurrentModal("UpdateClusterModal")}
           >
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
@@ -212,14 +212,14 @@ const InitializeButton = styled.div`
 `;
 
 const BgAccent = styled.img`
-  height: 42px;
+  height: 30px;
   background: #819bfd;
   width: 30px;
   border-top-left-radius: 100px;
   max-width: 30px;
   border-bottom-left-radius: 100px;
   position: absolute;
-  top: 0;
+  top: 6px;
   right: -8px;
   border: none;
   outline: none;
@@ -306,13 +306,13 @@ const ClusterSelector = styled.div`
   padding-left: 7px;
   width: 100%;
   height: 42px;
-  margin: 8px auto 10px auto;
+  margin: 8px auto 0 auto;
   font-size: 14px;
   font-weight: 500;
   color: white;
   cursor: pointer;
   background: ${(props: { isSelected: boolean }) =>
-    props.isSelected ? "#ffffff11" : ""};
+    props.isSelected ? "#ffffff08" : ""};
   z-index: 1;
 
   :hover {

+ 1 - 1
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -34,7 +34,7 @@ class Drawer extends Component<PropsType, StateType> {
             active={cluster.name === currentCluster.name}
             onClick={() => {
               setCurrentCluster(cluster);
-              this.props.history.push("cluster-dashboard");
+              this.props.history.push("applications");
             }}
           >
             <ClusterIcon>

+ 90 - 9
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -3,6 +3,8 @@ import styled from "styled-components";
 import category from "assets/category.svg";
 import integrations from "assets/integrations.svg";
 import rocket from "assets/rocket.png";
+import monojob from "assets/monojob.png";
+import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
 import discordLogo from "assets/discord.svg";
 
@@ -93,6 +95,50 @@ class Sidebar extends Component<PropsType, StateType> {
     }
   };
 
+  renderClusterContent = () => {
+    let { currentView } = this.props;
+    let { currentCluster } = this.context;
+
+    if (currentCluster) {
+      return (
+        <>
+          <NavButton
+            selected={currentView === "applications"}
+            onClick={() => {
+              this.props.history.push("/applications");
+            }}
+          >
+            <BranchPad>
+              <Gutter>
+                <Rail />
+                <Circle />
+                <Rail lastTab={false} />
+              </Gutter>
+            </BranchPad>
+            <Img src={monoweb} />
+            Applications
+          </NavButton>
+          <NavButton
+            selected={currentView === "jobs"}
+            onClick={() => {
+              this.props.history.push("/jobs");
+            }}
+          >
+            <BranchPad>
+              <Gutter>
+                <Rail />
+                <Circle />
+                <Rail lastTab={true} />
+              </Gutter>
+            </BranchPad>
+            <Img src={monojob} />
+            Jobs
+          </NavButton>
+        </>
+      );
+    }
+  };
+
   renderProjectContents = () => {
     let { currentView } = this.props;
     let { currentProject, setCurrentModal } = this.context;
@@ -151,10 +197,11 @@ class Sidebar extends Component<PropsType, StateType> {
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
             currentView={currentView}
-            isSelected={currentView === "cluster-dashboard"}
+            isSelected={false}
             forceRefreshClusters={this.props.forceRefreshClusters}
             setRefreshClusters={this.props.setRefreshClusters}
           />
+          {this.renderClusterContent()}
         </>
       );
     }
@@ -203,6 +250,40 @@ Sidebar.contextType = Context;
 
 export default withRouter(Sidebar);
 
+const BranchPad = styled.div`
+  width: 20px;
+  height: 42px;
+  margin-left: 2px;
+  margin-right: 8px;
+`;
+
+const Rail = styled.div`
+  width: 2px;
+  background: ${(props: { lastTab?: boolean }) =>
+    props.lastTab ? "" : "#52545D"};
+  height: 50%;
+`;
+
+const Circle = styled.div`
+  min-width: 10px;
+  min-height: 2px;
+  margin-bottom: -2px;
+  margin-left: 8px;
+  background: #52545d;
+`;
+
+const Gutter = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 22px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+`;
+
 const Icon = styled.img`
   height: 25px;
   width: 25px;
@@ -231,11 +312,12 @@ const ProjectPlaceholder = styled.div`
 `;
 
 const NavButton = styled.div`
-  display: block;
+  display: flex;
+  align-items: center;
   position: relative;
   text-decoration: none;
   height: 42px;
-  padding: 12px 35px 1px 53px;
+  padding: 0 30px 2px 20px;
   font-size: 14px;
   font-family: "Work Sans", sans-serif;
   color: #ffffff;
@@ -266,13 +348,12 @@ const NavButton = styled.div`
 `;
 
 const Img = styled.img<{ enlarge?: boolean }>`
-  padding: 4px 4px;
-  height: ${(props) => (props.enlarge ? "27px" : "23px")};
-  width: ${(props) => (props.enlarge ? "27px" : "23px")};
+  padding: ${(props) => (props.enlarge ? "0 0 0 1px" : "4px")};
+  height: 23px;
+  width: 23px;
+  padding-top: 4px;
   border-radius: 3px;
-  position: absolute;
-  left: ${(props) => (props.enlarge ? "19px" : "20px")};
-  top: 9px;
+  margin-right: 10px;
 `;
 
 const BottomSection = styled.div`

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

@@ -425,6 +425,24 @@ const getInvites = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/projects/${pathParams.id}/invites`;
 });
 
+const getJobs = baseApi<
+  {
+    cluster_id: number;
+  },
+  { chart: string; namespace: string; release_name: string; id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/${pathParams.chart}/${pathParams.release_name}/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;
@@ -735,6 +753,8 @@ export default {
   getInfra,
   getIngress,
   getInvites,
+  getJobs,
+  getJobPods,
   getMatchingPods,
   getMetrics,
   getNamespaces,

+ 5 - 1
dashboard/src/shared/routing.tsx

@@ -6,7 +6,9 @@ export type PorterUrl =
   | "integrations"
   | "new-project"
   | "cluster-dashboard"
-  | "project-settings";
+  | "project-settings"
+  | "applications"
+  | "jobs";
 
 export const PorterUrls = [
   "dashboard",
@@ -15,6 +17,8 @@ export const PorterUrls = [
   "new-project",
   "cluster-dashboard",
   "project-settings",
+  "applications",
+  "jobs",
 ];
 
 export const setSearchParam = (

+ 72 - 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,49 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// ListJobsByLabel lists jobs in a namespace matching a label
+type Label struct {
+	Key string
+	Val string
+}
+
+func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Job, error) {
+	selectors := make([]string, 0)
+
+	for _, label := range labels {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", label.Key, label.Val))
+	}
+
+	resp, err := a.Clientset.BatchV1().Jobs(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: strings.Join(selectors, ","),
+		},
+	)
+
+	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(
@@ -159,7 +202,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)
 	}
@@ -231,6 +276,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{})
@@ -249,6 +296,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() {

+ 115 - 7
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)
 
@@ -138,6 +137,55 @@ func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleDeletePod deletes the pod given the name and namespace.
+func (app *App) HandleDeletePod(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)
+	}
+
+	err = agent.DeletePod(namespace, name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleGetIngress returns the ingress object given the name and namespace.
 func (app *App) HandleGetIngress(w http.ResponseWriter, r *http.Request) {
 
@@ -262,8 +310,66 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleDeletePod deletes the pod given the name and namespace.
-func (app *App) HandleDeletePod(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")
+	releaseName := chi.URLParam(r, "release_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)
+	}
+
+	jobs, err := agent.ListJobsByLabel(namespace, kubernetes.Label{
+		Key: "helm.sh/chart",
+		Val: chart,
+	}, kubernetes.Label{
+		Key: "meta.helm.sh/release-name",
+		Val: releaseName,
+	})
+
+	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")
@@ -300,15 +406,17 @@ func (app *App) HandleDeletePod(w http.ResponseWriter, r *http.Request) {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 
-	err = agent.DeletePod(namespace, name)
+	pods, err := agent.GetJobPods(namespace, name)
 
 	if err != nil {
-		app.handleErrorInternal(err, w)
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 	}
 
-	w.WriteHeader(http.StatusOK)
-	return
+	if err := json.NewEncoder(w).Encode(pods); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
 }
 
 // HandleStreamControllerStatus test calls

+ 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}/{release_name}/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}",

+ 2 - 1
services/deploy_init_container/start.sh

@@ -5,7 +5,8 @@ cat << EOF
 👋 Hello from Porter!
 -------------------------------------------------------------------
 -------------------------------------------------------------------
-Your application is being deployed.
+Your application is currently being built and will be ready soon.
+This page will disappear once your application is live.
 To view build logs, navigate to your connected GitHub repo and     
 select the Actions tab.
 -------------------------------------------------------------------