ianedwards 2 роки тому
батько
коміт
cb17382a96

+ 57 - 43
api/server/handlers/porter_app/job_status.go

@@ -3,6 +3,8 @@ 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"
@@ -10,9 +12,8 @@ import (
 	"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/deployment_target"
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
@@ -36,8 +37,14 @@ func NewJobStatusHandler(
 
 // 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"`
+	DeploymentTargetID   string `schema:"deployment_target_id,omitempty"`
+	DeploymentTargetName string `schema:"deployment_target_name,omitempty"`
+	JobName              string `schema:"job_name"`
+}
+
+// JobStatusResponse is the response format for GET /apps/jobs
+type JobStatusResponse struct {
+	JobRuns []porter_app.JobRun `json:"job_runs"`
 }
 
 func (c *JobStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -63,57 +70,64 @@ func (c *JobStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	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
+	deploymentTargetName := request.DeploymentTargetName
+	if request.DeploymentTargetName == "" && request.DeploymentTargetID == "" {
+		defaultDeploymentTarget, err := defaultDeploymentTarget(ctx, defaultDeploymentTargetInput{
+			ProjectID:                 project.ID,
+			ClusterID:                 cluster.ID,
+			ClusterControlPlaneClient: c.Config().ClusterControlPlaneClient,
+		})
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting default deployment target")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		deploymentTargetName = defaultDeploymentTarget.Name
 	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
 
-	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
-		ProjectID:          int64(project.ID),
-		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: request.DeploymentTargetID,
-		CCPClient:          c.Config().ClusterControlPlaneClient,
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "deployment-target-name", Value: deploymentTargetName},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
+	)
+
+	jobRunsRequest := connect.NewRequest(&porterv1.JobRunsRequest{
+		ProjectId: int64(project.ID),
+		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
+			Id:   request.DeploymentTargetID,
+			Name: deploymentTargetName,
+		},
+		AppName:        name,
+		JobServiceName: request.JobName,
 	})
+
+	jobRunsResp, err := c.Config().ClusterControlPlaneClient.JobRuns(ctx, jobRunsRequest)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		err := telemetry.Error(ctx, span, err, "error getting job runs from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
-	namespace := deploymentTarget.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")
+	if jobRunsResp == nil || jobRunsResp.Msg == nil {
+		err := telemetry.Error(ctx, span, nil, "job runs response is nil")
 		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,
-		})
+	runs := []porter_app.JobRun{}
+	for _, jobRun := range jobRunsResp.Msg.JobRuns {
+		run, err := porter_app.JobRunFromProto(ctx, jobRun)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error converting job run from proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		runs = append(runs, run)
 	}
-	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
+
+	res := JobStatusResponse{
+		JobRuns: runs,
 	}
 
-	c.WriteResult(w, r, jobs)
+	c.WriteResult(w, r, res)
 }

+ 133 - 0
api/server/handlers/porter_app/job_status_by_name.go

@@ -0,0 +1,133 @@
+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/models"
+	"github.com/porter-dev/porter/internal/porter_app"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// JobStatusByNameHandler is the handler for GET /apps/jobs/{porter_app_name}/{job_run_name}
+type JobStatusByNameHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewJobStatusByNameHandler returns a new JobStatusByNameHandler
+func NewJobStatusByNameHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *JobStatusByNameHandler {
+	return &JobStatusByNameHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// JobStatusByNameRequest is the expected format for a request body on GET /apps/jobs/{porter_app_name}/{job_run_name}
+type JobStatusByNameRequest struct {
+	DeploymentTargetID   string `schema:"deployment_target_id,omitempty"`
+	DeploymentTargetName string `schema:"deployment_target_name,omitempty"`
+	JobRunName           string `schema:"job_run_name"`
+}
+
+// JobStatusByNameResponse is the response format for GET /apps/jobs/{porter_app_name}/{job_run_name}
+type JobStatusByNameResponse struct {
+	JobRun porter_app.JobRun `json:"job_run"`
+}
+
+func (c *JobStatusByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-job-status")
+	defer span.End()
+
+	request := &JobStatusByNameRequest{}
+	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})
+
+	jobRunName, reqErr := requestutils.GetURLParamString(r, types.URLParamJobRunName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "invalid job run name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deploymentTargetName := request.DeploymentTargetName
+	if request.DeploymentTargetName == "" && request.DeploymentTargetID == "" {
+		defaultDeploymentTarget, err := defaultDeploymentTarget(ctx, defaultDeploymentTargetInput{
+			ProjectID:                 project.ID,
+			ClusterID:                 cluster.ID,
+			ClusterControlPlaneClient: c.Config().ClusterControlPlaneClient,
+		})
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting default deployment target")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		deploymentTargetName = defaultDeploymentTarget.Name
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "deployment-target-name", Value: deploymentTargetName},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
+	)
+
+	jobRunsRequest := connect.NewRequest(&porterv1.JobRunStatusRequest{
+		ProjectId: int64(project.ID),
+		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
+			Id:   request.DeploymentTargetID,
+			Name: deploymentTargetName,
+		},
+		JobRunName: jobRunName,
+	})
+
+	jobRunResp, err := c.Config().ClusterControlPlaneClient.JobRunStatus(ctx, jobRunsRequest)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting job run from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if jobRunResp == nil || jobRunResp.Msg == nil {
+		err := telemetry.Error(ctx, span, nil, "job run response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	run, err := porter_app.JobRunFromProto(ctx, jobRunResp.Msg.JobRun)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error converting job run from proto")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := JobStatusByNameResponse{
+		JobRun: run,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 4 - 2
api/server/handlers/porter_app/run_app_job.go

@@ -53,7 +53,8 @@ type RunAppJobRequest struct {
 
 // RunAppJobResponse is the response object for the /apps/{porter_app_name}/run endpoint
 type RunAppJobResponse struct {
-	JobRunID string `json:"job_run_id"`
+	JobRunID   string `json:"job_run_id"`
+	JobRunName string `json:"job_run_name"`
 }
 
 // ServeHTTP runs a one-off command in the same environment as the provided service, app and deployment target
@@ -149,7 +150,8 @@ func (c *RunAppJobHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	response := RunAppJobResponse{
-		JobRunID: serviceResp.Msg.JobRunId,
+		JobRunID:   serviceResp.Msg.JobRunId,
+		JobRunName: serviceResp.Msg.JobRunName,
 	}
 
 	c.WriteResult(w, r, response)

+ 29 - 0
api/server/router/porter_app.go

@@ -1270,6 +1270,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET  /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/jobs/{job_run_name} -> porter_app.JobStatusByNameHandler
+	appJobStatusByNameEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/jobs/{%s}", relPathV2, types.URLParamPorterAppName, types.URLParamJobRunName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appJobStatusByNameHandler := porter_app.NewJobStatusByNameHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appJobStatusByNameEndpoint,
+		Handler:  appJobStatusByNameHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id} -> porter_app.NewGetAppRevisionHandler
 	getAppRevisionEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 0
api/types/request.go

@@ -63,6 +63,7 @@ const (
 	// URLParamDeploymentTargetIdentifier can be either the deployment target id or deployment target name
 	URLParamDeploymentTargetIdentifier URLParam = "deployment_target_identifier"
 	URLParamWebhookID                  URLParam = "webhook_id"
+	URLParamJobRunName                 URLParam = "job_run_name"
 )
 
 type Path struct {

+ 88 - 83
dashboard/src/lib/hooks/useJobs.ts

@@ -1,98 +1,103 @@
-import _ from "lodash";
 import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import _ from "lodash";
+import { z } from "zod";
+
 import api from "shared/api";
+
 import { useRevisionList } 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(),
-            "porter.run/app-id": 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(),
+  id: z.string(),
+  name: z.string(),
+  status: z.enum(["RUNNING", "SUCCESSFUL", "FAILED"]),
+  created_at: z.string(),
+  finished_at: z.string(),
+  app_revision_id: z.string(),
+  service_name: z.string(),
 });
 
-export type JobRun = z.infer<typeof jobRunValidator>;
+export type JobRun = z.infer<typeof jobRunValidator> & {
+  revisionNumber: number;
+};
 
-export const useJobs = (
-    {
-        appName,
-        projectId,
-        clusterId,
-        deploymentTargetId,
-        selectedJobName,
-    }: {
-        appName: string,
-        projectId: number,
-        clusterId: number,
-        deploymentTargetId: string,
-        selectedJobName: string,
-    }
-): {
-    jobRuns: JobRun[],
-    isLoadingJobRuns: boolean,
+export const useJobs = ({
+  appName,
+  projectId,
+  clusterId,
+  deploymentTargetId,
+  selectedJobName,
+}: {
+  appName: string;
+  projectId: number;
+  clusterId: number;
+  deploymentTargetId: string;
+  selectedJobName: string;
+}): {
+  jobRuns: JobRun[];
+  isLoadingJobRuns: boolean;
 } => {
-    const [jobRuns, setJobRuns] = useState<JobRun[]>([]);
+  const [jobRuns, setJobRuns] = useState<JobRun[]>([]);
 
-    const { revisionIdToNumber } = useRevisionList({ appName, deploymentTargetId, projectId, clusterId });
+  const { revisionIdToNumber } = useRevisionList({
+    appName,
+    deploymentTargetId,
+    projectId,
+    clusterId,
+  });
 
-    const { data, isLoading: isLoadingJobRuns } = 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;
-        },
+  const { data, isLoading: isLoadingJobRuns } = useQuery(
+    [
+      "jobRuns",
+      appName,
+      deploymentTargetId,
+      revisionIdToNumber,
+      selectedJobName,
+    ],
+    async () => {
+      const res = await api.appJobs(
+        "<token>",
         {
-            enabled: revisionIdToNumber != null,
-            refetchInterval: 5000,
-            refetchOnWindowFocus: false,
+          deployment_target_id: deploymentTargetId,
+          job_name: selectedJobName === "all" ? "" : selectedJobName,
         },
-    );
-
-    useEffect(() => {
-        if (data != null) {
-            setJobRuns(data);
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: appName,
         }
-    }, [data]);
+      );
+      const { job_runs: runs } = await z
+        .object({
+          job_runs: z.array(jobRunValidator),
+        })
+        .parseAsync(res.data);
+
+      const parsedWithRevision = runs.map((jobRun) => {
+        const revisionId = jobRun.app_revision_id;
+        const revisionNumber = revisionIdToNumber[revisionId];
+        return {
+          ...jobRun,
+          revisionNumber,
+        };
+      });
+      return parsedWithRevision;
+    },
+    {
+      enabled: revisionIdToNumber != null,
+      refetchInterval: 5000,
+      refetchOnWindowFocus: false,
+    }
+  );
+
+  useEffect(() => {
+    if (data != null) {
+      setJobRuns(data);
+    }
+  }, [data]);
 
-    return {
-        jobRuns,
-        isLoadingJobRuns,
-    };
-};
+  return {
+    jobRuns,
+    isLoadingJobRuns,
+  };
+};

+ 100 - 69
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx

@@ -1,85 +1,116 @@
-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 dayjs from "dayjs";
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
 import Container from "components/porter/Container";
-import Logs from "main/home/app-dashboard/validate-apply/logs/Logs";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import Logs from "main/home/app-dashboard/validate-apply/logs/Logs";
 import { type JobRun } from "lib/hooks/useJobs";
-import { match } from "ts-pattern";
-import { getStatusColor } from "../../app-view/tabs/activity-feed/events/utils";
+
+import { readableDate } from "shared/string_utils";
+import loading from "assets/loading.gif";
+
 import { AppearingView } from "../../app-view/tabs/activity-feed/events/focus-views/EventFocusView";
+import { getStatusColor } from "../../app-view/tabs/activity-feed/events/utils";
 import { getDuration } from "./utils";
-import { Link } from "react-router-dom";
-import styled from "styled-components";
-import dayjs from "dayjs";
 
 type Props = {
-    jobRun: JobRun;
+  jobRun: JobRun;
 };
 
-const JobRunDetails: React.FC<Props> = ({
-    jobRun,
-}) => {
-    const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision();
+const JobRunDetails: React.FC<Props> = ({ jobRun }) => {
+  const { projectId, clusterId, latestProto, deploymentTarget, porterApp } =
+    useLatestRevision();
 
-    const appName = latestProto.name
+  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 renderHeaderText = (): JSX.Element => {
+    return match(jobRun)
+      .with({ status: "SUCCESSFUL" }, () => (
+        <Text color={getStatusColor("SUCCESS")} size={16}>
+          Job run succeeded
+        </Text>
+      ))
+      .with({ status: "FAILED" }, () => (
+        <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>);
-    }
+  const renderDurationText = (): JSX.Element => {
+    return match(jobRun)
+      .with({ status: "SUCCESSFUL" }, () => (
+        <Text color="helper">
+          Started {readableDate(jobRun.created_at)} and ran for{" "}
+          {getDuration(jobRun)}.
+        </Text>
+      ))
+      .with({ status: "FAILED" }, () => (
+        <Text color="helper">
+          Started {readableDate(jobRun.created_at)} and ran for{" "}
+          {getDuration(jobRun)}.
+        </Text>
+      ))
+      .otherwise(() => (
+        <Text color="helper">Started {readableDate(jobRun.created_at)}.</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={deploymentTarget.id}
-                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,
-                }}
-                appId={parseInt(jobRun.metadata.labels["porter.run/app-id"])}
-                defaultLatestRevision={false}
-                jobRunID={jobRun.metadata.uid}
-            />
-        </>
-    );
+  return (
+    <>
+      <Link
+        to={
+          deploymentTarget.is_preview
+            ? `/preview-environments/apps/${latestProto.name}/job-history?service=${jobRun.service_name}&target=${deploymentTarget.id}`
+            : `/apps/${latestProto.name}/job-history?service=${jobRun.service_name}`
+        }
+      >
+        <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.service_name]}
+        deploymentTargetId={deploymentTarget.id}
+        appRevisionId={jobRun.app_revision_id}
+        logFilterNames={["service_name"]}
+        timeRange={{
+          startTime: dayjs(jobRun.created_at).subtract(30, "second"),
+          endTime:
+            new Date(jobRun.finished_at) > new Date(jobRun.created_at)
+              ? dayjs(jobRun.finished_at).add(30, "second")
+              : undefined,
+        }}
+        appId={porterApp.id}
+        defaultLatestRevision={false}
+        jobRunID={jobRun.id}
+      />
+    </>
+  );
 };
 
 export default JobRunDetails;
@@ -106,4 +137,4 @@ const BackButton = styled.div`
     font-size: 16px;
     margin-right: 6px;
   }
-`;
+`;

+ 90 - 70
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx

@@ -1,20 +1,24 @@
-import React, { useState, useMemo } from "react";
+import React, { useMemo, useState } from "react";
+import { useLocation } from "react-router";
+import { type Column } from "react-table";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
-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 { type JobRun, useJobs } from "lib/hooks/useJobs";
-import Table from "components/OldTable";
-import { type CellProps, type Column } from "react-table";
-import { relativeDate, timeFrom } from "shared/string_utils";
-import { useLocation } from "react-router";
 import SelectRow from "components/form-components/SelectRow";
+import Table from "components/OldTable";
+import Container from "components/porter/Container";
 import Link from "components/porter/Link";
-import { ranFor } from "./utils";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { useJobs, type JobRun } from "lib/hooks/useJobs";
+
+import { relativeDate } from "shared/string_utils";
+import history from "assets/history.png";
+
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 import JobRunDetails from "./JobRunDetails";
 import TriggerJobButton from "./TriggerJobButton";
+import { ranFor } from "./utils";
 
 type Props = {
   appName: string;
@@ -34,18 +38,24 @@ const JobsSection: React.FC<Props> = ({
   const { search } = useLocation();
   const queryParams = new URLSearchParams(search);
   const serviceFromQueryParams = queryParams.get("service");
-  const jobRunId = queryParams.get("job_run_id");
+  const jobRunName = queryParams.get("job_run_name");
   const [selectedJobName, setSelectedJobName] = useState<string>(
-    serviceFromQueryParams != null && jobNames.includes(serviceFromQueryParams) ? serviceFromQueryParams : "all"
+    serviceFromQueryParams != null && jobNames.includes(serviceFromQueryParams)
+      ? serviceFromQueryParams
+      : "all"
   );
+  const { deploymentTarget } = useLatestRevision();
 
   const jobOptions = useMemo(() => {
-    return [{ label: "All jobs", value: "all" }, ...jobNames.map((name) => {
-      return {
-        label: name,
-        value: name,
-      };
-    })];
+    return [
+      { label: "All jobs", value: "all" },
+      ...jobNames.map((name) => {
+        return {
+          label: name,
+          value: name,
+        };
+      }),
+    ];
   }, [jobNames]);
 
   const { jobRuns, isLoadingJobRuns } = useJobs({
@@ -57,29 +67,25 @@ const JobsSection: React.FC<Props> = ({
   });
 
   const selectedJobRun = useMemo(() => {
-    return jobRuns.find((jr) => jr.metadata.uid === jobRunId);
-  }, [jobRuns, jobRunId]);
+    return jobRuns.find((jr) => jr.name === jobRunName);
+  }, [jobRuns, jobRunName]);
 
   const columns = useMemo<Array<Column<JobRun>>>(
     () => [
       {
         Header: "Started",
-        accessor: (originalRow) => relativeDate(originalRow?.status.startTime ?? ''),
+        accessor: (originalRow) => relativeDate(originalRow.created_at),
       },
       {
         Header: "Run for",
-        Cell: ({ row }) => {
+        Cell: (cell) => {
+          const { original: row } = 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
-            );
+          const startedTime = new Date(row.created_at);
+          const finishedTime = new Date(row.finished_at);
+
+          if (finishedTime > startedTime) {
+            ranForString = ranFor(row.created_at, row.finished_at);
           }
 
           return <div>{ranForString}</div>;
@@ -88,43 +94,54 @@ const JobsSection: React.FC<Props> = ({
       {
         Header: "Name",
         id: "job_name",
-        Cell: ({ row }: CellProps<JobRun>) => {
-          return <div>{row.original.jobName}</div>;
+        Cell: (cell) => {
+          const { original: row } = cell.row;
+
+          return <div>{row.service_name}</div>;
         },
       },
       {
         Header: "Version",
         id: "version_number",
-        Cell: ({ row }: CellProps<JobRun>) => {
-          return <div>{row.original.revisionNumber}</div>;
+        Cell: (cell) => {
+          const { original: row } = cell.row;
+
+          return <div>{row.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>;
+        Cell: (cell) => {
+          const { original: row } = cell.row;
+
+          return match(row.status)
+            .with("SUCCESSFUL", () => (
+              <Status color="#38a88a">Succeeded</Status>
+            ))
+            .with("FAILED", () => <Status color="#cc3d42">Failed</Status>)
+            .otherwise(() => <Status color="#ffffff11">Running</Status>);
         },
       },
 
       {
         Header: "Details",
         id: "expand",
-        Cell: ({ row }: CellProps<JobRun>) => {
+        Cell: (cell) => {
+          const { original: row } = cell.row;
+
           return (
-            <Link to={`/apps/${appName}/job-history?job_run_id=${row.original.metadata.uid}&service=${row.original.jobName}`}>
+            <Link
+              to={
+                deploymentTarget.is_preview
+                  ? `/preview-environments/apps/${appName}/job-history?job_run_name=${row.name}&service=${row.service_name}&target=${deploymentTargetId}`
+                  : `/apps/${appName}/job-history?job_run_name=${row.name}&service=${row.service_name}`
+              }
+            >
               <ExpandButton>
                 <i className="material-icons">open_in_new</i>
               </ExpandButton>
@@ -139,28 +156,32 @@ const JobsSection: React.FC<Props> = ({
 
   return (
     <>
-      {selectedJobRun && (
-        <JobRunDetails
-          jobRun={selectedJobRun}
-        />
-      )}
+      {selectedJobRun && <JobRunDetails jobRun={selectedJobRun} />}
       {!selectedJobRun && (
         <StyledExpandedApp>
           <Container row spaced>
-          <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>
+            <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>
             {selectedJobName !== "all" && (
-              <TriggerJobButton projectId={projectId} clusterId={clusterId} appName={appName} jobName={selectedJobName} deploymentTargetId={deploymentTargetId}/>
+              <TriggerJobButton
+                projectId={projectId}
+                clusterId={clusterId}
+                appName={appName}
+                jobName={selectedJobName}
+                deploymentTargetId={deploymentTargetId}
+              />
             )}
           </Container>
           <Spacer y={1} />
@@ -168,8 +189,7 @@ const JobsSection: React.FC<Props> = ({
             columns={columns}
             disableGlobalFilter
             data={jobRuns.sort((a, b) => {
-              return Date.parse(a?.metadata?.creationTimestamp) >
-                Date.parse(b?.metadata?.creationTimestamp)
+              return Date.parse(a.created_at) > Date.parse(b.created_at)
                 ? -1
                 : 1;
             })}

+ 27 - 20
dashboard/src/main/home/app-dashboard/validate-apply/jobs/TriggerJobButton.tsx

@@ -1,16 +1,18 @@
 import React, { useState } from "react";
 import { useHistory } from "react-router";
+import { z } from "zod";
 
 import Button from "components/porter/Button";
 import Container from "components/porter/Container";
 import Error from "components/porter/Error";
-
+import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import { useIntercom } from "lib/hooks/useIntercom";
+
 import api from "shared/api";
-import {z} from "zod";
 import target from "assets/target.svg";
-import Icon from "components/porter/Icon";
+
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 
 type Props = {
   projectId: number;
@@ -29,6 +31,7 @@ const TriggerJobButton: React.FC<Props> = ({
 }) => {
   const history = useHistory();
   const { showIntercomWithMessage } = useIntercom();
+  const { deploymentTarget } = useLatestRevision();
 
   const [errorMessage, setErrorMessage] = useState("");
   const [status, setStatus] = useState("");
@@ -39,23 +42,27 @@ const TriggerJobButton: React.FC<Props> = ({
 
     try {
       const resp = await api.appRun(
-          "<token>",
-          {
-            deployment_target_id: deploymentTargetId,
-            service_name: jobName,
-          },
-          {
-            project_id: projectId,
-            cluster_id: clusterId,
-            porter_app_name: appName,
-          })
+        "<token>",
+        {
+          deployment_target_id: deploymentTargetId,
+          service_name: jobName,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: appName,
+        }
+      );
 
-      const parsed = await z.object({job_run_id: z.string()}).parseAsync(resp.data)
+      const parsed = await z
+        .object({ job_run_id: z.string(), job_run_name: z.string() })
+        .parseAsync(resp.data);
 
-      const jobRunID = parsed.job_run_id
-      history.push(
-          `/apps/${appName}/job-history?job_run_id=${jobRunID}&service=${jobName}`
-      );
+      const jobRunName = parsed.job_run_name;
+      const route = deploymentTarget.is_preview
+        ? `/preview-environments/apps/${appName}/job-history?job_run_name=${jobRunName}&service=${jobName}&target=${deploymentTargetId}`
+        : `/apps/${appName}/job-history?job_run_name=${jobRunName}&service=${jobName}`;
+      history.push(route);
     } catch {
       setStatus("");
       setErrorMessage("Unable to run job");
@@ -73,8 +80,8 @@ const TriggerJobButton: React.FC<Props> = ({
         status={status}
         height={"33px"}
       >
-        <Icon src={target} height={"15px"}/>
-        <Spacer inline x={.5}/>
+        <Icon src={target} height={"15px"} />
+        <Spacer inline x={0.5} />
         Run once
       </Button>
       {errorMessage !== "" && (

+ 39 - 27
dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts

@@ -1,36 +1,48 @@
+import { differenceInSeconds, intervalToDuration } from "date-fns";
+import { z } from "zod";
+
 import { type JobRun } from "lib/hooks/useJobs";
-import { timeFrom } from "shared/string_utils";
-import { differenceInSeconds, intervalToDuration } from 'date-fns';
+
 import api from "shared/api";
-import {z} from "zod";
+import { timeFrom } from "shared/string_utils";
 
 export const ranFor = (start: string, end?: string | number) => {
-    const duration = timeFrom(start, end);
+  const duration = timeFrom(start, end);
 
-    const unit =
-        duration.time === 1
-            ? duration.unitOfTime.substring(0, duration.unitOfTime.length - 1)
-            : duration.unitOfTime;
+  const unit =
+    duration.time === 1
+      ? duration.unitOfTime.substring(0, duration.unitOfTime.length - 1)
+      : duration.unitOfTime;
 
-    return `${duration.time} ${unit}`;
+  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);
-    const duration = intervalToDuration({ start: 0, end: timeDifferenceInSeconds * 1000 });
-    if (duration.weeks) {
-        return `${duration.weeks}w ${duration.days}d ${duration.hours}h`
-    } else if (duration.days) {
-        return `${duration.days}d ${duration.hours}h ${duration.minutes}m`
-    } else if (duration.hours) {
-        return `${duration.hours}h ${duration.minutes}m ${duration.seconds}s`
-    } else if (duration.minutes) {
-        return `${duration.minutes}m ${duration.seconds}s`
-    } else {
-        return `${duration.seconds}s`
-    }
-};
+  const startTimeStamp = new Date(jobRun.created_at).getTime();
+
+  const finishedAtHasPassed =
+    new Date(jobRun.finished_at) > new Date(jobRun.created_at);
+  const endTimeStamp = finishedAtHasPassed
+    ? new Date(jobRun.finished_at).getTime()
+    : Date.now();
+
+  const timeDifferenceInSeconds = differenceInSeconds(
+    endTimeStamp,
+    startTimeStamp
+  );
+  const duration = intervalToDuration({
+    start: 0,
+    end: timeDifferenceInSeconds * 1000,
+  });
+  if (duration.weeks) {
+    return `${duration.weeks}w ${duration.days}d ${duration.hours}h`;
+  } else if (duration.days) {
+    return `${duration.days}d ${duration.hours}h ${duration.minutes}m`;
+  } else if (duration.hours) {
+    return `${duration.hours}h ${duration.minutes}m ${duration.seconds}s`;
+  } else if (duration.minutes) {
+    return `${duration.minutes}m ${duration.seconds}s`;
+  } else {
+    return `${duration.seconds}s`;
+  }
+};

+ 7 - 1
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/JobFooter.tsx

@@ -20,7 +20,13 @@ const ServiceStatusFooter: React.FC<JobFooterProps> = ({ jobName }) => {
   return (
     <StyledStatusFooter>
       <Container row>
-        <Link to={`/apps/${latestProto.name}/job-history?service=${jobName}`}>
+        <Link
+          to={
+            deploymentTarget.is_preview
+              ? `/preview-environments/apps/${latestProto.name}/job-history?service=${jobName}&target=${deploymentTarget.id}`
+              : `/apps/${latestProto.name}/job-history?service=${jobName}`
+          }
+        >
           <Button
             onClick={() => {}}
             height="30px"

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.108
+	github.com/porter-dev/api-contracts v0.2.110
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1523,8 +1523,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.108 h1:HLJUiabAOJdnLtHDGwqFnJ9LQIpSYCufZR1100vaLbU=
-github.com/porter-dev/api-contracts v0.2.108/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.110 h1:/yfUCX4TtnynnqL4zUYMD+U96hkLDvgQqlrOuhZF7Ao=
+github.com/porter-dev/api-contracts v0.2.110/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 84 - 0
internal/porter_app/job_runs.go

@@ -0,0 +1,84 @@
+package porter_app
+
+import (
+	"context"
+	"time"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// JobRunStatus represents the status of a job run
+type JobRunStatus string
+
+// JobRun is a representation of a job run on the cluster
+type JobRun struct {
+	// ID is the UID of the job
+	ID string `json:"id"`
+	// Name is the name of the job object
+	Name string `json:"name"`
+	// Status is the status of the job run
+	Status JobRunStatus `json:"status"`
+	// CreatedAt is the time the job was created
+	CreatedAt time.Time `json:"created_at"`
+	// FinishedAt is the time the job finished, if applicable
+	FinishedAt time.Time `json:"finished_at"`
+	// AppRevisionID is the ID of the app revision associated with this run
+	AppRevisionID string `json:"app_revision_id"`
+	// ServiceName is the name of the job service on the app
+	ServiceName string `json:"service_name"`
+}
+
+const (
+	// JobRunStatus_Running represents a job run that is currently running
+	JobRunStatus_Running JobRunStatus = "RUNNING"
+	// JobRunStatus_Successful represents a job run that has completed successfully
+	JobRunStatus_Successful JobRunStatus = "SUCCESSFUL"
+	// JobRunStatus_Failed represents a job run that has failed
+	JobRunStatus_Failed JobRunStatus = "FAILED"
+)
+
+// JobRunFromProto converts a job run proto to a JobRun
+func JobRunFromProto(ctx context.Context, jobRun *porterv1.JobRun) (JobRun, error) {
+	ctx, span := telemetry.NewSpan(ctx, "porter-app-job-runs-from-proto")
+	defer span.End()
+
+	var run JobRun
+
+	if jobRun == nil {
+		return run, telemetry.Error(ctx, span, nil, "job run is nil")
+	}
+
+	status, err := jobStatusFromProto(ctx, jobRun.Status)
+	if err != nil {
+		return run, telemetry.Error(ctx, span, err, "job status from proto")
+	}
+
+	run = JobRun{
+		ID:            jobRun.Id,
+		Name:          jobRun.JobName,
+		ServiceName:   jobRun.ServiceName,
+		Status:        status,
+		CreatedAt:     jobRun.CreatedAt.AsTime(),
+		FinishedAt:    jobRun.FinishedAt.AsTime(),
+		AppRevisionID: jobRun.AppRevisionId,
+	}
+
+	return run, nil
+}
+
+func jobStatusFromProto(ctx context.Context, status porterv1.EnumJobRunStatus) (JobRunStatus, error) {
+	ctx, span := telemetry.NewSpan(ctx, "porter-app-job-status-from-proto")
+	defer span.End()
+
+	switch status {
+	case porterv1.EnumJobRunStatus_ENUM_JOB_RUN_STATUS_RUNNING:
+		return JobRunStatus_Running, nil
+	case porterv1.EnumJobRunStatus_ENUM_JOB_RUN_STATUS_SUCCESSFUL:
+		return JobRunStatus_Successful, nil
+	case porterv1.EnumJobRunStatus_ENUM_JOB_RUN_STATUS_FAILED:
+		return JobRunStatus_Failed, nil
+	default:
+		return "", telemetry.Error(ctx, span, nil, "invalid job status")
+	}
+}