From a208a76938f820ddfc1ec2c4cc82af565ac51040 Mon Sep 17 00:00:00 2001 From: Feroze Mohideen Date: Wed, 20 Sep 2023 16:46:27 -0400 Subject: [PATCH] [POR-1673] Implement Job History in New View (#3614) --- api/server/handlers/porter_app/job_status.go | 132 ++++++++++ api/server/router/porter_app.go | 33 ++- dashboard/src/components/OldTable.tsx | 23 +- .../components/form-components/SelectRow.tsx | 2 - dashboard/src/lib/hooks/useJobs.ts | 93 +++++++ .../app-view/AppDataContainer.tsx | 16 +- .../app-dashboard/app-view/tabs/JobsTab.tsx | 23 ++ .../app-dashboard/app-view/tabs/Overview.tsx | 2 +- .../events/cards/AppEventCard.tsx | 1 + .../tabs/activity-feed/events/utils.ts | 30 +-- .../expanded-app/logs/LogSection.tsx | 3 +- .../validate-apply/jobs/JobRunDetails.tsx | 106 ++++++++ .../validate-apply/jobs/JobsSection.tsx | 228 ++++++++++++++++++ .../validate-apply/jobs/utils.ts | 24 ++ .../validate-apply/logs/Logs.tsx | 12 +- .../validate-apply/logs/utils.ts | 24 +- .../validate-apply/metrics/MetricsSection.tsx | 1 + .../services-settings/ServiceContainer.tsx | 2 + .../services-settings/ServiceStatusFooter.tsx | 62 ++--- dashboard/src/shared/api.tsx | 105 ++++---- 20 files changed, 790 insertions(+), 132 deletions(-) create mode 100644 api/server/handlers/porter_app/job_status.go create mode 100644 dashboard/src/lib/hooks/useJobs.ts create mode 100644 dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx create mode 100644 dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts diff --git a/api/server/handlers/porter_app/job_status.go b/api/server/handlers/porter_app/job_status.go new file mode 100644 index 0000000000..e8a8570271 --- /dev/null +++ b/api/server/handlers/porter_app/job_status.go @@ -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) +} diff --git a/api/server/router/porter_app.go b/api/server/router/porter_app.go index 4f6dd05bd8..005f5b9db9 100644 --- a/api/server/router/porter_app.go +++ b/api/server/router/porter_app.go @@ -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, @@ -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, @@ -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{ diff --git a/dashboard/src/components/OldTable.tsx b/dashboard/src/components/OldTable.tsx index c21740ac78..e2418c69a1 100644 --- a/dashboard/src/components/OldTable.tsx +++ b/dashboard/src/components/OldTable.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import styled from "styled-components"; import { Column, @@ -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 = ({ setGlobalFilter, @@ -78,6 +79,7 @@ const Table: React.FC = ({ onRefresh, isRefreshing = false, }) => { + const [currentPageIndex, setCurrentPageIndex] = useState(0); const { getTableProps, getTableBodyProps, @@ -101,6 +103,10 @@ const Table: React.FC = ({ { columns: columnsData, data, + initialState: { + pageIndex: currentPageIndex, + }, + autoResetPage: false, }, useGlobalFilter, usePagination @@ -232,14 +238,21 @@ const Table: React.FC = ({ { + previousPage(); + setCurrentPageIndex(currentPageIndex - 1); + }} + type={"button"} > {"<"} - {pageIndex + 1} of {pageCount} + {currentPageIndex + 1} of {pageCount} - + { + nextPage(); + setCurrentPageIndex(currentPageIndex + 1); + }} type={"button"}> {">"} @@ -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"}; diff --git a/dashboard/src/components/form-components/SelectRow.tsx b/dashboard/src/components/form-components/SelectRow.tsx index a1851be0b3..b6eef06ac8 100644 --- a/dashboard/src/components/form-components/SelectRow.tsx +++ b/dashboard/src/components/form-components/SelectRow.tsx @@ -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; `; \ No newline at end of file diff --git a/dashboard/src/lib/hooks/useJobs.ts b/dashboard/src/lib/hooks/useJobs.ts new file mode 100644 index 0000000000..b063c691d8 --- /dev/null +++ b/dashboard/src/lib/hooks/useJobs.ts @@ -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; + +export const useJobs = ( + { + appName, + projectId, + clusterId, + deploymentTargetId, + selectedJobName, + }: { + appName: string, + projectId: number, + clusterId: number, + deploymentTargetId: string, + selectedJobName: string, + } +) => { + const [jobRuns, setJobRuns] = useState([]); + + const revisionIdToNumber = useRevisionIdToNumber(appName, deploymentTargetId); + + const { data } = 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; + }, + { + enabled: revisionIdToNumber != null, + refetchInterval: 5000, + refetchOnWindowFocus: false, + }, + ); + + useEffect(() => { + if (data != null) { + setJobRuns(data); + } + }, [data]); + + return { + jobRuns, + }; +}; \ No newline at end of file diff --git a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx index 74ea6257a3..50f44aa624 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx @@ -31,6 +31,7 @@ import Activity from "./tabs/Activity"; import EventFocusView from "./tabs/activity-feed/events/focus-views/EventFocusView"; import { z } from "zod"; import { PorterApp } from "@porter-dev/api-contracts"; +import JobsTab from "./tabs/JobsTab"; // commented out tabs are not yet implemented // will be included as support is available based on data from app revisions rather than helm releases @@ -45,7 +46,7 @@ const validTabs = [ "build-settings", "settings", // "helm-values", - // "job-history", + "job-history", ] as const; const DEFAULT_TAB = "activity"; type ValidTab = typeof validTabs[number]; @@ -249,7 +250,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { // redirect to the default tab after save history.push(`/apps/${porterApp.name}/${DEFAULT_TAB}`); - } catch (err) {} + } catch (err) { } }); useEffect(() => { @@ -319,11 +320,11 @@ const AppDataContainer: React.FC = ({ tabParam }) => { { label: "Environment", value: "environment" }, ...(latestProto.build ? [ - { - label: "Build Settings", - value: "build-settings", - }, - ] + { + label: "Build Settings", + value: "build-settings", + }, + ] : []), { label: "Settings", value: "settings" }, ]} @@ -347,6 +348,7 @@ const AppDataContainer: React.FC = ({ tabParam }) => { .with("logs", () => ) .with("metrics", () => ) .with("events", () => ) + .with("job-history", () => ) .otherwise(() => null)} diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx new file mode 100644 index 0000000000..92b626dc35 --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { useLatestRevision } from "../LatestRevisionContext"; +import JobsSection from "../../validate-apply/jobs/JobsSection"; + +const JobsTab: React.FC = () => { + const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision(); + + const appName = latestProto.name + + return ( + <> + latestProto.services[name].config.case === "jobConfig")} + /> + + ); +}; + +export default JobsTab; diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx index 4f7dbae317..e6e48eb49c 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx @@ -21,7 +21,7 @@ const Overview: React.FC = () => { const { serviceVersionStatus } = useAppStatus({ projectId, clusterId, - serviceNames: Object.keys(latestProto.services).filter(name => latestProto.services[name].config.case !== "jobConfig"), + serviceNames: Object.keys(latestProto.services), deploymentTargetId, appName: latestProto.name, }); diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx index d3a3bb3e11..745997dead 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx @@ -45,6 +45,7 @@ const AppEventCard: React.FC = ({ event, deploymentTargetId, projectId, c { project_id: projectId, cluster_id: clusterId, + app_name: appName, } ) diff --git a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts index 8e3cf1763f..b6c501040c 100644 --- a/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts +++ b/dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts @@ -3,9 +3,10 @@ import failure from "assets/failure.svg"; import loading from "assets/loading.gif"; import canceled from "assets/canceled.svg" import api from "shared/api"; -import { PorterAppBuildEvent, PorterAppEvent, PorterAppPreDeployEvent } from "./types"; +import { PorterAppBuildEvent, PorterAppPreDeployEvent } from "./types"; import { PorterAppRecord } from "../../../AppView"; import { match } from "ts-pattern"; +import { differenceInSeconds, formatDuration } from 'date-fns'; export const getDuration = (event: PorterAppPreDeployEvent | PorterAppBuildEvent): string => { const startTimeStamp = match(event) @@ -15,32 +16,9 @@ export const getDuration = (event: PorterAppPreDeployEvent | PorterAppBuildEvent const endTimeStamp = event.metadata.end_time ? new Date(event.metadata.end_time).getTime() : Date.now() - const timeDifferenceMilliseconds = endTimeStamp - startTimeStamp; + const timeDifferenceInSeconds = differenceInSeconds(endTimeStamp, startTimeStamp); - const seconds = Math.floor(timeDifferenceMilliseconds / 1000); - const weeks = Math.floor(seconds / 604800); - const remainingDays = Math.floor((seconds % 604800) / 86400); - const remainingHours = Math.floor((seconds % 86400) / 3600); - const remainingMinutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - if (weeks > 0) { - return `${weeks}w ${remainingDays}d`; - } - - if (remainingDays > 0) { - return `${remainingDays}d ${remainingHours}h`; - } - - if (remainingHours > 0) { - return `${remainingHours}h ${remainingMinutes}m`; - } - - if (remainingMinutes > 0) { - return `${remainingMinutes}m ${remainingSeconds}s`; - } - - return `${remainingSeconds}s`; + return formatDuration({ seconds: timeDifferenceInSeconds }); }; export const getStatusIcon = (status: string) => { diff --git a/dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx b/dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx index 19504a17aa..82b914fcb3 100644 --- a/dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx +++ b/dashboard/src/main/home/app-dashboard/expanded-app/logs/LogSection.tsx @@ -26,7 +26,6 @@ import Spacer from "components/porter/Spacer"; import Container from "components/porter/Container"; import Button from "components/porter/Button"; import { Service } from "../../new-app-flow/serviceTypes"; -import LogFilterContainer from "./LogFilterContainer"; import StyledLogs from "./StyledLogs"; import Filter from "components/porter/Filter"; @@ -252,7 +251,7 @@ const LogSection: React.FC = ({ {showFilter && ( - = ({ + jobRun, +}) => { + const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision(); + + 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 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)}.); + } + + return ( + <> + + + keyboard_backspace + Job run history + + + + + {renderHeaderText()} + + + {renderDurationText()} + + + + ); +}; + +export default JobRunDetails; + +const BackButton = styled.div` + display: flex; + align-items: center; + max-width: fit-content; + cursor: pointer; + font-size: 11px; + max-height: fit-content; + padding: 5px 13px; + border: 1px solid #ffffff55; + border-radius: 100px; + color: white; + background: #ffffff11; + + :hover { + background: #ffffff22; + } + + > i { + color: white; + 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 new file mode 100644 index 0000000000..fe6aed749f --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx @@ -0,0 +1,228 @@ +import React, { useState, useMemo } from "react"; +import styled from "styled-components"; + +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 Table from "components/OldTable"; +import { CellProps, 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"; + +type Props = { + appName: string; + projectId: number; + clusterId: number; + deploymentTargetId: string; + jobNames: string[]; +}; + +const JobsSection: React.FC = ({ + appName, + projectId, + clusterId, + deploymentTargetId, + jobNames, +}) => { + const { search } = useLocation(); + const queryParams = new URLSearchParams(search); + const serviceFromQueryParams = queryParams.get("service"); + const jobRunId = queryParams.get("job_run_id"); + const [selectedJobName, setSelectedJobName] = useState( + serviceFromQueryParams != null && jobNames.includes(serviceFromQueryParams) ? serviceFromQueryParams : "all" + ); + + const jobOptions = useMemo(() => { + return [{ label: "All jobs", value: "all" }, ...jobNames.map((name) => { + return { + label: name, + value: name, + }; + })]; + }, [jobNames]); + + const { jobRuns } = useJobs({ + appName, + projectId, + clusterId, + deploymentTargetId, + selectedJobName, + }); + + const selectedJobRun = useMemo(() => { + return jobRuns.find((jr) => jr.metadata.uid === jobRunId); + }, [jobRuns, jobRunId]); + + const columns = useMemo[]>( + () => [ + { + Header: "Started", + accessor: (originalRow) => relativeDate(originalRow?.status.startTime ?? ''), + }, + { + Header: "Run for", + 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 + ); + } + + return
{ranForString}
; + }, + }, + { + Header: "Name", + id: "job_name", + Cell: ({ row }: CellProps) => { + return
{row.original.jobName}
; + }, + }, + { + Header: "Version", + id: "version_number", + Cell: ({ row }: CellProps) => { + return
{row.original.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; + }, + }, + + { + Header: "Details", + id: "expand", + Cell: ({ row }: CellProps) => { + return ( + + + open_in_new + + + ); + }, + maxWidth: 40, + }, + ], + [] + ); + + return ( + <> + {selectedJobRun && ( + + )} + {!selectedJobRun && ( + + + + Run history for + setSelectedJobName(x)} + options={jobOptions} + width="200px" + /> + + + { + return Date.parse(a?.metadata?.creationTimestamp) > + Date.parse(b?.metadata?.creationTimestamp) + ? -1 + : 1; + })} + isLoading={jobRuns.length === 0} + enablePagination + /> + + )} + + ); +}; + +export default JobsSection; + +const Icon = styled.img` + height: 24px; + margin-right: 15px; +`; + +const StyledExpandedApp = styled.div` + width: 100%; + height: 100%; + + animation: fadeIn 0.5s 0s; + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + +const Status = styled.div<{ color: string }>` + padding: 5px 10px; + background: ${(props) => props.color}; + font-size: 13px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + width: min-content; + height: 25px; + min-width: 90px; +`; + +const ExpandButton = styled.div` + user-select: none; + cursor: pointer; + display: flex; + align-items: center; + > i { + border-radius: 20px; + font-size: 18px; + padding: 5px; + margin: 5px 5px; + :hover { + background: #ffffff11; + } + } +`; 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 new file mode 100644 index 0000000000..56df8bf24e --- /dev/null +++ b/dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts @@ -0,0 +1,24 @@ +import { JobRun } from "lib/hooks/useJobs"; +import { timeFrom } from "shared/string_utils"; +import { differenceInSeconds, formatDuration } from 'date-fns'; + +export const ranFor = (start: string, end?: string | number) => { + const duration = timeFrom(start, end); + + const unit = + duration.time === 1 + ? duration.unitOfTime.substring(0, duration.unitOfTime.length - 1) + : duration.unitOfTime; + + 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); + + return formatDuration({ seconds: timeDifferenceInSeconds }); +}; \ No newline at end of file diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx index 85fea490e1..4722165d51 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx @@ -11,7 +11,7 @@ import styled from "styled-components"; import spinner from "assets/loading.gif"; import api from "shared/api"; import { useLogs } from "./utils"; -import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName, LogFilterQueryParamOpts } from "../../expanded-app/logs/types"; +import { Direction, GenericFilterOption, GenericLogFilter, LogFilterName } from "../../expanded-app/logs/types"; import dayjs, { Dayjs } from "dayjs"; import Loading from "components/Loading"; import _ from "lodash"; @@ -36,6 +36,10 @@ type Props = { deploymentTargetId: string; appRevisionId?: string; logFilterNames?: LogFilterName[]; + timeRange?: { + startTime?: Dayjs; + endTime?: Dayjs; + }; filterPredeploy?: boolean; }; @@ -46,6 +50,7 @@ const Logs: React.FC = ({ serviceNames, deploymentTargetId, appRevisionId, + timeRange, logFilterNames = ["service_name", "revision", "output_stream"], filterPredeploy = false, }) => { @@ -187,6 +192,7 @@ const Logs: React.FC = ({ setDate: selectedDate, appRevisionId, filterPredeploy, + timeRange, }); useEffect(() => { @@ -288,7 +294,7 @@ const Logs: React.FC = ({ setSelectedDate={setSelectedDateIfUndefined} /> @@ -303,7 +309,7 @@ const Logs: React.FC = ({ { - refresh(); + refresh({ isLive: selectedDate == null && timeRange?.endTime == null }); }} > autorenew diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts b/dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts index df4e846398..7e39d60764 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts +++ b/dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts @@ -77,7 +77,7 @@ export const useLogs = ({ filterPredeploy: boolean, } ) => { - const isLive = !setDate; + const [isLive, setIsLive] = useState(!setDate && (timeRange?.startTime == null && timeRange?.endTime == null)); const logsBufferRef = useRef([]); const [logs, setLogs] = useState([]); const [paginationInfo, setPaginationInfo] = useState({ @@ -177,7 +177,7 @@ export const useLogs = ({ }; const setupWebsocket = (websocketKey: string) => { - const websocketBaseURL = `/api/projects/${projectID}/clusters/${clusterID}/apps/logs/loki`; + const websocketBaseURL = `/api/projects/${projectID}/clusters/${clusterID}/apps/${appName}/logs/loki`; const searchParams = { app_name: appName, @@ -277,6 +277,7 @@ export const useLogs = ({ { cluster_id: clusterID, project_id: projectID, + porter_app_name: appName, } ) @@ -324,7 +325,7 @@ export const useLogs = ({ } }; - const refresh = async () => { + const refresh = async ({ isLive }: { isLive: boolean }) => { setLoading(true); setLogs([]); flushLogsBuffer(true); @@ -358,7 +359,6 @@ export const useLogs = ({ if (isLive) { setupWebsocket(websocketKey); - } }; @@ -449,8 +449,20 @@ export const useLogs = ({ }, []); useEffect(() => { - refresh(); - }, [appName, serviceName, deploymentTargetId, searchParam, setDate, selectedFilterValues]); + // if a complete time range is not given, then we are live + const isLive = !setDate && (timeRange?.startTime == null || timeRange?.endTime == null); + refresh({ isLive }); + setIsLive(isLive); + }, [ + appName, + serviceName, + deploymentTargetId, + searchParam, + setDate, + JSON.stringify(selectedFilterValues), + JSON.stringify(timeRange?.endTime), + filterPredeploy + ]); useEffect(() => { // if the streaming is no longer live, close all websockets diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx index f043347926..d7b47af2fc 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/metrics/MetricsSection.tsx @@ -335,6 +335,7 @@ const MetricsHeader = styled.div` align-items: center; overflow: visible; justify-content: space-between; + margin-bottom: 20px; `; const RangeWrapper = styled.div` diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx index 62d8d7a11a..22ac76a3dc 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx @@ -238,6 +238,8 @@ const ServiceContainer: React.FC = ({ {status && ( )} diff --git a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx index 1c7e2a5e50..ea9ef75122 100644 --- a/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx +++ b/dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx @@ -11,44 +11,50 @@ import _ from "lodash"; import Link from "components/porter/Link"; import { PorterAppVersionStatus } from "lib/hooks/useAppStatus"; import { match } from "ts-pattern"; +import { useLatestRevision } from "../../app-view/LatestRevisionContext"; interface ServiceStatusFooterProps { + serviceName: string; status: PorterAppVersionStatus[]; + isJob: boolean, } const ServiceStatusFooter: React.FC = ({ + serviceName, status, + isJob }) => { const [expanded, setExpanded] = useState(false); + const { latestProto } = useLatestRevision(); const [height, setHeight] = useState(0); - // if (service.type === "job") { - // return ( - // - // {service.type === "job" && ( - // - // {/* - // check - // - // Last run succeeded at 12:39 PM on 4/13/23 - // - // */} - // - // - // - // - // )} - // - // ); - // } + if (isJob) { + return ( + + + + {/* + check + + Last run succeeded at 12:39 PM on 4/13/23 + + */} + + + + + + + ); + } return ( <> diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index 7ddbeae691..039851195e 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -293,11 +293,28 @@ const appLogs = baseApi< { project_id: number; cluster_id: number; + porter_app_name: string; } >( "GET", - ({ project_id, cluster_id }) => - `/api/projects/${project_id}/clusters/${cluster_id}/apps/logs` + ({ project_id, cluster_id, porter_app_name }) => + `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/logs` +); + +const appJobs = baseApi< + { + deployment_target_id: string; + job_name: string; + }, + { + project_id: number; + cluster_id: number; + porter_app_name: string; + } +>( + "GET", + ({ project_id, cluster_id, porter_app_name }) => + `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/jobs` ); const appPodStatus = baseApi< @@ -320,9 +337,8 @@ const getFeedEvents = baseApi< } >("GET", (pathParams) => { let { project_id, cluster_id, stack_name, page } = pathParams; - return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${ - page || 1 - }`; + return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1 + }`; }); const createEnvironment = baseApi< @@ -747,11 +763,9 @@ const detectBuildpack = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`; }); const detectGitlabBuildpack = baseApi< @@ -782,11 +796,9 @@ const getBranchContents = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/contents`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/contents`; }); const getProcfileContents = baseApi< @@ -802,11 +814,9 @@ const getProcfileContents = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/procfile`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/procfile`; }); const getPorterYamlContents = baseApi< @@ -822,11 +832,9 @@ const getPorterYamlContents = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/porteryaml`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/porteryaml`; }); const parsePorterYaml = baseApi< @@ -863,11 +871,9 @@ const getBranchHead = baseApi< branch: string; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.project_id}/gitrepos/${ - pathParams.git_repo_id - }/repos/${pathParams.kind}/${pathParams.owner}/${ - pathParams.name - }/${encodeURIComponent(pathParams.branch)}/head`; + return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id + }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name + }/${encodeURIComponent(pathParams.branch)}/head`; }); const validatePorterApp = baseApi< @@ -891,21 +897,21 @@ const validatePorterApp = baseApi< const createApp = baseApi< | { - name: string; - type: "github"; - git_repo_id: number; - git_branch: string; - git_repo_name: string; - porter_yaml_path: string; - } + name: string; + type: "github"; + git_repo_id: number; + git_branch: string; + git_repo_name: string; + porter_yaml_path: string; + } | { - name: string; - type: "docker-registry"; - image: { - repository: string; - tag: string; - }; - }, + name: string; + type: "docker-registry"; + image: { + repository: string; + tag: string; + }; + }, { project_id: number; cluster_id: number; @@ -1880,11 +1886,9 @@ const getEnvGroup = baseApi< version?: number; } >("GET", (pathParams) => { - return `/api/projects/${pathParams.id}/clusters/${ - pathParams.cluster_id - }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${ - pathParams.version ? "&version=" + pathParams.version : "" - }`; + return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id + }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : "" + }`; }); const getConfigMap = baseApi< @@ -2941,7 +2945,7 @@ const removeStackEnvGroup = baseApi< `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}` ); -const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`); +const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`); const createSecretAndOpenGitHubPullRequest = baseApi< { @@ -3009,6 +3013,7 @@ export default { createSecretAndOpenGitHubPullRequest, getLogsWithinTimeRange, appLogs, + appJobs, appPodStatus, getFeedEvents, updateStackStep,