Prechádzať zdrojové kódy

cancel job run from dashboard (#4463)

ianedwards 2 rokov pred
rodič
commit
361483f72f

+ 105 - 0
api/server/handlers/porter_app/job_run_cancel.go

@@ -0,0 +1,105 @@
+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/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/telemetry"
+)
+
+// CancelJobRunHandler is the handler for POST /apps/jobs/{porter_app_name}/jobs/{job_run_name}/cancel
+type CancelJobRunHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCancelJobRunHandler returns a new CancelJobRunHandler
+func NewCancelJobRunHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CancelJobRunHandler {
+	return &CancelJobRunHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// CancelJobRunRequest is the expected format for a request body on POST /apps/jobs/{porter_app_name}/jobs/{job_run_name}/cancel
+type CancelJobRunRequest struct {
+	DeploymentTargetID   string `json:"deployment_target_id,omitempty" validate:"optional"`
+	DeploymentTargetName string `json:"deployment_target_name,omitempty" validate:"optional"`
+}
+
+// CancelJobRunResponse is the response format for POST /apps/jobs/{porter_app_name}/jobs/{job_run_name}/cancel
+type CancelJobRunResponse struct{}
+
+// ServeHTTP handles the cancel job run request
+func (c *CancelJobRunHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-cancel-job-run")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &CancelJobRunRequest{}
+	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
+	}
+
+	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
+	}
+
+	deploymentTargetID := request.DeploymentTargetID
+	deploymentTargetName := request.DeploymentTargetName
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID},
+		telemetry.AttributeKV{Key: "deployment-target-name", Value: deploymentTargetName},
+	)
+
+	var deploymentTargetIdentifer *porterv1.DeploymentTargetIdentifier
+	if deploymentTargetID != "" || deploymentTargetName != "" {
+		deploymentTargetIdentifer = &porterv1.DeploymentTargetIdentifier{
+			Id:   deploymentTargetID,
+			Name: deploymentTargetName,
+		}
+	}
+
+	cancelJobRunRequest := connect.NewRequest(&porterv1.CancelJobRunRequest{
+		ProjectId:                  int64(project.ID),
+		ClusterId:                  int64(cluster.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifer,
+		JobRunName:                 jobRunName,
+	})
+
+	_, err := c.Config().ClusterControlPlaneClient.CancelJobRun(ctx, cancelJobRunRequest)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error canceling job run")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := &CancelJobRunResponse{}
+
+	c.WriteResult(w, r, res)
+}

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

@@ -1328,6 +1328,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/jobs/{job_run_name}/cancel -> porter_app.CancelJobRunHandler
+	appJobCancelEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/jobs/{%s}/cancel", relPathV2, types.URLParamPorterAppName, types.URLParamJobRunName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appJobCancelHandler := porter_app.NewCancelJobRunHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appJobCancelEndpoint,
+		Handler:  appJobCancelHandler,
+		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{

+ 5 - 0
dashboard/src/assets/cancel.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g opacity="0.9">
+<path d="M8.4001 15.5999L15.6001 8.3999M12.0001 21.5999C6.69816 21.5999 2.4001 17.3018 2.4001 11.9999C2.4001 6.69797 6.69816 2.3999 12.0001 2.3999C17.302 2.3999 21.6001 6.69797 21.6001 11.9999C21.6001 17.3018 17.302 21.5999 12.0001 21.5999Z" stroke="white" stroke-width="2" stroke-linecap="round"/>
+</g>
+</svg>

+ 2 - 8
dashboard/src/lib/hooks/useJobs.ts

@@ -10,7 +10,7 @@ import { useRevisionList } from "./useRevisionList";
 const jobRunValidator = z.object({
   id: z.string(),
   name: z.string(),
-  status: z.enum(["RUNNING", "SUCCESSFUL", "FAILED"]),
+  status: z.enum(["RUNNING", "SUCCESSFUL", "FAILED", "CANCELED"]),
   created_at: z.string(),
   finished_at: z.string(),
   app_revision_id: z.string(),
@@ -47,13 +47,7 @@ export const useJobs = ({
   });
 
   const { data, isLoading: isLoadingJobRuns } = useQuery(
-    [
-      "jobRuns",
-      appName,
-      deploymentTargetId,
-      revisionIdToNumber,
-      selectedJobName,
-    ],
+    ["jobRuns", appName, deploymentTargetId, selectedJobName],
     async () => {
       const res = await api.appJobs(
         "<token>",

+ 90 - 14
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx

@@ -1,18 +1,24 @@
-import React from "react";
+import React, { useCallback, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
 import dayjs from "dayjs";
-import { Link } from "react-router-dom";
+import { Link, useHistory } from "react-router-dom";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 
+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 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 { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster";
 import { type JobRun } from "lib/hooks/useJobs";
 
+import api from "shared/api";
 import { readableDate } from "shared/string_utils";
+import cancel from "assets/cancel.svg";
 import loading from "assets/loading.gif";
 
 import { AppearingView } from "../../app-view/tabs/activity-feed/events/focus-views/EventFocusView";
@@ -24,8 +30,12 @@ type Props = {
 };
 
 const JobRunDetails: React.FC<Props> = ({ jobRun }) => {
+  const queryClient = useQueryClient();
   const { projectId, clusterId, latestProto, deploymentTarget, porterApp } =
     useLatestRevision();
+  const history = useHistory();
+  const [jobRunCancelling, setJobRunCancelling] = useState<boolean>(false);
+  const [jobRunCancelError, setJobRunCancelError] = useState<string>("");
 
   const appName = latestProto.name;
 
@@ -41,6 +51,11 @@ const JobRunDetails: React.FC<Props> = ({ jobRun }) => {
           Job run failed
         </Text>
       ))
+      .with({ status: "CANCELED" }, () => (
+        <Text color={getStatusColor("CANCELED")} size={16}>
+          Job run canceled
+        </Text>
+      ))
       .otherwise(() => (
         <Container row>
           <Icon height="16px" src={loading} />
@@ -52,6 +67,43 @@ const JobRunDetails: React.FC<Props> = ({ jobRun }) => {
       ));
   };
 
+  const cancelRun = useCallback(async () => {
+    try {
+      setJobRunCancelling(true);
+      setJobRunCancelError("");
+
+      await api.cancelJob(
+        "<token>",
+        {
+          deployment_target_id: deploymentTarget.id,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: appName,
+          job_run_name: jobRun.name,
+        }
+      );
+
+      await queryClient.invalidateQueries([
+        "jobRuns",
+        appName,
+        deploymentTarget.id,
+        jobRun.name,
+      ]);
+
+      history.push(
+        `/apps/${appName}/job-history?service=${jobRun.service_name}`
+      );
+    } catch (err) {
+      setJobRunCancelError(
+        getErrorMessageFromNetworkCall(err, "Error canceling job run")
+      );
+    } finally {
+      setJobRunCancelling(false);
+    }
+  }, [jobRun.name, deploymentTarget.id, projectId, clusterId, appName]);
+
   const renderDurationText = (): JSX.Element => {
     return match(jobRun)
       .with({ status: "SUCCESSFUL" }, () => (
@@ -73,18 +125,42 @@ const JobRunDetails: React.FC<Props> = ({ jobRun }) => {
 
   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>
+      <Container row spaced>
+        <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>
+        {jobRun.status === "RUNNING" && (
+          <Button
+            color="red"
+            onClick={() => {
+              void cancelRun();
+            }}
+            disabled={jobRunCancelling}
+            status={
+              jobRunCancelling ? (
+                "loading"
+              ) : jobRunCancelError ? (
+                <Error message={jobRunCancelError} />
+              ) : (
+                ""
+              )
+            }
+          >
+            <Icon src={cancel} height={"15px"} />
+            <Spacer inline x={0.5} />
+            Cancel Run
+          </Button>
+        )}
+      </Container>
       <Spacer y={0.5} />
       <AppearingView>{renderHeaderText()}</AppearingView>
       <Spacer y={0.5} />

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

@@ -16,6 +16,7 @@ import { relativeDate } from "shared/string_utils";
 import history from "assets/history.png";
 
 import { useLatestRevision } from "../../app-view/LatestRevisionContext";
+import { getStatusColor } from "../../app-view/tabs/activity-feed/events/utils";
 import JobRunDetails from "./JobRunDetails";
 import TriggerJobButton from "./TriggerJobButton";
 import { ranFor } from "./utils";
@@ -124,6 +125,9 @@ const JobsSection: React.FC<Props> = ({
               <Status color="#38a88a">Succeeded</Status>
             ))
             .with("FAILED", () => <Status color="#cc3d42">Failed</Status>)
+            .with("CANCELED", () => (
+              <Status color={getStatusColor(row.status)}>Canceled</Status>
+            ))
             .otherwise(() => <Status color="#ffffff11">Running</Status>);
         },
       },

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

@@ -336,6 +336,20 @@ const appJobs = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/jobs`
 );
 
+const cancelJob = baseApi<
+  {
+    deployment_target_id: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+    job_run_name: string;
+  }
+>("POST", ({ project_id, cluster_id, porter_app_name, job_run_name }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/jobs/${job_run_name}/cancel`;
+});
+
 const appServiceStatus = baseApi<
   {
     deployment_target_id: string;
@@ -3590,6 +3604,7 @@ export default {
   getLogsWithinTimeRange,
   appLogs,
   appJobs,
+  cancelJob,
   appEvents,
   appServiceStatus,
   getFeedEvents,

+ 1 - 1
go.mod

@@ -84,7 +84,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.136
+	github.com/porter-dev/api-contracts v0.2.137
 	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

@@ -1525,8 +1525,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.136 h1:Z3tjl9johlJvER5sYuzYa3QZek4GWS0LUpXpbXi5zZ8=
-github.com/porter-dev/api-contracts v0.2.136/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.137 h1:ABs9mdt1wQFQZraaNdLw3s0OhSGGWKYwLIBKrNdd7PY=
+github.com/porter-dev/api-contracts v0.2.137/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 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=

+ 4 - 0
internal/porter_app/job_runs.go

@@ -36,6 +36,8 @@ const (
 	JobRunStatus_Successful JobRunStatus = "SUCCESSFUL"
 	// JobRunStatus_Failed represents a job run that has failed
 	JobRunStatus_Failed JobRunStatus = "FAILED"
+	// JobRunStatus_Canceled represents a job run that has been cancelled
+	JobRunStatus_Canceled JobRunStatus = "CANCELED"
 )
 
 // JobRunFromProto converts a job run proto to a JobRun
@@ -78,6 +80,8 @@ func jobStatusFromProto(ctx context.Context, status porterv1.EnumJobRunStatus) (
 		return JobRunStatus_Successful, nil
 	case porterv1.EnumJobRunStatus_ENUM_JOB_RUN_STATUS_FAILED:
 		return JobRunStatus_Failed, nil
+	case porterv1.EnumJobRunStatus_ENUM_JOB_RUN_STATUS_CANCELED:
+		return JobRunStatus_Canceled, nil
 	default:
 		return "", telemetry.Error(ctx, span, nil, "invalid job status")
 	}