Skip to content

Commit

Permalink
add manual job run button (#3933)
Browse files Browse the repository at this point in the history
Co-authored-by: David Townley <[email protected]>
  • Loading branch information
d-g-town and David Townley authored Nov 6, 2023
1 parent b9a3cf0 commit c7a964b
Show file tree
Hide file tree
Showing 14 changed files with 313 additions and 34 deletions.
117 changes: 117 additions & 0 deletions api/server/handlers/porter_app/app_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package porter_app

import (
"net/http"

"github.com/porter-dev/porter/api/server/authz"
"github.com/porter-dev/porter/api/server/shared/requestutils"

"connectrpc.com/connect"

porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"

"github.com/porter-dev/porter/internal/telemetry"

"github.com/porter-dev/porter/api/server/handlers"
"github.com/porter-dev/porter/api/server/shared"
"github.com/porter-dev/porter/api/server/shared/apierrors"
"github.com/porter-dev/porter/api/server/shared/config"
"github.com/porter-dev/porter/api/types"
"github.com/porter-dev/porter/internal/models"
)

// AppRunHandler handles requests to the /apps/{porter_app_name}/run endpoint
type AppRunHandler struct {
handlers.PorterHandlerReadWriter
authz.KubernetesAgentGetter
}

// NewAppRunHandler returns a new AppRunHandler
func NewAppRunHandler(
config *config.Config,
decoderValidator shared.RequestDecoderValidator,
writer shared.ResultWriter,
) *AppRunHandler {
return &AppRunHandler{
PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
}
}

// AppRunRequest is the request object for the /apps/{porter_app_name}/run endpoint
type AppRunRequest struct {
ServiceName string `json:"service_name"`
DeploymentTargetID string `json:"deployment_target_id"`
}

// AppRunResponse is the response object for the /apps/{porter_app_name}/run endpoint
type AppRunResponse struct {
JobRunID string `json:"job_run_id"`
}

// ServeHTTP runs a one-off command in the same environment as the provided service, app and deployment target
func (c *AppRunHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := telemetry.NewSpan(r.Context(), "serve-app-run")
defer span.End()

project, _ := ctx.Value(types.ProjectScope).(*models.Project)

appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
if reqErr != nil {
e := telemetry.Error(ctx, span, reqErr, "error parsing app name from url")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
return
}

telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})

request := &AppRunRequest{}
if ok := c.DecodeAndValidate(w, r, request); !ok {
err := telemetry.Error(ctx, span, nil, "error decoding request")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}

if request.ServiceName == "" {
err := telemetry.Error(ctx, span, nil, "service name is required")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: request.ServiceName})

if request.DeploymentTargetID == "" {
err := telemetry.Error(ctx, span, nil, "deployment target id is required")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
return
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})

manualServiceRunReq := connect.NewRequest(&porterv1.ManualServiceRunRequest{
ProjectId: int64(project.ID),
AppName: appName,
ServiceName: request.ServiceName,
Command: nil, // use default command for job
DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
Id: request.DeploymentTargetID,
},
})

serviceResp, err := c.Config().ClusterControlPlaneClient.ManualServiceRun(ctx, manualServiceRunReq)
if err != nil {
err := telemetry.Error(ctx, span, err, "error getting app helm values from cluster control plane client")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

if serviceResp == nil || serviceResp.Msg == nil {
err := telemetry.Error(ctx, span, err, "app helm values resp is nil")
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

response := AppRunResponse{
JobRunID: serviceResp.Msg.JobRunId,
}

c.WriteResult(w, r, response)
}
29 changes: 29 additions & 0 deletions api/server/router/porter_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1531,5 +1531,34 @@ func getPorterAppRoutes(
Router: r,
})

// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{app_name}/run -> porter_app.NewAppRunHandler
appRunEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Verb: types.APIVerbUpdate,
Method: types.HTTPVerbPost,
Path: &types.Path{
Parent: basePath,
RelativePath: fmt.Sprintf("%s/{%s}/run", relPathV2, types.URLParamPorterAppName),
},
Scopes: []types.PermissionScope{
types.UserScope,
types.ProjectScope,
types.ClusterScope,
},
},
)

appRunHandler := porter_app.NewAppRunHandler(
config,
factory.GetDecoderValidator(),
factory.GetResultWriter(),
)

routes = append(routes, &router.Route{
Endpoint: appRunEndpoint,
Handler: appRunHandler,
Router: r,
})

return routes, newPath
}
3 changes: 3 additions & 0 deletions dashboard/src/assets/target.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 6 additions & 5 deletions dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { z } from "zod";
import { AnserJsonEntry } from "anser";
import { type AnserJsonEntry } from "anser";

export enum Direction {
forward = "forward",
backward = "backward",
}

export interface PorterLog {
export type PorterLog = {
line: AnserJsonEntry[];
lineNumber: number;
timestamp?: string;
metadata?: z.infer<typeof agentLogMetadataValidator>;
}

export interface PaginationInfo {
export type PaginationInfo = {
previousCursor: string | null;
nextCursor: string | null;
}
Expand All @@ -25,6 +25,7 @@ const rawLabelsValidator = z.object({
porter_run_app_revision_id: z.string().optional(),
porter_run_service_name: z.string().optional(),
porter_run_service_type: z.string().optional(),
controller_uid: z.string().optional(),
});
export type RawLabels = z.infer<typeof rawLabelsValidator>;

Expand All @@ -44,7 +45,7 @@ export const agentLogValidator = z.object({
});
export type AgentLog = z.infer<typeof agentLogValidator>;

export interface GenericFilterOption {
export type GenericFilterOption = {
label: string;
value: string;
}
Expand All @@ -54,7 +55,7 @@ export const GenericFilterOption = {
}
}
export type FilterName = 'revision' | 'output_stream' | 'pod_name' | 'service_name' | 'revision_id';
export interface GenericFilter {
export type GenericFilter = {
name: FilterName;
displayName: string;
default: GenericFilterOption | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 { type 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";
Expand Down Expand Up @@ -75,6 +75,8 @@ const JobRunDetails: React.FC<Props> = ({
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}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ 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 { type JobRun, useJobs } from "lib/hooks/useJobs";
import Table from "components/OldTable";
import { CellProps, Column } from "react-table";
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 Link from "components/porter/Link";
import { ranFor } from "./utils";
import JobRunDetails from "./JobRunDetails";
import TriggerJobButton from "./TriggerJobButton";

type Props = {
appName: string;
Expand Down Expand Up @@ -59,7 +60,7 @@ const JobsSection: React.FC<Props> = ({
return jobRuns.find((jr) => jr.metadata.uid === jobRunId);
}, [jobRuns, jobRunId]);

const columns = useMemo<Column<JobRun>[]>(
const columns = useMemo<Array<Column<JobRun>>>(
() => [
{
Header: "Started",
Expand Down Expand Up @@ -145,17 +146,22 @@ const JobsSection: React.FC<Props> = ({
)}
{!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)}
setActiveValue={(x: string) => { setSelectedJobName(x); }}
options={jobOptions}
width="200px"
/>
</Container>
{selectedJobName !== "all" && (
<TriggerJobButton projectId={projectId} clusterId={clusterId} appName={appName} jobName={selectedJobName} deploymentTargetId={deploymentTargetId}/>
)}
</Container>
<Spacer y={1} />
<Table
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useState } from "react";
import { useHistory } from "react-router";

import Button from "components/porter/Button";
import Container from "components/porter/Container";
import Error from "components/porter/Error";

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";

type Props = {
projectId: number;
clusterId: number;
appName: string;
jobName: string;
deploymentTargetId: string;
};

const TriggerJobButton: React.FC<Props> = ({
projectId,
clusterId,
appName,
jobName,
deploymentTargetId,
}) => {
const history = useHistory();
const { showIntercomWithMessage } = useIntercom();

const [errorMessage, setErrorMessage] = useState("");
const [status, setStatus] = useState("");

const triggerJobRun = async (): Promise<void> => {
setStatus("loading");
setErrorMessage("");

try {
const resp = await api.appRun(
"<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 jobRunID = parsed.job_run_id
history.push(
`/apps/${appName}/job-history?job_run_id=${jobRunID}&service=${jobName}`
);
} catch {
setStatus("");
setErrorMessage("Unable to run job");
showIntercomWithMessage({
message: "I am running into an issue running my job.",
});
}
};

return (
<Container row>
<Button
onClick={triggerJobRun}
loadingText={"Running..."}
status={status}
height={"33px"}
>
<Icon src={target} height={"15px"}/>
<Spacer inline x={.5}/>
Run once
</Button>
{errorMessage !== "" && (
<>
<Spacer x={1} inline /> <Error message={errorMessage} />
</>
)}
</Container>
);
};

export default TriggerJobButton;
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { JobRun } from "lib/hooks/useJobs";
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";

export const ranFor = (start: string, end?: string | number) => {
const duration = timeFrom(start, end);
Expand Down
Loading

0 comments on commit c7a964b

Please sign in to comment.