Skip to content

Commit

Permalink
[POR-1673] Implement Job History in New View (#3614)
Browse files Browse the repository at this point in the history
  • Loading branch information
Feroze Mohideen authored Sep 20, 2023
1 parent 8b662a3 commit a208a76
Show file tree
Hide file tree
Showing 20 changed files with 790 additions and 132 deletions.
132 changes: 132 additions & 0 deletions api/server/handlers/porter_app/job_status.go
Original file line number Diff line number Diff line change
@@ -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)
}
33 changes: 31 additions & 2 deletions api/server/router/porter_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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{
Expand Down
23 changes: 18 additions & 5 deletions dashboard/src/components/OldTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import {
Column,
Expand All @@ -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,
Expand Down Expand Up @@ -78,6 +79,7 @@ const Table: React.FC<TableProps> = ({
onRefresh,
isRefreshing = false,
}) => {
const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
const {
getTableProps,
getTableBodyProps,
Expand All @@ -101,6 +103,10 @@ const Table: React.FC<TableProps> = ({
{
columns: columnsData,
data,
initialState: {
pageIndex: currentPageIndex,
},
autoResetPage: false,
},
useGlobalFilter,
usePagination
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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"};
Expand Down
2 changes: 0 additions & 2 deletions dashboard/src/components/form-components/SelectRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 93 additions & 0 deletions dashboard/src/lib/hooks/useJobs.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading

0 comments on commit a208a76

Please sign in to comment.