Просмотр исходного кода

Merge pull request #1706 from porter-dev/nico/jobs-overhaul-job-run-list

[feat] Add a table with all the job runs across all job charts
abelanger5 4 лет назад
Родитель
Сommit
6a856b7b7c

+ 56 - 0
api/server/handlers/namespace/stream_job_runs.go

@@ -0,0 +1,56 @@
+package namespace
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/websocket"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StreamJobRunsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStreamJobRunsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StreamJobRunsHandler {
+	return &StreamJobRunsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *StreamJobRunsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	safeRW := r.Context().Value(types.RequestCtxWebsocketKey).(*websocket.WebsocketSafeReadWriter)
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if strings.ToLower(namespace) == "all" {
+		namespace = ""
+	}
+
+	err = agent.StreamJobs(namespace, "", safeRW)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 18 - 2
api/server/handlers/release/get_jobs.go

@@ -22,15 +22,22 @@ type GetJobsHandler struct {
 
 func NewGetJobsHandler(
 	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
 ) *GetJobsHandler {
 	return &GetJobsHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
 func (c *GetJobsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.GetJobsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
 	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	agent, err := c.GetAgent(r, cluster, "")
@@ -40,7 +47,16 @@ func (c *GetJobsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	jobs, err := agent.ListJobsByLabel(helmRelease.Namespace, getJobLabels(helmRelease)...)
+	labels := getJobLabels(helmRelease)
+
+	if request.Revision != 0 {
+		labels = append(labels, kubernetes.Label{
+			Key: "helm.sh/revision",
+			Val: fmt.Sprintf("%d", request.Revision),
+		})
+	}
+
+	jobs, err := agent.ListJobsByLabel(helmRelease.Namespace, labels...)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 62 - 0
api/server/handlers/release/get_latest_job_run.go

@@ -0,0 +1,62 @@
+package release
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type GetLatestJobRunHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetLatestJobRunHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetLatestJobRunHandler {
+	return &GetLatestJobRunHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetLatestJobRunHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	jobs, err := agent.ListJobsByLabel(helmRelease.Namespace, getJobLabels(helmRelease)...)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get the most recent job
+	if len(jobs) > 0 {
+		mostRecentJob := jobs[0]
+
+		for _, job := range jobs {
+			createdAt := job.ObjectMeta.CreationTimestamp
+
+			if mostRecentJob.CreationTimestamp.Before(&createdAt) {
+				mostRecentJob = job
+			}
+		}
+
+		c.WriteResult(w, r, mostRecentJob)
+	}
+}

+ 35 - 1
api/server/router/namespace.go

@@ -450,6 +450,40 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/jobs/stream -> namespace.NewStreamJobRunsHandler
+	streamJobRunsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/jobs/stream",
+					relPath,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+			IsWebsocket: true,
+		},
+	)
+
+	streamJobRunsHandler := namespace.NewStreamJobRunsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: streamJobRunsEndpoint,
+		Handler:  streamJobRunsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pod/{name}/previous_logs
 	getPreviousLogsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -680,7 +714,7 @@ func getNamespaceRoutes(
 	})
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/ingresses/{name} ->
-	// release.NewGetJobsHandler
+	// namespace.NewGetIngressHandler
 	getIngressEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,

+ 32 - 0
api/server/router/release.go

@@ -681,6 +681,7 @@ func getReleaseRoutes(
 
 	getJobsHandler := release.NewGetJobsHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
@@ -690,6 +691,37 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/latest_job_run ->
+	// release.NewGetLatestJobRunHandler
+	getLatestJobRunEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/latest_job_run",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	getLatestJobRunHandler := release.NewGetLatestJobRunHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getLatestJobRunEndpoint,
+		Handler:  getLatestJobRunHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/jobs/status ->
 	// release.NewGetJobsHandler
 	getJobsStatusEndpoint := factory.NewAPIEndpoint(

+ 9 - 0
api/types/namespace.go

@@ -176,3 +176,12 @@ type GetPreviousPodLogsRequest struct {
 type GetPreviousPodLogsResponse struct {
 	PrevLogs []string `json:"previous_logs"`
 }
+
+type GetJobsRequest struct {
+	Revision uint `schema:"revision"`
+}
+
+type GetJobRunsRequest struct {
+	Status string `schema:"status"`
+	Sort   string `schema:"sort"`
+}

+ 132 - 14
dashboard/src/components/Table.tsx

@@ -1,7 +1,14 @@
-import React from "react";
+import React, { useEffect } from "react";
 import styled from "styled-components";
-import { Column, Row, useGlobalFilter, useTable } from "react-table";
+import {
+  Column,
+  Row,
+  useGlobalFilter,
+  usePagination,
+  useTable,
+} from "react-table";
 import Loading from "components/Loading";
+import Selector from "./Selector";
 
 const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
   const [value, setValue] = React.useState("");
@@ -31,6 +38,7 @@ export type TableProps = {
   isLoading: boolean;
   disableGlobalFilter?: boolean;
   disableHover?: boolean;
+  enablePagination?: boolean;
 };
 
 const Table: React.FC<TableProps> = ({
@@ -40,23 +48,42 @@ const Table: React.FC<TableProps> = ({
   isLoading,
   disableGlobalFilter = false,
   disableHover,
+  enablePagination,
 }) => {
   const {
     getTableProps,
     getTableBodyProps,
-    rows,
+    page,
     setGlobalFilter,
     prepareRow,
     headerGroups,
     visibleColumns,
+
+    // Pagination options
+    canPreviousPage,
+    canNextPage,
+    pageOptions,
+    pageCount,
+    gotoPage,
+    nextPage,
+    previousPage,
+    setPageSize,
+    state: { pageIndex, pageSize },
   } = useTable(
     {
       columns: columnsData,
       data,
     },
-    useGlobalFilter
+    useGlobalFilter,
+    usePagination
   );
 
+  useEffect(() => {
+    if (!enablePagination) {
+      setPageSize(data.length);
+    }
+  }, [data, enablePagination]);
+
   const renderRows = () => {
     if (isLoading) {
       return (
@@ -68,7 +95,7 @@ const Table: React.FC<TableProps> = ({
       );
     }
 
-    if (!rows.length) {
+    if (!page.length) {
       return (
         <StyledTr disableHover={true} selected={false}>
           <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
@@ -77,7 +104,7 @@ const Table: React.FC<TableProps> = ({
     }
     return (
       <>
-        {rows.map((row) => {
+        {page.map((row) => {
           prepareRow(row);
 
           return (
@@ -89,14 +116,19 @@ const Table: React.FC<TableProps> = ({
               selected={false}
             >
               {/* TODO: This is actually broken, not sure why but we need the width to be properly setted, this is a temporary solution */}
-              {row.cells.map((cell) => (
-                <StyledTd
-                  {...cell.getCellProps()}
-                  width={cell.column.totalWidth}
-                >
-                  {cell.render("Cell")}
-                </StyledTd>
-              ))}
+              {row.cells.map((cell) => {
+                console.log(cell.getCellProps());
+                return (
+                  <StyledTd
+                    {...cell.getCellProps()}
+                    style={{
+                      width: cell.column.totalWidth,
+                    }}
+                  >
+                    {cell.render("Cell")}
+                  </StyledTd>
+                );
+              })}
             </StyledTr>
           );
         })}
@@ -126,6 +158,50 @@ const Table: React.FC<TableProps> = ({
         </StyledTHead>
         <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
       </StyledTable>
+      {enablePagination && (
+        <FlexEnd style={{ marginTop: "15px" }}>
+          <PageCountWrapper>
+            Page size:
+            <Selector
+              activeValue={String(pageSize)}
+              options={[
+                {
+                  label: "10",
+                  value: "10",
+                },
+                {
+                  label: "20",
+                  value: "20",
+                },
+                {
+                  label: "50",
+                  value: "50",
+                },
+                {
+                  label: "100",
+                  value: "100",
+                },
+              ]}
+              setActiveValue={(val) => setPageSize(Number(val))}
+              width="70px"
+            ></Selector>
+          </PageCountWrapper>
+          <PaginationActionsWrapper>
+            <PaginationAction
+              disabled={!canPreviousPage}
+              onClick={previousPage}
+            >
+              {"<"}
+            </PaginationAction>
+            <PageCounter>
+              {pageIndex + 1} of {pageCount}
+            </PageCounter>
+            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+              {">"}
+            </PaginationAction>
+          </PaginationActionsWrapper>
+        </FlexEnd>
+      )}
     </TableWrapper>
   );
 };
@@ -136,6 +212,47 @@ const TableWrapper = styled.div`
   padding-bottom: 20px;
 `;
 
+const FlexEnd = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  width: 100%;
+`;
+
+const PaginationActionsWrapper = styled.div``;
+
+const PageCountWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 160px;
+  margin-right: 10px;
+`;
+
+const PaginationAction = styled.button`
+  border: none;
+  background: unset;
+  color: white;
+  padding: 10px;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff40;
+  }
+
+  :disabled {
+    color: #ffffff88;
+    cursor: unset;
+    :hover {
+      background: unset;
+    }
+  }
+`;
+
+const PageCounter = styled.span`
+  margin: 0 5px;
+`;
+
 type StyledTrProps = {
   enablePointer?: boolean;
   disableHover?: boolean;
@@ -169,6 +286,7 @@ export const StyledTHead = styled.thead`
   width: 100%;
   border-top: 1px solid #aaaabb22;
   border-bottom: 1px solid #aaaabb22;
+  position: sticky;
 `;
 
 export const StyledTh = styled.th`

+ 119 - 15
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -28,6 +28,10 @@ import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import LastRunStatusSelector from "./LastRunStatusSelector";
 import loadable from "@loadable/component";
 import Loading from "components/Loading";
+import JobRunTable from "./chart/JobRunTable";
+import SwitchBase from "@material-ui/core/internal/SwitchBase";
+import Selector from "components/Selector";
+import TabSelector from "components/TabSelector";
 
 // @ts-ignore
 const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
@@ -47,6 +51,7 @@ type StateType = {
   lastRunStatus: JobStatusType | null;
   currentChart: ChartType | null;
   isMetricsInstalled: boolean;
+  showRuns: boolean;
 };
 
 // TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
@@ -59,6 +64,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     lastRunStatus: "all" as null,
     currentChart: null as ChartType | null,
     isMetricsInstalled: false,
+    showRuns: false,
   };
 
   componentDidMount() {
@@ -130,7 +136,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     }
   };
 
-  renderBody = () => {
+  renderBodyForApps = () => {
     let { currentCluster, currentView } = this.props;
     const isAuthorizedToAdd = this.props.isAuthorized(
       "namespace",
@@ -186,25 +192,104 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
   };
 
-  renderContents = () => {
-    let { currentCluster, setSidebar, currentView } = this.props;
-    if (currentView === "env-groups") {
-      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
-    }
-
+  renderBodyForJobs = () => {
+    let { currentCluster, currentView } = this.props;
+    const isAuthorizedToAdd = this.props.isAuthorized(
+      "namespace",
+      [],
+      ["get", "create"]
+    );
     return (
       <>
-        <DashboardHeader
-          image={currentView === "jobs" ? monojob : monoweb}
-          title={currentView}
-          description={this.getDescription(currentView)}
+        <TabSelector
+          currentTab={this.state.showRuns ? "job_runs" : "chart_list"}
+          options={[
+            { label: "Jobs", value: "chart_list" },
+            { label: "Runs", value: "job_runs" },
+          ]}
+          setCurrentTab={(value) => {
+            if (value === "job_runs") {
+              this.setState({ showRuns: true });
+            } else {
+              this.setState({ showRuns: false });
+            }
+          }}
         />
-        {this.renderBody()}
+        <ControlRow style={{ marginTop: "35px" }}>
+          {isAuthorizedToAdd && (
+            <Button
+              onClick={() =>
+                pushFiltered(this.props, "/launch", ["project_id"])
+              }
+            >
+              <i className="material-icons">add</i> Launch Template
+            </Button>
+          )}
+          <SortFilterWrapper>
+            {currentView === "jobs" && (
+              <LastRunStatusSelector
+                lastRunStatus={this.state.lastRunStatus}
+                setLastRunStatus={(lastRunStatus: JobStatusType) => {
+                  this.setState({ lastRunStatus });
+                }}
+              />
+            )}
+            <NamespaceSelector
+              setNamespace={(namespace) =>
+                this.setState({ namespace }, () => {
+                  pushQueryParams(this.props, {
+                    namespace: this.state.namespace || "ALL",
+                  });
+                })
+              }
+              namespace={this.state.namespace}
+            />
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
+          </SortFilterWrapper>
+        </ControlRow>
+        <HidableElement show={this.state.showRuns}>
+          <JobRunTable
+            lastRunStatus={this.state.lastRunStatus}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType as any}
+          />
+        </HidableElement>
+        <HidableElement show={!this.state.showRuns}>
+          <ChartList
+            currentView={currentView}
+            currentCluster={currentCluster}
+            lastRunStatus={this.state.lastRunStatus}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType}
+          />
+        </HidableElement>
       </>
     );
   };
 
+  // renderContents = () => {
+  //   let { currentCluster, setSidebar, currentView } = this.props;
+  //   if (currentView === "env-groups") {
+  //     return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
+  //   }
+
+  //   return (
+  //     <>
+  //       <DashboardHeader
+  //         image={currentView === "jobs" ? monojob : monoweb}
+  //         title={currentView}
+  //         description={this.getDescription(currentView)}
+  //       />
+  //       {this.renderBody()}
+  //     </>
+  //   );
+  // };
+
   render() {
+    let { currentView } = this.props;
     let { setSidebar } = this.props;
     return (
       <Switch>
@@ -220,7 +305,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {this.renderContents()}
+          <DashboardHeader
+            image={monojob}
+            title={currentView}
+            description={this.getDescription(currentView)}
+            disableLineBreak
+          />
+
+          {this.renderBodyForJobs()}
         </GuardedRoute>
         <GuardedRoute
           path={"/applications"}
@@ -228,7 +320,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {this.renderContents()}
+          {/* {this.renderContents()} */}
+          <DashboardHeader
+            image={monoweb}
+            title={currentView}
+            description={this.getDescription(currentView)}
+          />
+
+          {this.renderBodyForApps()}
         </GuardedRoute>
         <GuardedRoute
           path={"/env-groups"}
@@ -236,7 +335,8 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {this.renderContents()}
+          {/* {this.renderContents()} */}
+          <EnvGroupDashboard currentCluster={this.props.currentCluster} />
         </GuardedRoute>
         <Route path={"/databases"}>
           <LazyDatabasesRoutes />
@@ -253,6 +353,10 @@ ClusterDashboard.contextType = Context;
 
 export default withRouter(withAuth(ClusterDashboard));
 
+const HidableElement = styled.div<{ show: boolean }>`
+  display: ${(props) => (props.show ? "unset" : "none")};
+`;
+
 const Br = styled.div`
   width: 100%;
   height: 1px;

+ 567 - 0
dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx

@@ -0,0 +1,567 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import Table from "components/Table";
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import { CellProps, Column, Row } from "react-table";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+
+type Props = {
+  lastRunStatus: "failed" | "succeeded" | "active" | "all";
+  namespace: string;
+  sortType: "Newest" | "Oldest" | "Alphabetical";
+};
+
+const dateFormatter = (date: string) => {
+  if (!date) {
+    return "N/A";
+  }
+
+  // @ts-ignore
+  const rtf = new Intl.RelativeTimeFormat("en", {
+    localeMatcher: "best fit", // other values: "lookup"
+    numeric: "auto", // other values: "auto"
+    style: "long", // other values: "short" or "narrow"
+  });
+
+  const time = timeFrom(date);
+  if (!time) {
+    return "N/A";
+  }
+
+  return rtf.format(-time.time, time.unitOfTime);
+};
+
+const runnedFor = (start: string | number, end?: string | number) => {
+  const duration = timeFrom(start, end);
+
+  const unit =
+    duration.time === 1
+      ? duration.unitOfTime.substring(0, duration.unitOfTime.length - 1)
+      : duration.unitOfTime;
+
+  return `${duration.time} ${unit}`;
+};
+
+function timeFrom(time: string | number, secondTime?: string | number) {
+  // Get timestamps
+  let unixTime = new Date(time).getTime();
+  if (!unixTime) return;
+
+  let now = new Date().getTime();
+
+  if (secondTime) {
+    now = new Date(secondTime).getTime();
+  }
+
+  // Calculate difference
+  let difference = unixTime / 1000 - now / 1000;
+
+  // Setup return object
+  let tfn: any = {};
+
+  // Check if time is in the past, present, or future
+  tfn.when = "now";
+  if (difference > 0) {
+    tfn.when = "future";
+  } else if (difference < -1) {
+    tfn.when = "past";
+  }
+
+  // Convert difference to absolute
+  difference = Math.abs(difference);
+
+  // Calculate time unit
+  if (difference / (60 * 60 * 24 * 365) > 1) {
+    // Years
+    tfn.unitOfTime = "years";
+    tfn.time = Math.floor(difference / (60 * 60 * 24 * 365));
+  } else if (difference / (60 * 60 * 24 * 45) > 1) {
+    // Months
+    tfn.unitOfTime = "months";
+    tfn.time = Math.floor(difference / (60 * 60 * 24 * 45));
+  } else if (difference / (60 * 60 * 24) > 1) {
+    // Days
+    tfn.unitOfTime = "days";
+    tfn.time = Math.floor(difference / (60 * 60 * 24));
+  } else if (difference / (60 * 60) > 1) {
+    // Hours
+    tfn.unitOfTime = "hours";
+    tfn.time = Math.floor(difference / (60 * 60));
+  } else if (difference / 60 > 1) {
+    // Minutes
+    tfn.unitOfTime = "minutes";
+    tfn.time = Math.floor(difference / 60);
+  } else {
+    // Seconds
+    tfn.unitOfTime = "seconds";
+    tfn.time = Math.floor(difference);
+  }
+
+  // Return time from now data
+  return tfn;
+}
+
+const JobRunTable: React.FC<Props> = ({
+  lastRunStatus,
+  namespace,
+  sortType,
+}) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [jobRuns, setJobRuns] = useState<JobRun[]>(null);
+  const [error, setError] = useState();
+  const tmpJobRuns = useRef([]);
+  const { openWebsocket, newWebsocket, closeAllWebsockets } = useWebsockets();
+  const { pushFiltered } = useRouting();
+
+  useEffect(() => {
+    closeAllWebsockets();
+    tmpJobRuns.current = [];
+    setJobRuns(null);
+    const websocketId = "job-runs-for-all-charts-ws";
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${namespace}/jobs/stream`;
+
+    const config: NewWebsocketOptions = {
+      onopen: console.log,
+      onmessage: (message) => {
+        const data = JSON.parse(message.data);
+        if (data.streamStatus === "finished") {
+          setJobRuns(tmpJobRuns.current);
+          return;
+        }
+
+        if (data.streamStatus === "errored") {
+          setError(data.error);
+          tmpJobRuns.current = [];
+          setJobRuns([]);
+          return;
+        }
+
+        tmpJobRuns.current = [...tmpJobRuns.current, data];
+      },
+      onclose: () => {
+        closeAllWebsockets();
+      },
+      onerror: (error) => {
+        console.log(error);
+        closeAllWebsockets();
+      },
+    };
+    newWebsocket(websocketId, endpoint, config);
+    openWebsocket(websocketId);
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, [currentCluster, currentProject, namespace]);
+
+  const columns = useMemo<Column<JobRun>[]>(
+    () => [
+      {
+        Header: "Namespace / Name",
+        accessor: (originalRow) => {
+          const owners = originalRow.metadata.ownerReferences;
+          let name = "N/A";
+          if (Array.isArray(owners)) {
+            name = owners[0]?.name;
+          }
+          if (originalRow?.metadata?.labels["meta.helm.sh/release-name"]) {
+            name = originalRow.metadata.labels["meta.helm.sh/release-name"];
+          }
+
+          if (name !== "N/A") {
+            return originalRow.metadata?.namespace + "/" + name;
+          }
+
+          return name;
+        },
+        width: "max-content",
+      },
+      {
+        Header: "Run at",
+        accessor: (originalRow) => dateFormatter(originalRow.status.startTime),
+      },
+      {
+        Header: "Run for",
+        accessor: (originalRow) => {
+          if (originalRow.status?.completionTime) {
+            return originalRow.status?.completionTime;
+          } else if (
+            Array.isArray(originalRow.status?.conditions) &&
+            originalRow.status?.conditions[0]?.lastTransitionTime
+          ) {
+            return originalRow.status?.conditions[0]?.lastTransitionTime;
+          } else {
+            return "Still running...";
+          }
+        },
+        Cell: ({ row }: CellProps<JobRun>) => {
+          if (row.original.status?.completionTime) {
+            return runnedFor(
+              row.original.status?.startTime,
+              row.original.status?.completionTime
+            );
+          } else if (
+            Array.isArray(row.original.status?.conditions) &&
+            row.original.status?.conditions[0]?.lastTransitionTime
+          ) {
+            return runnedFor(
+              row.original.status?.startTime,
+              row.original.status?.conditions[0]?.lastTransitionTime
+            );
+          } else {
+            return "Still running...";
+          }
+        },
+        styles: {
+          padding: "10px",
+        },
+      },
+      {
+        Header: "Status",
+        id: "status",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          if (row.original.status?.succeeded >= 1) {
+            return <Status color="#38a88a">Succeeded</Status>;
+          }
+
+          if (row.original.status?.failed >= 1) {
+            return <Status color="#cc3d42">Failed</Status>;
+          }
+
+          return <Status color="#ffffff11">Running</Status>;
+        },
+      },
+      {
+        Header: "Commit/Image tag",
+        id: "commit_or_image_tag",
+        accessor: (originalRow) => {
+          const container = originalRow.spec?.template?.spec?.containers[0];
+          return container?.image?.split(":")[1] || "N/A";
+        },
+        Cell: ({ row }: CellProps<JobRun>) => {
+          const container = row.original.spec?.template?.spec?.containers[0];
+
+          const tag = container?.image?.split(":")[1];
+          return tag;
+        },
+      },
+      {
+        Header: "Command",
+        id: "command",
+        accessor: (originalRow) => {
+          const container = originalRow.spec?.template?.spec?.containers[0];
+          return container?.command?.join(" ") || "N/A";
+        },
+        Cell: ({ row }: CellProps<JobRun>) => {
+          const container = row.original.spec?.template?.spec?.containers[0];
+
+          return (
+            <CommandString>
+              {container?.command?.join(" ") || "N/A"}
+            </CommandString>
+          );
+        },
+      },
+      {
+        id: "expand",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          /**
+           * project_id: currentProject.id,
+          chart_revision: 0,
+          job: row.original?.metadata?.name,
+           */
+          const urlParams = new URLSearchParams();
+          urlParams.append("project_id", String(currentProject.id));
+          urlParams.append("chart_revision", String(0));
+          urlParams.append("job", row.original.metadata.name);
+
+          return (
+            <RedirectButton
+              to={{
+                pathname: `/jobs/${currentCluster.name}/${row.original?.metadata?.namespace}/${row.original?.metadata?.labels["meta.helm.sh/release-name"]}`,
+                search: urlParams.toString(),
+              }}
+            >
+              <i className="material-icons">open_in_new</i>
+            </RedirectButton>
+          );
+        },
+        maxWidth: 40,
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    if (jobRuns === null) {
+      return [];
+    }
+    let tmp = [...tmpJobRuns.current];
+    const filter = new JobRunsFilter(tmp);
+    switch (lastRunStatus) {
+      case "active":
+        tmp = filter.filterByActive();
+        break;
+      case "failed":
+        tmp = filter.filterByFailed();
+        break;
+      case "succeeded":
+        tmp = filter.filterBySucceded();
+        break;
+      default:
+        tmp = filter.dontFilter();
+        break;
+    }
+
+    const sorter = new JobRunsSorter(tmp);
+    switch (sortType) {
+      case "Alphabetical":
+        tmp = sorter.sortByAlphabetical();
+        break;
+      case "Newest":
+        tmp = sorter.sortByNewest();
+        break;
+      case "Oldest":
+        tmp = sorter.sortByOldest();
+        break;
+      default:
+        break;
+    }
+
+    return tmp;
+  }, [jobRuns, lastRunStatus, sortType]);
+
+  if (error) {
+    return <>{error}</>;
+  }
+
+  if (jobRuns === null) {
+    return <Loading />;
+  }
+
+  if (!jobRuns?.length) {
+    return <>No job runs found</>;
+  }
+
+  return (
+    <Table
+      columns={columns}
+      data={data}
+      isLoading={jobRuns === null}
+      enablePagination
+    />
+  );
+};
+
+export default JobRunTable;
+
+const Status = styled.div<{ color: string }>`
+  padding: 5px 10px;
+  background: ${(props) => props.color};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: min-content;
+  height: 25px;
+  min-width: 90px;
+`;
+
+const CommandString = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 300px;
+  color: #ffffff55;
+  margin-right: 27px;
+  font-family: monospace;
+`;
+
+const RedirectButton = styled(DynamicLink)`
+  user-select: none;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+type JobRun = {
+  metadata: {
+    name: string;
+    namespace: string;
+    selfLink: string;
+    uid: string;
+    resourceVersion: string;
+    creationTimestamp: string;
+    labels: {
+      [key: string]: string;
+      "app.kubernetes.io/instance": string;
+      "app.kubernetes.io/managed-by": string;
+      "app.kubernetes.io/version": string;
+      "helm.sh/chart": string;
+      "helm.sh/revision": string;
+      "meta.helm.sh/release-name": string;
+    };
+    ownerReferences: {
+      apiVersion: string;
+      kind: string;
+      name: string;
+      uid: string;
+      controller: boolean;
+      blockOwnerDeletion: boolean;
+    }[];
+    managedFields: unknown[];
+  };
+  spec: {
+    [key: string]: unknown;
+    parallelism: number;
+    completions: number;
+    backOffLimit?: number;
+    selector: {
+      [key: string]: unknown;
+      matchLabels: {
+        [key: string]: unknown;
+        "controller-uid": string;
+      };
+    };
+    template: {
+      [key: string]: unknown;
+      metadata: {
+        creationTimestamp: string | null;
+        labels: {
+          [key: string]: unknown;
+          "controller-uid": string;
+          "job-name": string;
+        };
+      };
+      spec: {
+        containers: {
+          name: string;
+          image: string;
+          command: string[];
+          env?: {
+            [key: string]: unknown;
+            name: string;
+            value?: string;
+            valueFrom?: {
+              secretKeyRef?: { name: string; key: string };
+              configMapKeyRef?: { name: string; key: string };
+            };
+          }[];
+          resources: {
+            [key: string]: unknown;
+            limits: { [key: string]: unknown; memory: string };
+            requests: { [key: string]: unknown; cpu: string; memory: string };
+          };
+          terminationMessagePath: string;
+          terminationMessagePolicy: string;
+          imagePullPolicy: string;
+        }[];
+
+        restartPolicy: string;
+        terminationGracePeriodSeconds: number;
+        dnsPolicy: string;
+        shareProcessNamespace: boolean;
+        securityContext: unknown;
+        schedulerName: string;
+        tolerations: {
+          [key: string]: unknown;
+          key: string;
+          operator: string;
+          value: string;
+          effect: string;
+        }[];
+      };
+    };
+  };
+  status: {
+    [key: string]: unknown;
+    conditions: {
+      [key: string]: unknown;
+      type: string;
+      status: string;
+      lastProbeTime: string;
+      lastTransitionTime: string;
+    }[];
+    startTime: string;
+    completionTime: string | undefined | null;
+    succeeded?: number;
+    failed?: number;
+    active?: number;
+  };
+};
+
+class JobRunsFilter {
+  jobRuns: JobRun[];
+
+  constructor(newJobRuns: JobRun[]) {
+    this.jobRuns = newJobRuns;
+  }
+
+  filterByFailed() {
+    return this.jobRuns.filter((jobRun) => jobRun?.status?.failed);
+  }
+
+  filterByActive() {
+    return this.jobRuns.filter((jobRun) => jobRun?.status?.active);
+  }
+
+  filterBySucceded() {
+    return this.jobRuns.filter(
+      (jobRun) =>
+        jobRun?.status?.succeeded &&
+        !jobRun?.status?.active &&
+        !jobRun?.status?.failed
+    );
+  }
+
+  dontFilter() {
+    return this.jobRuns;
+  }
+}
+
+class JobRunsSorter {
+  jobRuns: JobRun[];
+
+  constructor(newJobRuns: JobRun[]) {
+    this.jobRuns = newJobRuns;
+  }
+
+  sortByNewest() {
+    return this.jobRuns.sort((a, b) => {
+      return Date.parse(a?.metadata?.creationTimestamp) >
+        Date.parse(b?.metadata?.creationTimestamp)
+        ? -1
+        : 1;
+    });
+  }
+
+  sortByOldest() {
+    return this.jobRuns.sort((a, b) => {
+      return Date.parse(a?.metadata?.creationTimestamp) >
+        Date.parse(b?.metadata?.creationTimestamp)
+        ? 1
+        : -1;
+    });
+  }
+
+  sortByAlphabetical() {
+    return this.jobRuns.sort((a, b) =>
+      a?.metadata?.name > b?.metadata?.name ? 1 : -1
+    );
+  }
+}

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

@@ -474,9 +474,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const getBranchContents = baseApi<
@@ -492,9 +494,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -510,9 +514,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getBranches = baseApi<
@@ -1071,9 +1077,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<

+ 109 - 0
internal/kubernetes/agent.go

@@ -689,6 +689,115 @@ func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Jo
 	return resp.Items, nil
 }
 
+// StreamJobs streams a list of jobs to the websocket writer, closing the connection once all jobs have been sent
+func (a *Agent) StreamJobs(namespace string, selectors string, rw *websocket.WebsocketSafeReadWriter) error {
+	run := func() error {
+		errorchan := make(chan error)
+		ctx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+
+		var wg sync.WaitGroup
+		var once sync.Once
+		var err error
+
+		wg.Add(2)
+
+		go func() {
+			wg.Wait()
+			close(errorchan)
+		}()
+
+		go func() {
+			defer func() {
+				if r := recover(); r != nil {
+					// TODO: add method to alert on panic
+					return
+				}
+			}()
+
+			// listens for websocket closing handshake
+			defer wg.Done()
+
+			for {
+				if _, _, err := rw.ReadMessage(); err != nil {
+					errorchan <- nil
+					return
+				}
+			}
+		}()
+
+		go func() {
+			defer func() {
+				if r := recover(); r != nil {
+					// TODO: add method to alert on panic
+					return
+				}
+			}()
+
+			// listens for websocket closing handshake
+			defer wg.Done()
+
+			continueVal := ""
+
+			for {
+				if ctx.Err() != nil {
+					errorchan <- nil
+					return
+				}
+
+				jobs, err := a.Clientset.BatchV1().Jobs(namespace).List(
+					ctx,
+					metav1.ListOptions{
+						Limit:         100,
+						Continue:      continueVal,
+						LabelSelector: "meta.helm.sh/release-name",
+					},
+				)
+
+				if err != nil {
+					errorchan <- err
+					return
+				}
+
+				for _, job := range jobs.Items {
+					err := rw.WriteJSON(job)
+
+					if err != nil {
+						errorchan <- err
+						return
+					}
+				}
+
+				if jobs.Continue == "" {
+					// we have reached the end of the list of jobs
+					break
+				} else {
+					// start pagination
+					continueVal = jobs.Continue
+				}
+			}
+
+			// at this point, we can return the status finished
+			err := rw.WriteJSON(map[string]interface{}{
+				"streamStatus": "finished",
+			})
+
+			errorchan <- err
+		}()
+
+		for err = range errorchan {
+			once.Do(func() {
+				rw.Close()
+				cancel()
+			})
+		}
+
+		return err
+	}
+
+	return a.RunWebsocketTask(run)
+}
+
 // DeleteJob deletes the job in the given name and namespace.
 func (a *Agent) DeleteJob(name, namespace string) error {
 	return a.Clientset.BatchV1().Jobs(namespace).Delete(