Skip to content

Commit

Permalink
cancel job run from dashboard (#4463)
Browse files Browse the repository at this point in the history
  • Loading branch information
ianedwards authored Mar 28, 2024
1 parent 39d5c31 commit 361483f
Show file tree
Hide file tree
Showing 10 changed files with 257 additions and 25 deletions.
105 changes: 105 additions & 0 deletions api/server/handlers/porter_app/job_run_cancel.go
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions api/server/router/porter_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
5 changes: 5 additions & 0 deletions dashboard/src/assets/cancel.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 2 additions & 8 deletions dashboard/src/lib/hooks/useJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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>",
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand All @@ -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} />
Expand All @@ -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" }, () => (
Expand All @@ -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} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>);
},
},
Expand Down
15 changes: 15 additions & 0 deletions dashboard/src/shared/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3590,6 +3604,7 @@ export default {
getLogsWithinTimeRange,
appLogs,
appJobs,
cancelJob,
appEvents,
appServiceStatus,
getFeedEvents,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading

0 comments on commit 361483f

Please sign in to comment.