Explorar el Código

jobs fe last mile + slight expanded chart state refactor

jusrhee hace 5 años
padre
commit
7ac99571eb
Se han modificado 33 ficheros con 1584 adiciones y 185 borrados
  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 = () => {
   updateContents = () => {
     let { currentProject } = this.context;
     let { currentProject } = this.context;
     let { actionConfig, branch } = this.props;
     let { actionConfig, branch } = this.props;
-    console.log(this.state.currentDir);
+
     // Get branch contents
     // Get branch contents
     api
     api
       .getBranchContents(
       .getBranchContents(

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

@@ -6,6 +6,7 @@ type PropsType = {
   values: string[];
   values: string[];
   setValues: (x: string[]) => void;
   setValues: (x: string[]) => void;
   width?: string;
   width?: string;
+  disabled?: boolean;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -19,6 +20,22 @@ export default class InputArray extends Component<PropsType, StateType> {
     return arr;
     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[]) => {
   renderInputList = (values: string[]) => {
     return (
     return (
       <>
       <>
@@ -34,16 +51,9 @@ export default class InputArray extends Component<PropsType, StateType> {
                   v[i] = e.target.value;
                   v[i] = e.target.value;
                   this.props.setValues(v);
                   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>
             </InputWrapper>
           );
           );
         })}
         })}

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

@@ -6,6 +6,7 @@ type PropsType = {
   values: any;
   values: any;
   setValues: (x: any) => void;
   setValues: (x: any) => void;
   width?: string;
   width?: string;
+  disabled?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -33,6 +34,24 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     return obj;
     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 = () => {
   renderInputList = () => {
     return (
     return (
       <>
       <>
@@ -50,6 +69,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                   this.props.setValues(obj);
                 }}
                 }}
+                disabled={this.props.disabled}
               />
               />
               <Spacer />
               <Spacer />
               <Input
               <Input
@@ -63,18 +83,9 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                   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>
             </InputWrapper>
           );
           );
         })}
         })}
@@ -87,14 +98,18 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
       <StyledInputArray>
       <StyledInputArray>
         <Label>{this.props.label}</Label>
         <Label>{this.props.label}</Label>
         {this.state.values.length === 0 ? <></> : this.renderInputList()}
         {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>
       </StyledInputArray>
     );
     );
   }
   }

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

@@ -20,6 +20,7 @@ type PropsType = {
   metaState?: any;
   metaState?: any;
   setMetaState?: any;
   setMetaState?: any;
   handleEnvChange?: (x: any) => void;
   handleEnvChange?: (x: any) => void;
+  disabled?: boolean;
 };
 };
 
 
 type StateType = any;
 type StateType = any;
@@ -90,6 +91,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 }
                 }
               }}
               }}
               label={item.label}
               label={item.label}
+              disabled={this.props.disabled}
             />
             />
           );
           );
         case "array-input":
         case "array-input":
@@ -101,6 +103,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 this.props.setMetaState({ [key]: x });
                 this.props.setMetaState({ [key]: x });
               }}
               }}
               label={item.label}
               label={item.label}
+              disabled={this.props.disabled}
             />
             />
           );
           );
         case "string-input":
         case "string-input":
@@ -118,6 +121,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               }}
               label={item.label}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
             />
           );
           );
         case "string-input-password":
         case "string-input-password":
@@ -128,7 +132,6 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               type="password"
               type="password"
               value={this.getInputValue(item)}
               value={this.getInputValue(item)}
               setValue={(x: string) => {
               setValue={(x: string) => {
-                console.log("string input", x);
                 if (item.settings && item.settings.unit && x !== "") {
                 if (item.settings && item.settings.unit && x !== "") {
                   x = x + item.settings.unit;
                   x = x + item.settings.unit;
                 }
                 }
@@ -136,6 +139,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               }}
               label={item.label}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
             />
           );
           );
         case "number-input":
         case "number-input":
@@ -161,6 +165,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               }}
               label={item.label}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
             />
           );
           );
         case "select":
         case "select":
@@ -206,6 +211,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               }}
               label={item.label}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
             />
           );
           );
         case "base-64-password":
         case "base-64-password":
@@ -223,6 +229,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               }}
               }}
               label={item.label}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
               unit={item.settings ? item.settings.unit : null}
+              disabled={this.props.disabled}
             />
             />
           );
           );
         default:
         default:

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

@@ -13,6 +13,7 @@ type PropsType = {
   saveValuesStatus?: string | null;
   saveValuesStatus?: string | null;
   isInModal?: boolean;
   isInModal?: boolean;
   currentTab?: string; // For resetting state when flipping b/w tabs in ExpandedChart
   currentTab?: string; // For resetting state when flipping b/w tabs in ExpandedChart
+  renderSaveButton?: boolean;
 };
 };
 
 
 type StateType = any;
 type StateType = any;
@@ -110,24 +111,26 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
   };
   };
 
 
   renderButton = () => {
   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
         <ClusterDashboard
           currentCluster={currentCluster}
           currentCluster={currentCluster}
           setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
           setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
+          currentView={this.props.currentRoute}
           // setCurrentView={(x: string) => this.setState({ currentView: x })}
           // setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
         />
       </DashboardWrapper>
       </DashboardWrapper>
@@ -288,8 +289,13 @@ class Home extends Component<PropsType, StateType> {
 
 
   renderContents = () => {
   renderContents = () => {
     let currentView = this.props.currentRoute;
     let currentView = this.props.currentRoute;
+
     if (this.context.currentProject && currentView !== "new-project") {
     if (this.context.currentProject && currentView !== "new-project") {
-      if (currentView === "cluster-dashboard") {
+      if (
+        currentView === "cluster-dashboard" ||
+        currentView === "applications" ||
+        currentView === "jobs"
+      ) {
         return this.renderDashboard();
         return this.renderDashboard();
       } else if (currentView === "dashboard") {
       } else if (currentView === "dashboard") {
         return (
         return (

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

@@ -1,14 +1,18 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import gradient from "assets/gradient.jpg";
 import gradient from "assets/gradient.jpg";
+import monojob from "assets/monojob.png";
+import monoweb from "assets/monoweb.png";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ChartType, ClusterType } from "shared/types";
 import { ChartType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
 
 
 import ChartList from "./chart/ChartList";
 import ChartList from "./chart/ChartList";
 import NamespaceSelector from "./NamespaceSelector";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
+import ExpandedJobChart from "./expanded-chart/ExpandedJobChart";
 import { RouteComponentProps, withRouter } from "react-router";
 import { RouteComponentProps, withRouter } from "react-router";
 
 
 import api from "shared/api";
 import api from "shared/api";
@@ -16,6 +20,7 @@ import api from "shared/api";
 type PropsType = RouteComponentProps & {
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
   currentCluster: ClusterType;
   setSidebar: (x: boolean) => void;
   setSidebar: (x: boolean) => void;
+  currentView: PorterUrl;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -55,7 +60,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
   }
   }
 
 
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
-    localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
     // Reset namespace filter and close expanded chart on cluster change
     if (prevProps.currentCluster !== this.props.currentCluster) {
     if (prevProps.currentCluster !== this.props.currentCluster) {
       this.setState({
       this.setState({
@@ -66,40 +70,43 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         currentChart: null,
         currentChart: null,
       });
       });
     }
     }
+
+    if (prevProps.currentView !== this.props.currentView) {
+      this.setState({
+        namespace: "default",
+        sortType: "Newest",
+        currentChart: null,
+      });
+    }
   }
   }
 
 
   renderDashboardIcon = () => {
   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 = () => {
   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 (
       return (
         <ExpandedChart
         <ExpandedChart
           namespace={this.state.namespace}
           namespace={this.state.namespace}
           currentCluster={this.props.currentCluster}
           currentCluster={this.props.currentCluster}
           currentChart={this.state.currentChart}
           currentChart={this.state.currentChart}
-          setCurrentChart={(x: ChartType | null) =>
-            this.setState({ currentChart: x })
-          }
+          closeChart={() => this.setState({ currentChart: null })}
           isMetricsInstalled={this.state.isMetricsInstalled}
           isMetricsInstalled={this.state.isMetricsInstalled}
           setSidebar={setSidebar}
           setSidebar={setSidebar}
         />
         />
@@ -110,13 +117,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
       <div>
       <div>
         <TitleSection>
         <TitleSection>
           {this.renderDashboardIcon()}
           {this.renderDashboardIcon()}
-          <Title>{currentCluster.name}</Title>
-          <i
-            className="material-icons"
-            onClick={() => this.context.setCurrentModal("UpdateClusterModal")}
-          >
-            more_vert
-          </i>
+          <Title>{currentView}</Title>
         </TitleSection>
         </TitleSection>
 
 
         <InfoSection>
         <InfoSection>
@@ -126,7 +127,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
             </InfoLabel>
             </InfoLabel>
           </TopRow>
           </TopRow>
           <Description>
           <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>
           </Description>
         </InfoSection>
         </InfoSection>
 
 
@@ -149,6 +152,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         </ControlRow>
         </ControlRow>
 
 
         <ChartList
         <ChartList
+          currentView={currentView}
           currentCluster={currentCluster}
           currentCluster={currentCluster}
           namespace={this.state.namespace}
           namespace={this.state.namespace}
           sortType={this.state.sortType}
           sortType={this.state.sortType}
@@ -183,7 +187,7 @@ const TopRow = styled.div`
 `;
 `;
 
 
 const Description = styled.div`
 const Description = styled.div`
-  color: #ffffff;
+  color: #aaaabb;
   margin-top: 13px;
   margin-top: 13px;
   margin-left: 2px;
   margin-left: 2px;
   font-size: 13px;
   font-size: 13px;
@@ -311,6 +315,10 @@ const DashboardIcon = styled.div`
   }
   }
 `;
 `;
 
 
+const Img = styled.img`
+  width: 30px;
+`;
+
 const Title = styled.div`
 const Title = styled.div`
   font-size: 20px;
   font-size: 20px;
   font-weight: 500;
   font-weight: 500;
@@ -320,6 +328,7 @@ const Title = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
+  text-transform: capitalize;
 `;
 `;
 
 
 const TitleSection = styled.div`
 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 { Context } from "shared/Context";
 import api from "shared/api";
 import api from "shared/api";
 import { ChartType, StorageType, ClusterType } from "shared/types";
 import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
 
 
 import Chart from "./Chart";
 import Chart from "./Chart";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
@@ -13,6 +14,7 @@ type PropsType = {
   namespace: string;
   namespace: string;
   sortType: string;
   sortType: string;
   setCurrentChart: (c: ChartType) => void;
   setCurrentChart: (c: ChartType) => void;
+  currentView: PorterUrl;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -63,6 +65,19 @@ export default class ChartList extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         let charts = res.data || [];
         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") {
         if (this.props.sortType == "Newest") {
           charts.sort((a: any, b: any) =>
           charts.sort((a: any, b: any) =>
             Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
             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 (
     if (
       prevProps.currentCluster !== this.props.currentCluster ||
       prevProps.currentCluster !== this.props.currentCluster ||
       prevProps.namespace !== this.props.namespace ||
       prevProps.namespace !== this.props.namespace ||
-      prevProps.sortType !== this.props.sortType
+      prevProps.sortType !== this.props.sortType ||
+      prevProps.currentView !== this.props.currentView
     ) {
     ) {
       this.updateCharts(this.getControllers);
       this.updateCharts(this.getControllers);
     }
     }
@@ -247,8 +263,9 @@ export default class ChartList extends Component<PropsType, StateType> {
     } else if (charts.length === 0) {
     } else if (charts.length === 0) {
       return (
       return (
         <Placeholder>
         <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>
         </Placeholder>
       );
       );
     }
     }

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

@@ -31,12 +31,13 @@ type PropsType = {
   namespace: string;
   namespace: string;
   currentChart: ChartType;
   currentChart: ChartType;
   currentCluster: ClusterType;
   currentCluster: ClusterType;
-  setCurrentChart: (x: ChartType | null) => void;
+  closeChart: () => void;
   setSidebar: (x: boolean) => void;
   setSidebar: (x: boolean) => void;
   isMetricsInstalled: boolean;
   isMetricsInstalled: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
+  currentChart: ChartType;
   loading: boolean;
   loading: boolean;
   showRevisions: boolean;
   showRevisions: boolean;
   components: ResourceType[];
   components: ResourceType[];
@@ -57,6 +58,7 @@ type StateType = {
 
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
 export default class ExpandedChart extends Component<PropsType, StateType> {
   state = {
   state = {
+    currentChart: this.props.currentChart,
     loading: true,
     loading: true,
     showRevisions: false,
     showRevisions: false,
     components: [] as ResourceType[],
     components: [] as ResourceType[],
@@ -78,7 +80,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   // Retrieve full chart data (includes form and values)
   // Retrieve full chart data (includes form and values)
   getChartData = (chart: ChartType) => {
   getChartData = (chart: ChartType) => {
     let { currentProject } = this.context;
     let { currentProject } = this.context;
-    let { currentCluster, currentChart, setCurrentChart } = this.props;
+    let { currentCluster, currentChart } = this.props;
 
 
     this.setState({ loading: true });
     this.setState({ loading: true });
     api
     api
@@ -96,8 +98,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
         }
       )
       )
       .then((res) => {
       .then((res) => {
-        setCurrentChart(res.data);
-        this.setState({ loading: false });
+        this.setState({ currentChart: res.data, loading: false });
       })
       })
       .catch(console.log);
       .catch(console.log);
   };
   };
@@ -125,7 +126,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           }
           }
         )
         )
         .then((res) => {
         .then((res) => {
-          res.data.forEach(async (c: any) => {
+          res.data?.forEach(async (c: any) => {
             await new Promise((nextController: (res?: any) => void) => {
             await new Promise((nextController: (res?: any) => void) => {
               c.metadata.kind = c.kind;
               c.metadata.kind = c.kind;
               this.setState(
               this.setState(
@@ -159,17 +160,20 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
     ws.onmessage = (evt: MessageEvent) => {
     ws.onmessage = (evt: MessageEvent) => {
       let event = JSON.parse(evt.data);
       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 = () => {
     ws.onclose = () => {
@@ -193,7 +197,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
   updateResources = () => {
   updateResources = () => {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
 
 
     api
     api
       .getChartComponents(
       .getChartComponents(
@@ -218,7 +222,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       .catch(console.log);
       .catch(console.log);
   };
   };
 
 
-  refreshChart = () => this.getChartData(this.props.currentChart);
+  refreshChart = () => this.getChartData(this.state.currentChart);
 
 
   onSubmit = (rawValues: any) => {
   onSubmit = (rawValues: any) => {
     let { currentProject, currentCluster, setCurrentError } = this.context;
     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
     // Weave in preexisting values and convert to yaml
     let valuesYaml = yaml.dump({
     let valuesYaml = yaml.dump({
-      ...(this.props.currentChart.config as Object),
+      ...(this.state.currentChart.config as Object),
       ...values,
       ...values,
     });
     });
 
 
@@ -242,13 +246,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       .upgradeChartValues(
       .upgradeChartValues(
         "<token>",
         "<token>",
         {
         {
-          namespace: this.props.currentChart.namespace,
+          namespace: this.state.currentChart.namespace,
           storage: StorageType.Secret,
           storage: StorageType.Secret,
           values: valuesYaml,
           values: valuesYaml,
         },
         },
         {
         {
           id: currentProject.id,
           id: currentProject.id,
-          name: this.props.currentChart.name,
+          name: this.state.currentChart.name,
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
         }
         }
       )
       )
@@ -259,14 +263,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         });
         });
 
 
         window.analytics.track("Chart Upgraded", {
         window.analytics.track("Chart Upgraded", {
-          chart: this.props.currentChart.name,
+          chart: this.state.currentChart.name,
           values: valuesYaml,
           values: valuesYaml,
         });
         });
       })
       })
       .catch((err) => {
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
         this.setState({ saveValuesStatus: "error" });
         window.analytics.track("Failed to Upgrade Chart", {
         window.analytics.track("Failed to Upgrade Chart", {
-          chart: this.props.currentChart.name,
+          chart: this.state.currentChart.name,
           values: valuesYaml,
           values: valuesYaml,
           error: err,
           error: err,
         });
         });
@@ -282,22 +286,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       saveValuesStatus,
       saveValuesStatus,
       tabOptions,
       tabOptions,
     } = this.state;
     } = this.state;
-    let { currentChart, setSidebar } = this.props;
+    let { setSidebar } = this.props;
+    let { currentChart } = this.state;
     let chart = currentChart;
     let chart = currentChart;
 
 
     switch (currentTab) {
     switch (currentTab) {
       case "metrics":
       case "metrics":
         return <MetricsSection currentChart={chart} />;
         return <MetricsSection currentChart={chart} />;
       case "status":
       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} />;
         return <StatusSection currentChart={chart} />;
       case "settings":
       case "settings":
         return (
         return (
@@ -341,6 +337,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               saveValuesStatus={this.state.saveValuesStatus}
               saveValuesStatus={this.state.saveValuesStatus}
               isInModal={true}
               isInModal={true}
               currentTab={currentTab}
               currentTab={currentTab}
+              renderSaveButton={true}
             >
             >
               {(metaState: any, setMetaState: any) => {
               {(metaState: any, setMetaState: any) => {
                 return tabOptions.map((tab: any, i: number) => {
                 return tabOptions.map((tab: any, i: number) => {
@@ -364,7 +361,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
   };
 
 
   updateTabs() {
   updateTabs() {
-    let formData = this.props.currentChart.form;
+    let formData = this.state.currentChart.form;
     let tabOptions = [] as any[];
     let tabOptions = [] as any[];
 
 
     // Generate form tabs if form.yaml exists
     // Generate form tabs if form.yaml exists
@@ -430,7 +427,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
   };
 
 
   renderIcon = () => {
   renderIcon = () => {
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
 
 
     if (
     if (
       currentChart.chart.metadata.icon &&
       currentChart.chart.metadata.icon &&
@@ -496,7 +493,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
   componentDidMount() {
   componentDidMount() {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
 
 
     window.analytics.track("Opened Chart", {
     window.analytics.track("Opened Chart", {
       chart: currentChart.name,
       chart: currentChart.name,
@@ -541,7 +538,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               {
               {
                 id: currentProject.id,
                 id: currentProject.id,
                 name: ingressName,
                 name: ingressName,
-                namespace: `${this.props.currentChart.namespace}`,
+                namespace: `${this.state.currentChart.namespace}`,
               }
               }
             )
             )
             .then((res) => {
             .then((res) => {
@@ -575,8 +572,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   }
   }
 
 
   componentWillUnmount() {
   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();
         ws.close();
       });
       });
     }
     }
@@ -594,7 +591,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       let serviceName = null as string;
       let serviceName = null as string;
       let serviceNamespace = null as string;
       let serviceNamespace = null as string;
 
 
-      this.state.components.forEach((c: any) => {
+      this.state.components?.forEach((c: any) => {
         if (c.Kind == "Service") {
         if (c.Kind == "Service") {
           serviceName = c.Name;
           serviceName = c.Name;
           serviceNamespace = c.Namespace;
           serviceNamespace = c.Namespace;
@@ -612,7 +609,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
   handleUninstallChart = () => {
   handleUninstallChart = () => {
     let { currentProject, currentCluster } = this.context;
     let { currentProject, currentCluster } = this.context;
-    let { currentChart } = this.props;
+    let { currentChart } = this.state;
     this.setState({ deleting: true });
     this.setState({ deleting: true });
     api
     api
       .uninstallTemplate(
       .uninstallTemplate(
@@ -628,7 +625,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         this.setState({ showDeleteOverlay: false });
         this.setState({ showDeleteOverlay: false });
-        this.props.setCurrentChart(null);
+        this.props.closeChart();
       })
       })
       .catch(console.log);
       .catch(console.log);
   };
   };
@@ -644,13 +641,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
   };
 
 
   render() {
   render() {
-    let { currentChart, setCurrentChart } = this.props;
+    let { closeChart } = this.props;
+    let { currentChart } = this.state;
     let chart = currentChart;
     let chart = currentChart;
     let status = this.getChartStatus(chart.info.status);
     let status = this.getChartStatus(chart.info.status);
 
 
     return (
     return (
       <>
       <>
-        <CloseOverlay onClick={() => setCurrentChart(null)} />
+        <CloseOverlay onClick={closeChart} />
         <StyledExpandedChart>
         <StyledExpandedChart>
           <ConfirmOverlay
           <ConfirmOverlay
             show={this.state.showDeleteOverlay}
             show={this.state.showDeleteOverlay}
@@ -686,7 +684,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               </TagWrapper>
               </TagWrapper>
             </TitleSection>
             </TitleSection>
 
 
-            <CloseButton onClick={() => setCurrentChart(null)}>
+            <CloseButton onClick={closeChart}>
               <CloseButtonImg src={close} />
               <CloseButtonImg src={close} />
             </CloseButton>
             </CloseButton>
 
 
@@ -944,7 +942,6 @@ const CloseButtonImg = styled.img`
 const StyledExpandedChart = styled.div`
 const StyledExpandedChart = styled.div`
   width: calc(100% - 50px);
   width: calc(100% - 50px);
   height: calc(100% - 50px);
   height: calc(100% - 50px);
-  background: red;
   z-index: 0;
   z-index: 0;
   position: absolute;
   position: absolute;
   top: 25px;
   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 }
         { id: currentProject.id, name: this.props.currentChart.name }
       )
       )
       .then((res) => {
       .then((res) => {
-        console.log(res.data);
         this.setState({
         this.setState({
           action: res.data.git_action_config,
           action: res.data.git_action_config,
           webhookToken: res.data.webhook_token,
           webhookToken: res.data.webhook_token,
@@ -287,7 +286,7 @@ const Wrapper = styled.div`
 
 
 const StyledSettingsSection = styled.div`
 const StyledSettingsSection = styled.div`
   width: 100%;
   width: 100%;
-  height: calc(100% - 60px);
+  height: calc(100% - 65px);
   background: #ffffff11;
   background: #ffffff11;
   padding: 0 35px;
   padding: 0 35px;
   padding-bottom: 50px;
   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 { ChartType, StorageType } from "shared/types";
 
 
 import TabSelector from "components/TabSelector";
 import TabSelector from "components/TabSelector";
+import Loading from "components/Loading";
 import SelectRow from "components/values-form/SelectRow";
 import SelectRow from "components/values-form/SelectRow";
 import AreaChart, { MetricsData } from "./AreaChart";
 import AreaChart, { MetricsData } from "./AreaChart";
 
 
@@ -525,17 +526,21 @@ export default class MetricsSection extends Component<PropsType, StateType> {
             />
             />
           </RangeWrapper>
           </RangeWrapper>
         </MetricsHeader>
         </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>
       </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) => {
       .then((res) => {
         let pods = res?.data?.map((pod: any) => {
         let pods = res?.data?.map((pod: any) => {
-          console.log(pod?.metadata?.namespace);
           return {
           return {
             namespace: pod?.metadata?.namespace,
             namespace: pod?.metadata?.namespace,
             name: pod?.metadata?.name,
             name: pod?.metadata?.name,
@@ -119,7 +118,6 @@ export default class ControllerTab extends Component<PropsType, StateType> {
           c.status?.desiredNumberScheduled || 0,
           c.status?.desiredNumberScheduled || 0,
         ];
         ];
       case "job":
       case "job":
-        console.log(c);
         return [1, 1];
         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 = {
 type PropsType = {
   selectedPod: any;
   selectedPod: any;
   podError: string;
   podError: string;
+  rawText?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -36,12 +37,17 @@ export default class Logs extends Component<PropsType, StateType> {
   };
   };
 
 
   renderLogs = () => {
   renderLogs = () => {
-    let { selectedPod } = this.props;
+    let { selectedPod, podError } = this.props;
+
+    if (podError && podError != "") {
+      return <Message>{this.props.podError}</Message>;
+    }
+
     if (!selectedPod?.metadata?.name) {
     if (!selectedPod?.metadata?.name) {
       return <Message>Please select a pod to view its logs.</Message>;
       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 (
       return (
         <Message>
         <Message>
           ⌛ This job has been completed. You can now delete this job.
           ⌛ 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) {
     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 this.state.logs.map((log, i) => {
       return <Log key={i}>{log}</Log>;
       return <Log key={i}>{log}</Log>;
@@ -64,7 +66,7 @@ export default class Logs extends Component<PropsType, StateType> {
   setupWebsocket = () => {
   setupWebsocket = () => {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     let { selectedPod } = this.props;
-    if (!selectedPod.metadata?.name) return;
+    if (!selectedPod?.metadata?.name) return;
     let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
     let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
     this.ws = new WebSocket(
     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}`
       `${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() {
   render() {
+    if (this.props.rawText) {
+      return (
+        <LogStreamAlt>
+          <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
+        </LogStreamAlt>
+      );
+    }
+
     return (
     return (
       <LogStream>
       <LogStream>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
         <Wrapper ref={this.parentRef}>{this.renderLogs()}</Wrapper>
@@ -217,6 +227,11 @@ const LogStream = styled.div`
   overflow-wrap: break-word;
   overflow-wrap: break-word;
 `;
 `;
 
 
+const LogStreamAlt = styled(LogStream)`
+  width: 100%;
+  max-width: 100%;
+`;
+
 const Message = styled.div`
 const Message = styled.div`
   display: flex;
   display: flex;
   height: 100%;
   height: 100%;

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

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

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

@@ -23,6 +23,7 @@ const tabOptions = [
   { label: "Project Overview", value: "overview" },
   { label: "Project Overview", value: "overview" },
   { label: "Provisioner Status", value: "provisioner" },
   { label: "Provisioner Status", value: "provisioner" },
 ];
 ];
+
 // TODO: rethink this list, should be coupled with tabOptions
 // TODO: rethink this list, should be coupled with tabOptions
 const tabOptionStrings = ["overview", "provisioner"];
 const tabOptionStrings = ["overview", "provisioner"];
 
 
@@ -174,7 +175,7 @@ const TopRow = styled.div`
 `;
 `;
 
 
 const Description = styled.div`
 const Description = styled.div`
-  color: #ffffff;
+  color: #aaaabb;
   margin-top: 13px;
   margin-top: 13px;
   margin-left: 2px;
   margin-left: 2px;
   font-size: 13px;
   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) => {
       .then((res) => {
-        console.log(res.data);
         this.props.closeForm();
         this.props.closeForm();
       })
       })
       .catch(this.catchError);
       .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,
           RELEASE_NAMESPACE: chartNamespace,
         }
         }
       )
       )
-      .then((res) => console.log(res.data))
+      .then((res) => console.log(""))
       .catch(console.log);
       .catch(console.log);
   };
   };
 
 
@@ -153,8 +153,10 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         // this.props.setCurrentView('cluster-dashboard');
         // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: "successful" }, () => {
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard
           // redirect to dashboard
+          let dst =
+            this.props.currentTemplate.name === "job" ? "jobs" : "applications";
           setTimeout(() => {
           setTimeout(() => {
-            this.props.history.push("cluster-dashboard");
+            this.props.history.push(dst);
           }, 500);
           }, 500);
           window.analytics.track("Deployed Add-on", {
           window.analytics.track("Deployed Add-on", {
             name: this.props.currentTemplate.name,
             name: this.props.currentTemplate.name,
@@ -286,7 +288,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         this.setState({ saveValuesStatus: "successful" }, () => {
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard with namespace
           // redirect to dashboard with namespace
           setTimeout(() => {
           setTimeout(() => {
-            this.props.history.push("cluster-dashboard");
+            let dst =
+              this.props.currentTemplate.name === "job"
+                ? "jobs"
+                : "applications";
+            this.props.history.push(dst);
           }, 1000);
           }, 1000);
         });
         });
         /*
         /*
@@ -388,6 +394,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         }
         }
         saveValuesStatus={this.getStatus()}
         saveValuesStatus={this.getStatus()}
         disabled={this.submitIsDisabled()}
         disabled={this.submitIsDisabled()}
+        renderSaveButton={true}
       >
       >
         {(metaState: any, setMetaState: any) => {
         {(metaState: any, setMetaState: any) => {
           return this.props.form?.tabs.map((tab: any, i: number) => {
           return this.props.form?.tabs.map((tab: any, i: number) => {
@@ -726,7 +733,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(cluster: string) => {
             setActiveValue={(cluster: string) => {
               this.context.setCurrentCluster(this.state.clusterMap[cluster]);
               this.context.setCurrentCluster(this.state.clusterMap[cluster]);
               this.updateNamespaces(this.state.clusterMap[cluster].id);
               this.updateNamespaces(this.state.clusterMap[cluster].id);
-              console.log(this.state.clusterMap[cluster]);
               this.setState({ selectedCluster: cluster });
               this.setState({ selectedCluster: cluster });
             }}
             }}
             options={this.state.clusterOptions}
             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;
   padding: 0 15px;
   margin-top: 13px;
   margin-top: 13px;
   text-align: left;
   text-align: left;
-  background: red;
   float: left;
   float: left;
   margin-left: 0;
   margin-left: 0;
   justify-content: center;
   justify-content: center;

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

@@ -139,7 +139,6 @@ const DeleteButton = styled.div`
   padding: 0 15px;
   padding: 0 15px;
   margin-top: 10px;
   margin-top: 10px;
   text-align: left;
   text-align: left;
-  background: red;
   float: left;
   float: left;
   margin-left: 0;
   margin-left: 0;
   justify-content: center;
   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) {
     if (clusters.length > 0) {
       return (
       return (
-        <ClusterSelector isSelected={this.props.isSelected}>
+        <ClusterSelector isSelected={false}>
           <LinkWrapper
           <LinkWrapper
-            onClick={() => this.props.history.push("cluster-dashboard")}
+            onClick={() => this.context.setCurrentModal("UpdateClusterModal")}
           >
           >
             <ClusterIcon>
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
               <i className="material-icons">device_hub</i>
@@ -212,14 +212,14 @@ const InitializeButton = styled.div`
 `;
 `;
 
 
 const BgAccent = styled.img`
 const BgAccent = styled.img`
-  height: 42px;
+  height: 30px;
   background: #819bfd;
   background: #819bfd;
   width: 30px;
   width: 30px;
   border-top-left-radius: 100px;
   border-top-left-radius: 100px;
   max-width: 30px;
   max-width: 30px;
   border-bottom-left-radius: 100px;
   border-bottom-left-radius: 100px;
   position: absolute;
   position: absolute;
-  top: 0;
+  top: 6px;
   right: -8px;
   right: -8px;
   border: none;
   border: none;
   outline: none;
   outline: none;
@@ -306,13 +306,13 @@ const ClusterSelector = styled.div`
   padding-left: 7px;
   padding-left: 7px;
   width: 100%;
   width: 100%;
   height: 42px;
   height: 42px;
-  margin: 8px auto 10px auto;
+  margin: 8px auto 0 auto;
   font-size: 14px;
   font-size: 14px;
   font-weight: 500;
   font-weight: 500;
   color: white;
   color: white;
   cursor: pointer;
   cursor: pointer;
   background: ${(props: { isSelected: boolean }) =>
   background: ${(props: { isSelected: boolean }) =>
-    props.isSelected ? "#ffffff11" : ""};
+    props.isSelected ? "#ffffff08" : ""};
   z-index: 1;
   z-index: 1;
 
 
   :hover {
   :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}
             active={cluster.name === currentCluster.name}
             onClick={() => {
             onClick={() => {
               setCurrentCluster(cluster);
               setCurrentCluster(cluster);
-              this.props.history.push("cluster-dashboard");
+              this.props.history.push("applications");
             }}
             }}
           >
           >
             <ClusterIcon>
             <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 category from "assets/category.svg";
 import integrations from "assets/integrations.svg";
 import integrations from "assets/integrations.svg";
 import rocket from "assets/rocket.png";
 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 settings from "assets/settings.svg";
 import discordLogo from "assets/discord.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 = () => {
   renderProjectContents = () => {
     let { currentView } = this.props;
     let { currentView } = this.props;
     let { currentProject, setCurrentModal } = this.context;
     let { currentProject, setCurrentModal } = this.context;
@@ -151,10 +197,11 @@ class Sidebar extends Component<PropsType, StateType> {
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
             setWelcome={this.props.setWelcome}
             currentView={currentView}
             currentView={currentView}
-            isSelected={currentView === "cluster-dashboard"}
+            isSelected={false}
             forceRefreshClusters={this.props.forceRefreshClusters}
             forceRefreshClusters={this.props.forceRefreshClusters}
             setRefreshClusters={this.props.setRefreshClusters}
             setRefreshClusters={this.props.setRefreshClusters}
           />
           />
+          {this.renderClusterContent()}
         </>
         </>
       );
       );
     }
     }
@@ -203,6 +250,40 @@ Sidebar.contextType = Context;
 
 
 export default withRouter(Sidebar);
 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`
 const Icon = styled.img`
   height: 25px;
   height: 25px;
   width: 25px;
   width: 25px;
@@ -231,11 +312,12 @@ const ProjectPlaceholder = styled.div`
 `;
 `;
 
 
 const NavButton = styled.div`
 const NavButton = styled.div`
-  display: block;
+  display: flex;
+  align-items: center;
   position: relative;
   position: relative;
   text-decoration: none;
   text-decoration: none;
   height: 42px;
   height: 42px;
-  padding: 12px 35px 1px 53px;
+  padding: 0 30px 2px 20px;
   font-size: 14px;
   font-size: 14px;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
   color: #ffffff;
   color: #ffffff;
@@ -266,13 +348,12 @@ const NavButton = styled.div`
 `;
 `;
 
 
 const Img = styled.img<{ enlarge?: boolean }>`
 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;
   border-radius: 3px;
-  position: absolute;
-  left: ${(props) => (props.enlarge ? "19px" : "20px")};
-  top: 9px;
+  margin-right: 10px;
 `;
 `;
 
 
 const BottomSection = styled.div`
 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`;
   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<
 const getMatchingPods = baseApi<
   {
   {
     cluster_id: number;
     cluster_id: number;
@@ -735,6 +753,8 @@ export default {
   getInfra,
   getInfra,
   getIngress,
   getIngress,
   getInvites,
   getInvites,
+  getJobs,
+  getJobPods,
   getMatchingPods,
   getMatchingPods,
   getMetrics,
   getMetrics,
   getNamespaces,
   getNamespaces,

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

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

+ 72 - 1
internal/kubernetes/agent.go

@@ -49,7 +49,7 @@ type Agent struct {
 }
 }
 
 
 type Message struct {
 type Message struct {
-	EventType string
+	EventType string `json:"event_type"`
 	Object    interface{}
 	Object    interface{}
 	Kind      string
 	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
 // GetIngress gets ingress given the name and namespace
 func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
 func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
 	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
 	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
@@ -159,7 +202,9 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 		TailLines: &tails,
 		TailLines: &tails,
 	}
 	}
 	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
 	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
 	podLogs, err := req.Stream(context.TODO())
 	podLogs, err := req.Stream(context.TODO())
+
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("Cannot open log stream for pod %s", name)
 		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()
 		informer = factory.Apps().V1().ReplicaSets().Informer()
 	case "daemonset":
 	case "daemonset":
 		informer = factory.Apps().V1().DaemonSets().Informer()
 		informer = factory.Apps().V1().DaemonSets().Informer()
+	case "job":
+		informer = factory.Batch().V1().Jobs().Informer()
 	}
 	}
 
 
 	stopper := make(chan struct{})
 	stopper := make(chan struct{})
@@ -249,6 +296,30 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 				return
 				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() {
 	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
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // TODO: Refactor repeated calls.
 // TODO: Refactor repeated calls.
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
-
 	// get session to retrieve correct kubeconfig
 	// get session to retrieve correct kubeconfig
 	_, err := app.Store.Get(r, app.ServerConf.CookieName)
 	_, 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.
 // HandleGetIngress returns the ingress object given the name and namespace.
 func (app *App) HandleGetIngress(w http.ResponseWriter, r *http.Request) {
 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
 	// get path parameters
 	namespace := chi.URLParam(r, "namespace")
 	namespace := chi.URLParam(r, "namespace")
 	name := chi.URLParam(r, "name")
 	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)
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 	}
 
 
-	err = agent.DeletePod(namespace, name)
+	pods, err := agent.GetJobPods(namespace, name)
 
 
 	if err != nil {
 	if err != nil {
-		app.handleErrorInternal(err, w)
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 		return
 	}
 	}
 
 
-	w.WriteHeader(http.StatusOK)
-	return
+	if err := json.NewEncoder(w).Encode(pods); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
 }
 }
 
 
 // HandleStreamControllerStatus test calls
 // 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(
 		r.Method(
 			"GET",
 			"GET",
 			"/projects/{project_id}/k8s/{namespace}/ingress/{name}",
 			"/projects/{project_id}/k8s/{namespace}/ingress/{name}",

+ 2 - 1
services/deploy_init_container/start.sh

@@ -5,7 +5,8 @@ cat << EOF
 👋 Hello from Porter!
 👋 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     
 To view build logs, navigate to your connected GitHub repo and     
 select the Actions tab.
 select the Actions tab.
 -------------------------------------------------------------------
 -------------------------------------------------------------------