Переглянути джерело

[POR-1673] Implement Job History in New View (#3614)

Feroze Mohideen 2 роки тому
батько
коміт
a208a76938
20 змінених файлів з 790 додано та 132 видалено
  1. 132 0
      api/server/handlers/porter_app/job_status.go
  2. 31 2
      api/server/router/porter_app.go
  3. 18 5
      dashboard/src/components/OldTable.tsx
  4. 0 2
      dashboard/src/components/form-components/SelectRow.tsx
  5. 93 0
      dashboard/src/lib/hooks/useJobs.ts
  6. 9 7
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  7. 23 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx
  8. 1 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx
  9. 1 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx
  10. 4 26
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts
  11. 1 2
      dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx
  12. 106 0
      dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx
  13. 228 0
      dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx
  14. 24 0
      dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts
  15. 9 3
      dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx
  16. 18 6
      dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts
  17. 1 0
      dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx
  18. 2 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx
  19. 34 28
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx
  20. 55 50
      dashboard/src/shared/api.tsx

+ 132 - 0
api/server/handlers/porter_app/job_status.go

@@ -0,0 +1,132 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// JobStatusHandler is the handler for GET /apps/jobs
+type JobStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewJobStatusHandler returns a new JobStatusHandler
+func NewJobStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *JobStatusHandler {
+	return &JobStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// JobStatusRequest is the expected format for a request body on GET /apps/jobs
+type JobStatusRequest struct {
+	DeploymentTargetID string `schema:"deployment_target_id"`
+	JobName            string `schema:"job_name"`
+}
+
+func (c *JobStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-job-status")
+	defer span.End()
+
+	request := &JobStatusRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "invalid request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	name, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "invalid porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: name})
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := c.Config().ClusterControlPlaneClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != int64(cluster.ID) {
+		err := telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	namespace := deploymentTargetDetailsResp.Msg.Namespace
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
+
+	agent, err := c.GetAgent(r, cluster, "")
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "unable to get agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	labels := []kubernetes.Label{
+		{
+			Key: "porter.run/deployment-target-id",
+			Val: request.DeploymentTargetID,
+		},
+		{
+			Key: "porter.run/app-name",
+			Val: name,
+		},
+	}
+	if request.JobName != "" {
+		labels = append(labels, kubernetes.Label{
+			Key: "porter.run/service-name",
+			Val: request.JobName,
+		})
+	}
+	jobs, err := agent.ListJobsByLabel(namespace, labels...)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing jobs")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, jobs)
+}

+ 31 - 2
api/server/router/porter_app.go

@@ -869,7 +869,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/logs", relPathV2),
+				RelativePath: fmt.Sprintf("%s/{%s}/logs", relPathV2, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -898,7 +898,7 @@ func getPorterAppRoutes(
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/logs/loki", relPathV2),
+				RelativePath: fmt.Sprintf("%s/{%s}/logs/loki", relPathV2, types.URLParamPorterAppName),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -1009,6 +1009,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/jobs -> cluster.NewJobStatusHandler
+	appJobStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/jobs", relPathV2, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appJobStatusHandler := porter_app.NewJobStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appJobStatusEndpoint,
+		Handler:  appJobStatusHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id} -> porter_app.NewUpdateAppRevisionStatusHandler
 	updateAppRevisionStatusEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 18 - 5
dashboard/src/components/OldTable.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import {
   Column,
@@ -10,6 +10,7 @@ import {
 import Loading from "components/Loading";
 import Selector from "./Selector";
 import loading from "assets/loading.gif";
+import Button from "./porter/Button";
 
 const GlobalFilter: React.FunctionComponent<any> = ({
   setGlobalFilter,
@@ -78,6 +79,7 @@ const Table: React.FC<TableProps> = ({
   onRefresh,
   isRefreshing = false,
 }) => {
+  const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
   const {
     getTableProps,
     getTableBodyProps,
@@ -101,6 +103,10 @@ const Table: React.FC<TableProps> = ({
     {
       columns: columnsData,
       data,
+      initialState: {
+        pageIndex: currentPageIndex,
+      },
+      autoResetPage: false,
     },
     useGlobalFilter,
     usePagination
@@ -232,14 +238,21 @@ const Table: React.FC<TableProps> = ({
           <PaginationActionsWrapper>
             <PaginationAction
               disabled={!canPreviousPage}
-              onClick={previousPage}
+              onClick={() => {
+                previousPage();
+                setCurrentPageIndex(currentPageIndex - 1);
+              }}
+              type={"button"}
             >
               {"<"}
             </PaginationAction>
             <PageCounter>
-              {pageIndex + 1} of {pageCount}
+              {currentPageIndex + 1} of {pageCount}
             </PageCounter>
-            <PaginationAction disabled={!canNextPage} onClick={nextPage}>
+            <PaginationAction disabled={!canNextPage} onClick={() => {
+              nextPage();
+              setCurrentPageIndex(currentPageIndex + 1);
+            }} type={"button"}>
               {">"}
             </PaginationAction>
           </PaginationActionsWrapper>
@@ -307,7 +320,7 @@ export const StyledTr = styled.tr`
   background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
   :hover {
     background: ${(props: StyledTrProps) =>
-      props.disableHover ? "" : "#ffffff22"};
+    props.disableHover ? "" : "#ffffff22"};
   }
   cursor: ${(props: StyledTrProps) =>
     props.enablePointer ? "pointer" : "unset"};

+ 0 - 2
dashboard/src/components/form-components/SelectRow.tsx

@@ -78,6 +78,4 @@ const Label = styled.div<{ displayFlex?: boolean }>`
 
 const StyledSelectRow = styled.div<{ displayFlex?: boolean }>`
   display: ${props => props.displayFlex ? "flex" : "block"};
-  margin-bottom: 15px;
-  margin-top: 20px;
 `;

+ 93 - 0
dashboard/src/lib/hooks/useJobs.ts

@@ -0,0 +1,93 @@
+import _ from "lodash";
+import { useEffect, useState } from "react";
+import api from "shared/api";
+import { useRevisionIdToNumber } from "./useRevisionList";
+import { z } from "zod";
+import { useQuery } from "@tanstack/react-query";
+
+const jobRunValidator = z.object({
+    metadata: z.object({
+        labels: z.object({
+            "porter.run/app-revision-id": z.string(),
+            "porter.run/service-name": z.string(),
+        }),
+        creationTimestamp: z.string(),
+        uid: z.string(),
+    }),
+    status: z.object({
+        startTime: z.string().optional(),
+        completionTime: z.string().optional(),
+        conditions: z.array(z.object({
+            lastTransitionTime: z.string(),
+        })).default([]),
+        succeeded: z.number().optional(),
+        failed: z.number().optional(),
+    }),
+    revisionNumber: z.number().optional(),
+    jobName: z.string().optional(),
+});
+
+export type JobRun = z.infer<typeof jobRunValidator>;
+
+export const useJobs = (
+    {
+        appName,
+        projectId,
+        clusterId,
+        deploymentTargetId,
+        selectedJobName,
+    }: {
+        appName: string,
+        projectId: number,
+        clusterId: number,
+        deploymentTargetId: string,
+        selectedJobName: string,
+    }
+) => {
+    const [jobRuns, setJobRuns] = useState<JobRun[]>([]);
+
+    const revisionIdToNumber = useRevisionIdToNumber(appName, deploymentTargetId);
+
+    const { data } = useQuery(
+        ["jobRuns", appName, deploymentTargetId, revisionIdToNumber, selectedJobName],
+        async () => {
+            const res = await api.appJobs(
+                "<token>",
+                {
+                    deployment_target_id: deploymentTargetId,
+                    job_name: selectedJobName === "all" ? "" : selectedJobName,
+                },
+                {
+                    project_id: projectId,
+                    cluster_id: clusterId,
+                    porter_app_name: appName,
+                });
+            const parsed = await z.array(jobRunValidator).parseAsync(res.data);
+            const parsedWithRevision = parsed.map((jobRun) => {
+                const revisionId = jobRun.metadata.labels["porter.run/app-revision-id"];
+                const revisionNumber = revisionIdToNumber[revisionId];
+                return {
+                    ...jobRun,
+                    revisionNumber,
+                    jobName: jobRun.metadata.labels["porter.run/service-name"],
+                };
+            });
+            return parsedWithRevision;
+        },
+        {
+            enabled: revisionIdToNumber != null,
+            refetchInterval: 5000,
+            refetchOnWindowFocus: false,
+        },
+    );
+
+    useEffect(() => {
+        if (data != null) {
+            setJobRuns(data);
+        }
+    }, [data]);
+
+    return {
+        jobRuns,
+    };
+};

+ 9 - 7
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -31,6 +31,7 @@ import Activity from "./tabs/Activity";
 import EventFocusView from "./tabs/activity-feed/events/focus-views/EventFocusView";
 import { z } from "zod";
 import { PorterApp } from "@porter-dev/api-contracts";
+import JobsTab from "./tabs/JobsTab";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -45,7 +46,7 @@ const validTabs = [
   "build-settings",
   "settings",
   // "helm-values",
-  // "job-history",
+  "job-history",
 ] as const;
 const DEFAULT_TAB = "activity";
 type ValidTab = typeof validTabs[number];
@@ -249,7 +250,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
 
       // redirect to the default tab after save
       history.push(`/apps/${porterApp.name}/${DEFAULT_TAB}`);
-    } catch (err) {}
+    } catch (err) { }
   });
 
   useEffect(() => {
@@ -319,11 +320,11 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
             { label: "Environment", value: "environment" },
             ...(latestProto.build
               ? [
-                  {
-                    label: "Build Settings",
-                    value: "build-settings",
-                  },
-                ]
+                {
+                  label: "Build Settings",
+                  value: "build-settings",
+                },
+              ]
               : []),
             { label: "Settings", value: "settings" },
           ]}
@@ -347,6 +348,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           .with("logs", () => <LogsTab />)
           .with("metrics", () => <MetricsTab />)
           .with("events", () => <EventFocusView />)
+          .with("job-history", () => <JobsTab />)
           .otherwise(() => null)}
         <Spacer y={2} />
       </form>

+ 23 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { useLatestRevision } from "../LatestRevisionContext";
+import JobsSection from "../../validate-apply/jobs/JobsSection";
+
+const JobsTab: React.FC = () => {
+    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+
+    const appName = latestProto.name
+
+    return (
+        <>
+            <JobsSection
+                projectId={projectId}
+                clusterId={clusterId}
+                deploymentTargetId={deploymentTargetId}
+                appName={appName}
+                jobNames={Object.keys(latestProto.services).filter(name => latestProto.services[name].config.case === "jobConfig")}
+            />
+        </>
+    );
+};
+
+export default JobsTab;

+ 1 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -21,7 +21,7 @@ const Overview: React.FC = () => {
   const { serviceVersionStatus } = useAppStatus({
     projectId,
     clusterId,
-    serviceNames: Object.keys(latestProto.services).filter(name => latestProto.services[name].config.case !== "jobConfig"),
+    serviceNames: Object.keys(latestProto.services),
     deploymentTargetId,
     appName: latestProto.name,
   });

+ 1 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx

@@ -45,6 +45,7 @@ const AppEventCard: React.FC<Props> = ({ event, deploymentTargetId, projectId, c
         {
           project_id: projectId,
           cluster_id: clusterId,
+          app_name: appName,
         }
       )
 

+ 4 - 26
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts

@@ -3,9 +3,10 @@ import failure from "assets/failure.svg";
 import loading from "assets/loading.gif";
 import canceled from "assets/canceled.svg"
 import api from "shared/api";
-import { PorterAppBuildEvent, PorterAppEvent, PorterAppPreDeployEvent } from "./types";
+import { PorterAppBuildEvent, PorterAppPreDeployEvent } from "./types";
 import { PorterAppRecord } from "../../../AppView";
 import { match } from "ts-pattern";
+import { differenceInSeconds, formatDuration } from 'date-fns';
 
 export const getDuration = (event: PorterAppPreDeployEvent | PorterAppBuildEvent): string => {
     const startTimeStamp = match(event)
@@ -15,32 +16,9 @@ export const getDuration = (event: PorterAppPreDeployEvent | PorterAppBuildEvent
 
     const endTimeStamp = event.metadata.end_time ? new Date(event.metadata.end_time).getTime() : Date.now()
 
-    const timeDifferenceMilliseconds = endTimeStamp - startTimeStamp;
+    const timeDifferenceInSeconds = differenceInSeconds(endTimeStamp, startTimeStamp);
 
-    const seconds = Math.floor(timeDifferenceMilliseconds / 1000);
-    const weeks = Math.floor(seconds / 604800);
-    const remainingDays = Math.floor((seconds % 604800) / 86400);
-    const remainingHours = Math.floor((seconds % 86400) / 3600);
-    const remainingMinutes = Math.floor((seconds % 3600) / 60);
-    const remainingSeconds = seconds % 60;
-
-    if (weeks > 0) {
-        return `${weeks}w ${remainingDays}d`;
-    }
-
-    if (remainingDays > 0) {
-        return `${remainingDays}d ${remainingHours}h`;
-    }
-
-    if (remainingHours > 0) {
-        return `${remainingHours}h ${remainingMinutes}m`;
-    }
-
-    if (remainingMinutes > 0) {
-        return `${remainingMinutes}m ${remainingSeconds}s`;
-    }
-
-    return `${remainingSeconds}s`;
+    return formatDuration({ seconds: timeDifferenceInSeconds });
 };
 
 export const getStatusIcon = (status: string) => {

+ 1 - 2
dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx

@@ -26,7 +26,6 @@ import Spacer from "components/porter/Spacer";
 import Container from "components/porter/Container";
 import Button from "components/porter/Button";
 import { Service } from "../../new-app-flow/serviceTypes";
-import LogFilterContainer from "./LogFilterContainer";
 import StyledLogs from "./StyledLogs";
 import Filter from "components/porter/Filter";
 
@@ -252,7 +251,7 @@ const LogSection: React.FC<Props> = ({
           </Flex>
           <Flex>
             {showFilter && (
-              <Filter 
+              <Filter
                 filters={filters}
                 selectedFilterValues={selectedFilterValues}
                 filterString={generateFilterString()}

+ 106 - 0
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx

@@ -0,0 +1,106 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import Logs from "main/home/app-dashboard/validate-apply/logs/Logs";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { JobRun } from "lib/hooks/useJobs";
+import { match } from "ts-pattern";
+import { getStatusColor } from "../../app-view/tabs/activity-feed/events/utils";
+import { AppearingView } from "../../app-view/tabs/activity-feed/events/focus-views/EventFocusView";
+import { getDuration } from "./utils";
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+import dayjs from "dayjs";
+
+type Props = {
+    jobRun: JobRun;
+};
+
+const JobRunDetails: React.FC<Props> = ({
+    jobRun,
+}) => {
+    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+
+    const appName = latestProto.name
+
+    const renderHeaderText = () => {
+        return match(jobRun)
+            .with({ status: { succeeded: 1 } }, () => <Text color={getStatusColor("SUCCESS")} size={16}>Job run succeeded</Text>)
+            .with({ status: { failed: 1 } }, () => <Text color={getStatusColor("FAILED")} size={16}>Job run failed</Text>)
+            .otherwise(() => (
+                <Container row>
+                    <Icon height="16px" src={loading} />
+                    <Spacer inline width="10px" />
+                    <Text size={16} color={getStatusColor("PROGRESSING")}>Job run in progress...</Text>
+                </Container>
+            ));
+    };
+
+    const renderDurationText = () => {
+        return match(jobRun)
+            .with({ status: { succeeded: 1 } }, () => <Text color="helper">Started {readableDate(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp)} and ran for {getDuration(jobRun)}.</Text>)
+            .with({ status: { failed: 1 } }, () => <Text color="helper">Started {readableDate(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp)} and ran for {getDuration(jobRun)}.</Text>)
+            .otherwise(() => <Text color="helper">Started {readableDate(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp)}.</Text>);
+    }
+
+    return (
+        <>
+            <Link to={`/apps/${latestProto.name}/job-history?service=${jobRun.jobName}`}>
+                <BackButton>
+                    <i className="material-icons">keyboard_backspace</i>
+                    Job run history
+                </BackButton>
+            </Link>
+            <Spacer y={0.5} />
+            <AppearingView>
+                {renderHeaderText()}
+            </AppearingView>
+            <Spacer y={0.5} />
+            {renderDurationText()}
+            <Spacer y={0.5} />
+            <Logs
+                projectId={projectId}
+                clusterId={clusterId}
+                appName={appName}
+                serviceNames={[jobRun.jobName ?? "all"]}
+                deploymentTargetId={deploymentTargetId}
+                appRevisionId={jobRun.metadata.labels["porter.run/app-revision-id"]}
+                logFilterNames={["service_name"]}
+                timeRange={{
+                    startTime: dayjs(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp).subtract(30, 'second'),
+                    endTime: jobRun.status.completionTime != null ? dayjs(jobRun.status.completionTime).add(30, 'second') : undefined,
+                }}
+            />
+        </>
+    );
+};
+
+export default JobRunDetails;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  max-width: fit-content;
+  cursor: pointer;
+  font-size: 11px;
+  max-height: fit-content;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 228 - 0
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx

@@ -0,0 +1,228 @@
+import React, { useState, useMemo } from "react";
+import styled from "styled-components";
+
+import history from "assets/history.png";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import { JobRun, useJobs } from "lib/hooks/useJobs";
+import Table from "components/OldTable";
+import { CellProps, Column } from "react-table";
+import { relativeDate, timeFrom } from "shared/string_utils";
+import { useLocation } from "react-router";
+import SelectRow from "components/form-components/SelectRow";
+import Link from "components/porter/Link";
+import { ranFor } from "./utils";
+import JobRunDetails from "./JobRunDetails";
+
+type Props = {
+  appName: string;
+  projectId: number;
+  clusterId: number;
+  deploymentTargetId: string;
+  jobNames: string[];
+};
+
+const JobsSection: React.FC<Props> = ({
+  appName,
+  projectId,
+  clusterId,
+  deploymentTargetId,
+  jobNames,
+}) => {
+  const { search } = useLocation();
+  const queryParams = new URLSearchParams(search);
+  const serviceFromQueryParams = queryParams.get("service");
+  const jobRunId = queryParams.get("job_run_id");
+  const [selectedJobName, setSelectedJobName] = useState<string>(
+    serviceFromQueryParams != null && jobNames.includes(serviceFromQueryParams) ? serviceFromQueryParams : "all"
+  );
+
+  const jobOptions = useMemo(() => {
+    return [{ label: "All jobs", value: "all" }, ...jobNames.map((name) => {
+      return {
+        label: name,
+        value: name,
+      };
+    })];
+  }, [jobNames]);
+
+  const { jobRuns } = useJobs({
+    appName,
+    projectId,
+    clusterId,
+    deploymentTargetId,
+    selectedJobName,
+  });
+
+  const selectedJobRun = useMemo(() => {
+    return jobRuns.find((jr) => jr.metadata.uid === jobRunId);
+  }, [jobRuns, jobRunId]);
+
+  const columns = useMemo<Column<JobRun>[]>(
+    () => [
+      {
+        Header: "Started",
+        accessor: (originalRow) => relativeDate(originalRow?.status.startTime ?? ''),
+      },
+      {
+        Header: "Run for",
+        Cell: ({ row }) => {
+          let ranForString = "Still running...";
+          if (row.original.status.completionTime) {
+            ranForString = ranFor(
+              row.original.status.startTime ?? row.original.metadata.creationTimestamp,
+              row.original.status.completionTime
+            );
+          } else if (row.original.status.conditions.length > 0 && row.original.status.conditions[0].lastTransitionTime) {
+            ranForString = ranFor(
+              row.original.status.startTime ?? row.original.metadata.creationTimestamp,
+              row.original?.status?.conditions[0]?.lastTransitionTime
+            );
+          }
+
+          return <div>{ranForString}</div>;
+        },
+      },
+      {
+        Header: "Name",
+        id: "job_name",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          return <div>{row.original.jobName}</div>;
+        },
+      },
+      {
+        Header: "Version",
+        id: "version_number",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          return <div>{row.original.revisionNumber}</div>;
+        },
+        maxWidth: 100,
+        styles: {
+          padding: "10px",
+        }
+      },
+      {
+        Header: "Status",
+        id: "status",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          if (row.original.status.succeeded != null && row.original.status.succeeded >= 1) {
+            return <Status color="#38a88a">Succeeded</Status>;
+          }
+
+          if (row.original.status.failed != null && row.original.status.failed >= 1) {
+            return <Status color="#cc3d42">Failed</Status>;
+          }
+
+          return <Status color="#ffffff11">Running</Status>;
+        },
+      },
+
+      {
+        Header: "Details",
+        id: "expand",
+        Cell: ({ row }: CellProps<JobRun>) => {
+          return (
+            <Link to={`/apps/${appName}/job-history?job_run_id=${row.original.metadata.uid}&service=${row.original.jobName}`}>
+              <ExpandButton>
+                <i className="material-icons">open_in_new</i>
+              </ExpandButton>
+            </Link>
+          );
+        },
+        maxWidth: 40,
+      },
+    ],
+    []
+  );
+
+  return (
+    <>
+      {selectedJobRun && (
+        <JobRunDetails
+          jobRun={selectedJobRun}
+        />
+      )}
+      {!selectedJobRun && (
+        <StyledExpandedApp>
+          <Container row>
+            <Icon src={history} />
+            <Text size={21}>Run history for</Text>
+            <SelectRow
+              displayFlex={true}
+              label=""
+              value={selectedJobName}
+              setActiveValue={(x: string) => setSelectedJobName(x)}
+              options={jobOptions}
+              width="200px"
+            />
+          </Container>
+          <Spacer y={1} />
+          <Table
+            columns={columns}
+            disableGlobalFilter
+            data={jobRuns.sort((a, b) => {
+              return Date.parse(a?.metadata?.creationTimestamp) >
+                Date.parse(b?.metadata?.creationTimestamp)
+                ? -1
+                : 1;
+            })}
+            isLoading={jobRuns.length === 0}
+            enablePagination
+          />
+        </StyledExpandedApp>
+      )}
+    </>
+  );
+};
+
+export default JobsSection;
+
+const Icon = styled.img`
+  height: 24px;
+  margin-right: 15px;
+`;
+
+const StyledExpandedApp = styled.div`
+  width: 100%;
+  height: 100%;
+
+  animation: fadeIn 0.5s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+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 ExpandButton = styled.div`
+  user-select: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 5px 5px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;

+ 24 - 0
dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts

@@ -0,0 +1,24 @@
+import { JobRun } from "lib/hooks/useJobs";
+import { timeFrom } from "shared/string_utils";
+import { differenceInSeconds, formatDuration } from 'date-fns';
+
+export const ranFor = (start: string, 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}`;
+};
+
+export const getDuration = (jobRun: JobRun): string => {
+    const startTimeStamp = new Date(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp).getTime();
+
+    const endTimeStamp = jobRun.status.completionTime ? new Date(jobRun.status.completionTime).getTime() : Date.now()
+
+    const timeDifferenceInSeconds = differenceInSeconds(endTimeStamp, startTimeStamp);
+
+    return formatDuration({ seconds: timeDifferenceInSeconds });
+};

+ 9 - 3
dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx

@@ -11,7 +11,7 @@ import styled from "styled-components";
 import spinner from "assets/loading.gif";
 import api from "shared/api";
 import { useLogs } from "./utils";
-import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName, LogFilterQueryParamOpts } from "../../expanded-app/logs/types";
+import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName } from "../../expanded-app/logs/types";
 import dayjs, { Dayjs } from "dayjs";
 import Loading from "components/Loading";
 import _ from "lodash";
@@ -36,6 +36,10 @@ type Props = {
     deploymentTargetId: string;
     appRevisionId?: string;
     logFilterNames?: LogFilterName[];
+    timeRange?: {
+        startTime?: Dayjs;
+        endTime?: Dayjs;
+    };
     filterPredeploy?: boolean;
 };
 
@@ -46,6 +50,7 @@ const Logs: React.FC<Props> = ({
     serviceNames,
     deploymentTargetId,
     appRevisionId,
+    timeRange,
     logFilterNames = ["service_name", "revision", "output_stream"],
     filterPredeploy = false,
 }) => {
@@ -187,6 +192,7 @@ const Logs: React.FC<Props> = ({
         setDate: selectedDate,
         appRevisionId,
         filterPredeploy,
+        timeRange,
     });
 
     useEffect(() => {
@@ -288,7 +294,7 @@ const Logs: React.FC<Props> = ({
                             setSelectedDate={setSelectedDateIfUndefined}
                         />
                         <LogQueryModeSelectionToggle
-                            selectedDate={selectedDate}
+                            selectedDate={selectedDate ?? timeRange?.endTime?.toDate()}
                             setSelectedDate={setSelectedDate}
                             resetSearch={resetSearch}
                         />
@@ -303,7 +309,7 @@ const Logs: React.FC<Props> = ({
                         <Spacer inline width="10px" />
                         <ScrollButton
                             onClick={() => {
-                                refresh();
+                                refresh({ isLive: selectedDate == null && timeRange?.endTime == null });
                             }}
                         >
                             <i className="material-icons">autorenew</i>

+ 18 - 6
dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts

@@ -77,7 +77,7 @@ export const useLogs = ({
   filterPredeploy: boolean,
 }
 ) => {
-  const isLive = !setDate;
+  const [isLive, setIsLive] = useState<boolean>(!setDate && (timeRange?.startTime == null && timeRange?.endTime == null));
   const logsBufferRef = useRef<PorterLog[]>([]);
   const [logs, setLogs] = useState<PorterLog[]>([]);
   const [paginationInfo, setPaginationInfo] = useState<PaginationInfo>({
@@ -177,7 +177,7 @@ export const useLogs = ({
   };
 
   const setupWebsocket = (websocketKey: string) => {
-    const websocketBaseURL = `/api/projects/${projectID}/clusters/${clusterID}/apps/logs/loki`;
+    const websocketBaseURL = `/api/projects/${projectID}/clusters/${clusterID}/apps/${appName}/logs/loki`;
 
     const searchParams = {
       app_name: appName,
@@ -277,6 +277,7 @@ export const useLogs = ({
         {
           cluster_id: clusterID,
           project_id: projectID,
+          porter_app_name: appName,
         }
       )
 
@@ -324,7 +325,7 @@ export const useLogs = ({
     }
   };
 
-  const refresh = async () => {
+  const refresh = async ({ isLive }: { isLive: boolean }) => {
     setLoading(true);
     setLogs([]);
     flushLogsBuffer(true);
@@ -358,7 +359,6 @@ export const useLogs = ({
 
     if (isLive) {
       setupWebsocket(websocketKey);
-
     }
   };
 
@@ -449,8 +449,20 @@ export const useLogs = ({
   }, []);
 
   useEffect(() => {
-    refresh();
-  }, [appName, serviceName, deploymentTargetId, searchParam, setDate, selectedFilterValues]);
+    // if a complete time range is not given, then we are live
+    const isLive = !setDate && (timeRange?.startTime == null || timeRange?.endTime == null);
+    refresh({ isLive });
+    setIsLive(isLive);
+  }, [
+    appName,
+    serviceName,
+    deploymentTargetId,
+    searchParam,
+    setDate,
+    JSON.stringify(selectedFilterValues),
+    JSON.stringify(timeRange?.endTime),
+    filterPredeploy
+  ]);
 
   useEffect(() => {
     // if the streaming is no longer live, close all websockets

+ 1 - 0
dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx

@@ -335,6 +335,7 @@ const MetricsHeader = styled.div`
   align-items: center;
   overflow: visible;
   justify-content: space-between;
+  margin-bottom: 20px;
 `;
 
 const RangeWrapper = styled.div`

+ 2 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -238,6 +238,8 @@ const ServiceContainer: React.FC<ServiceProps> = ({
       </AnimateHeight>
       {status && (
         <ServiceStatusFooter
+          serviceName={service.name.value}
+          isJob={service.config.type === "job"}
           status={status}
         />
       )}

+ 34 - 28
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx

@@ -11,44 +11,50 @@ import _ from "lodash";
 import Link from "components/porter/Link";
 import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
 import { match } from "ts-pattern";
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 
 interface ServiceStatusFooterProps {
+    serviceName: string;
     status: PorterAppVersionStatus[];
+    isJob: boolean,
 }
 const ServiceStatusFooter: React.FC<ServiceStatusFooterProps> = ({
+    serviceName,
     status,
+    isJob
 }) => {
     const [expanded, setExpanded] = useState<boolean>(false);
+    const { latestProto } = useLatestRevision();
     const [height, setHeight] = useState<Height>(0);
 
-    // if (service.type === "job") {
-    //     return (
-    //         <StyledStatusFooter>
-    //             {service.type === "job" && (
-    //                 <Container row>
-    //                     {/*
-    //         <Mi className="material-icons">check</Mi>
-    //         <Text color="helper">
-    //           Last run succeeded at 12:39 PM on 4/13/23
-    //         </Text>
-    //         */}
-    //                     <Link to={`/apps/${chart.name}/job-history?service=${service.name}`}>
-    //                         <Button
-    //                             onClick={() => { }}
-    //                             height="30px"
-    //                             width="87px"
-    //                             color="#ffffff11"
-    //                             withBorder
-    //                         >
-    //                             <I className="material-icons">open_in_new</I>
-    //                             History
-    //                         </Button>
-    //                     </Link>
-    //                 </Container>
-    //             )}
-    //         </StyledStatusFooter>
-    //     );
-    // }
+    if (isJob) {
+        return (
+            <StyledStatusFooter>
+
+                <Container row>
+                    {/*
+            <Mi className="material-icons">check</Mi>
+            <Text color="helper">
+              Last run succeeded at 12:39 PM on 4/13/23
+            </Text>
+            */}
+                    <Link to={`/apps/${latestProto.name}/job-history?service=${serviceName}`}>
+                        <Button
+                            onClick={() => { }}
+                            height="30px"
+                            width="87px"
+                            color="#ffffff11"
+                            withBorder
+                        >
+                            <I className="material-icons">open_in_new</I>
+                            History
+                        </Button>
+                    </Link>
+                </Container>
+
+            </StyledStatusFooter>
+        );
+    }
 
     return (
         <>

+ 55 - 50
dashboard/src/shared/api.tsx

@@ -293,11 +293,28 @@ const appLogs = baseApi<
   {
     project_id: number;
     cluster_id: number;
+    porter_app_name: string;
   }
 >(
   "GET",
-  ({ project_id, cluster_id }) =>
-    `/api/projects/${project_id}/clusters/${cluster_id}/apps/logs`
+  ({ project_id, cluster_id, porter_app_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/logs`
+);
+
+const appJobs = baseApi<
+  {
+    deployment_target_id: string;
+    job_name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, porter_app_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/jobs`
 );
 
 const appPodStatus = baseApi<
@@ -320,9 +337,8 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   let { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
-    page || 1
-  }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
+    }`;
 });
 
 const createEnvironment = baseApi<
@@ -747,11 +763,9 @@ 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 detectGitlabBuildpack = baseApi<
@@ -782,11 +796,9 @@ 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<
@@ -802,11 +814,9 @@ 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 getPorterYamlContents = baseApi<
@@ -822,11 +832,9 @@ const getPorterYamlContents = 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)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -863,11 +871,9 @@ const getBranchHead = 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)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 
 const validatePorterApp = baseApi<
@@ -891,21 +897,21 @@ const validatePorterApp = baseApi<
 
 const createApp = baseApi<
   | {
-      name: string;
-      type: "github";
-      git_repo_id: number;
-      git_branch: string;
-      git_repo_name: string;
-      porter_yaml_path: string;
-    }
+    name: string;
+    type: "github";
+    git_repo_id: number;
+    git_branch: string;
+    git_repo_name: string;
+    porter_yaml_path: string;
+  }
   | {
-      name: string;
-      type: "docker-registry";
-      image: {
-        repository: string;
-        tag: string;
-      };
-    },
+    name: string;
+    type: "docker-registry";
+    image: {
+      repository: string;
+      tag: string;
+    };
+  },
   {
     project_id: number;
     cluster_id: number;
@@ -1880,11 +1886,9 @@ 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<
@@ -2941,7 +2945,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -3009,6 +3013,7 @@ export default {
   createSecretAndOpenGitHubPullRequest,
   getLogsWithinTimeRange,
   appLogs,
+  appJobs,
   appPodStatus,
   getFeedEvents,
   updateStackStep,