diff --git a/api/server/handlers/porter_app/job_status.go b/api/server/handlers/porter_app/job_status.go index ff59367d6b..220bc3f28a 100644 --- a/api/server/handlers/porter_app/job_status.go +++ b/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) } diff --git a/api/server/handlers/porter_app/job_status_by_name.go b/api/server/handlers/porter_app/job_status_by_name.go new file mode 100644 index 0000000000..39e823e5d3 --- /dev/null +++ b/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) +} diff --git a/api/server/handlers/porter_app/run_app_job.go b/api/server/handlers/porter_app/run_app_job.go index d0dfed1db1..c9e149b043 100644 --- a/api/server/handlers/porter_app/run_app_job.go +++ b/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) diff --git a/api/server/router/porter_app.go b/api/server/router/porter_app.go index cbd18e0607..8e98ebb61e 100644 --- a/api/server/router/porter_app.go +++ b/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{ diff --git a/api/types/request.go b/api/types/request.go index 59f7c90b29..926a56ad8a 100644 --- a/api/types/request.go +++ b/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 { diff --git a/dashboard/src/lib/hooks/useJobs.ts b/dashboard/src/lib/hooks/useJobs.ts index e9c941bf7e..223123e223 100644 --- a/dashboard/src/lib/hooks/useJobs.ts +++ b/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; +export type JobRun = z.infer & { + 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([]); + const [jobRuns, setJobRuns] = useState([]); - 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( - "", - { - 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( + "", { - 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, - }; -}; \ No newline at end of file + return { + jobRuns, + isLoadingJobRuns, + }; +}; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx index 82206e5b3c..20ea4e24e9 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx +++ b/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 = ({ - jobRun, -}) => { - const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision(); +const JobRunDetails: React.FC = ({ 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 } }, () => Job run succeeded) - .with({ status: { failed: 1 } }, () => Job run failed) - .otherwise(() => ( - - - - Job run in progress... - - )); - }; + const renderHeaderText = (): JSX.Element => { + return match(jobRun) + .with({ status: "SUCCESSFUL" }, () => ( + + Job run succeeded + + )) + .with({ status: "FAILED" }, () => ( + + Job run failed + + )) + .otherwise(() => ( + + + + + Job run in progress... + + + )); + }; - const renderDurationText = () => { - return match(jobRun) - .with({ status: { succeeded: 1 } }, () => Started {readableDate(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp)} and ran for {getDuration(jobRun)}.) - .with({ status: { failed: 1 } }, () => Started {readableDate(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp)} and ran for {getDuration(jobRun)}.) - .otherwise(() => Started {readableDate(jobRun.status.startTime ?? jobRun.metadata.creationTimestamp)}.); - } + const renderDurationText = (): JSX.Element => { + return match(jobRun) + .with({ status: "SUCCESSFUL" }, () => ( + + Started {readableDate(jobRun.created_at)} and ran for{" "} + {getDuration(jobRun)}. + + )) + .with({ status: "FAILED" }, () => ( + + Started {readableDate(jobRun.created_at)} and ran for{" "} + {getDuration(jobRun)}. + + )) + .otherwise(() => ( + Started {readableDate(jobRun.created_at)}. + )); + }; - return ( - <> - - - keyboard_backspace - Job run history - - - - - {renderHeaderText()} - - - {renderDurationText()} - - - - ); + return ( + <> + + + keyboard_backspace + Job run history + + + + {renderHeaderText()} + + {renderDurationText()} + + 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; } -`; \ No newline at end of file +`; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx index dec4963ae0..9610bafa56 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx +++ b/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 = ({ 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( - 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 = ({ }); 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>>( () => [ { 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
{ranForString}
; @@ -88,43 +94,54 @@ const JobsSection: React.FC = ({ { Header: "Name", id: "job_name", - Cell: ({ row }: CellProps) => { - return
{row.original.jobName}
; + Cell: (cell) => { + const { original: row } = cell.row; + + return
{row.service_name}
; }, }, { Header: "Version", id: "version_number", - Cell: ({ row }: CellProps) => { - return
{row.original.revisionNumber}
; + Cell: (cell) => { + const { original: row } = cell.row; + + return
{row.revisionNumber}
; }, maxWidth: 100, styles: { padding: "10px", - } + }, }, { Header: "Status", id: "status", - Cell: ({ row }: CellProps) => { - if (row.original.status.succeeded != null && row.original.status.succeeded >= 1) { - return Succeeded; - } - - if (row.original.status.failed != null && row.original.status.failed >= 1) { - return Failed; - } - - return Running; + Cell: (cell) => { + const { original: row } = cell.row; + + return match(row.status) + .with("SUCCESSFUL", () => ( + Succeeded + )) + .with("FAILED", () => Failed) + .otherwise(() => Running); }, }, { Header: "Details", id: "expand", - Cell: ({ row }: CellProps) => { + Cell: (cell) => { + const { original: row } = cell.row; + return ( - + open_in_new @@ -139,28 +156,32 @@ const JobsSection: React.FC = ({ return ( <> - {selectedJobRun && ( - - )} + {selectedJobRun && } {!selectedJobRun && ( - - - Run history for - { setSelectedJobName(x); }} - options={jobOptions} - width="200px" - /> - + + + Run history for + { + setSelectedJobName(x); + }} + options={jobOptions} + width="200px" + /> + {selectedJobName !== "all" && ( - + )} @@ -168,8 +189,7 @@ const JobsSection: React.FC = ({ 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; })} diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/TriggerJobButton.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/TriggerJobButton.tsx index 5b932e3f03..2d4ac00dcb 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/TriggerJobButton.tsx +++ b/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 = ({ }) => { 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 = ({ try { const resp = await api.appRun( - "", - { - deployment_target_id: deploymentTargetId, - service_name: jobName, - }, - { - project_id: projectId, - cluster_id: clusterId, - porter_app_name: appName, - }) + "", + { + 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 = ({ status={status} height={"33px"} > - - + + Run once {errorMessage !== "" && ( diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts index fdc3dacbb5..25b4ad5b44 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts +++ b/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` - } -}; \ No newline at end of file + 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`; + } +}; diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/JobFooter.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/JobFooter.tsx index 8d8a188e5f..39b90a951e 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/JobFooter.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/footers/JobFooter.tsx @@ -20,7 +20,13 @@ const ServiceStatusFooter: React.FC = ({ jobName }) => { return ( - +