From 42df5751f241a708c1f6b0cb6c9c0131345d84b8 Mon Sep 17 00:00:00 2001 From: Ky_pham <57948086+hongky-1994@users.noreply.github.com> Date: Wed, 26 Feb 2025 09:15:45 +0700 Subject: [PATCH] Draft UI for deployment traces feature (#5594) Signed-off-by: kypham --- .../__fixtures__/dummy-deployment-trace.ts | 21 ++ web/src/api/deploymentTraces.ts | 25 +++ .../deployment-trace-filter/index.test.tsx | 43 ++++ .../deployment-trace-filter/index.tsx | 65 ++++++ .../deployment-item.test.tsx | 33 +++ .../deployment-trace-item/deployment-item.tsx | 106 +++++++++ .../deployment-trace-item/index.test.tsx | 50 +++++ .../deployment-trace-item/index.tsx | 172 +++++++++++++++ .../deployment-trace-page/index.tsx | 204 ++++++++++++++++++ .../useGroupedDeploymentTrace.tsx | 51 +++++ web/src/components/header/index.tsx | 12 ++ web/src/constants/path.ts | 1 + web/src/modules/deploymentTrace/index.test.ts | 137 ++++++++++++ web/src/modules/deploymentTrace/index.ts | 151 +++++++++++++ web/src/modules/index.ts | 2 + web/src/routes.tsx | 6 + web/src/utils/common.ts | 16 +- web/src/utils/debounce.ts | 26 +++ 18 files changed, 1119 insertions(+), 2 deletions(-) create mode 100644 web/src/__fixtures__/dummy-deployment-trace.ts create mode 100644 web/src/api/deploymentTraces.ts create mode 100644 web/src/components/deployment-trace-page/deployment-trace-filter/index.test.tsx create mode 100644 web/src/components/deployment-trace-page/deployment-trace-filter/index.tsx create mode 100644 web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.test.tsx create mode 100644 web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.tsx create mode 100644 web/src/components/deployment-trace-page/deployment-trace-item/index.test.tsx create mode 100644 web/src/components/deployment-trace-page/deployment-trace-item/index.tsx create mode 100644 web/src/components/deployment-trace-page/index.tsx create mode 100644 web/src/components/deployment-trace-page/useGroupedDeploymentTrace.tsx create mode 100644 web/src/modules/deploymentTrace/index.test.ts create mode 100644 web/src/modules/deploymentTrace/index.ts create mode 100644 web/src/utils/debounce.ts diff --git a/web/src/__fixtures__/dummy-deployment-trace.ts b/web/src/__fixtures__/dummy-deployment-trace.ts new file mode 100644 index 0000000000..525d01739e --- /dev/null +++ b/web/src/__fixtures__/dummy-deployment-trace.ts @@ -0,0 +1,21 @@ +import { dummyDeployment } from "./dummy-deployment"; +import { createRandTimes, randomUUID } from "./utils"; +import { ListDeploymentTracesResponse } from "~~/api_client/service_pb"; + +const [createdAt, completedAt] = createRandTimes(3); + +export const dummyDeploymentTrace: ListDeploymentTracesResponse.DeploymentTraceRes.AsObject = { + trace: { + id: randomUUID(), + title: "title", + author: "user", + commitTimestamp: createdAt.unix(), + commitMessage: "commit-message", + commitHash: "commit-hash", + commitUrl: "commit-url", + createdAt: createdAt.unix(), + updatedAt: completedAt.unix(), + completedAt: completedAt.unix(), + }, + deploymentsList: [dummyDeployment], +}; diff --git a/web/src/api/deploymentTraces.ts b/web/src/api/deploymentTraces.ts new file mode 100644 index 0000000000..9efa54a14b --- /dev/null +++ b/web/src/api/deploymentTraces.ts @@ -0,0 +1,25 @@ +import { + ListDeploymentTracesRequest, + ListDeploymentTracesResponse, +} from "~~/api_client/service_pb"; +import { apiClient, apiRequest } from "./client"; + +export const getDeploymentTraces = ({ + options, + pageSize, + cursor, + pageMinUpdatedAt, +}: ListDeploymentTracesRequest.AsObject): Promise< + ListDeploymentTracesResponse.AsObject +> => { + const req = new ListDeploymentTracesRequest(); + if (options) { + const opts = new ListDeploymentTracesRequest.Options(); + opts.setCommitHash(options.commitHash); + req.setOptions(opts); + req.setPageSize(pageSize); + req.setCursor(cursor); + req.setPageMinUpdatedAt(pageMinUpdatedAt); + } + return apiRequest(req, apiClient.listDeploymentTraces); +}; diff --git a/web/src/components/deployment-trace-page/deployment-trace-filter/index.test.tsx b/web/src/components/deployment-trace-page/deployment-trace-filter/index.test.tsx new file mode 100644 index 0000000000..cc84d4eebd --- /dev/null +++ b/web/src/components/deployment-trace-page/deployment-trace-filter/index.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import DeploymentTraceFilter from "./index"; +import { MemoryRouter } from "~~/test-utils"; + +jest.useFakeTimers(); + +describe("DeploymentTraceFilter", () => { + const mockOnChange = jest.fn(); + const mockOnClear = jest.fn(); + const filterValues = { commitHash: "12345" }; + + beforeEach(() => { + render( + + + + ); + }); + + it("should render filter inputs", () => { + expect( + screen.getByRole("textbox", { name: /commit hash/i }) + ).toBeInTheDocument(); + }); + + it("should call onChange when filter value changes", () => { + const input = screen.getByRole("textbox", { name: /commit hash/i }); + fireEvent.change(input, { target: { value: "67890" } }); + + jest.runAllTimers(); + expect(mockOnChange).toHaveBeenCalledWith({ commitHash: "67890" }); + }); + + it("should call onClear when clear button is clicked", () => { + const clearButton = screen.getByRole("button", { name: /clear/i }); + fireEvent.click(clearButton); + expect(mockOnClear).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/deployment-trace-page/deployment-trace-filter/index.tsx b/web/src/components/deployment-trace-page/deployment-trace-filter/index.tsx new file mode 100644 index 0000000000..ef95c26160 --- /dev/null +++ b/web/src/components/deployment-trace-page/deployment-trace-filter/index.tsx @@ -0,0 +1,65 @@ +import { makeStyles, TextField } from "@material-ui/core"; +import { FC, useMemo, useState } from "react"; +import { FilterView } from "~/components/filter-view"; +import debounce from "~/utils/debounce"; + +type Props = { + filterValues: { commitHash?: string }; + onClear: () => void; + onChange: (options: { commitHash?: string }) => void; +}; + +const useStyles = makeStyles((theme) => ({ + formItem: { + width: "100%", + marginTop: theme.spacing(4), + }, +})); + +const DEBOUNCE_INPUT_WAIT = 1000; + +const DeploymentTraceFilter: FC = ({ + filterValues, + onClear, + onChange, +}) => { + const classes = useStyles(); + const [commitHash, setCommitHash] = useState( + filterValues.commitHash ?? "" + ); + + const debounceChangeCommitHash = useMemo( + () => debounce(onChange, DEBOUNCE_INPUT_WAIT), + [onChange] + ); + + const onChangeCommitHash = (commitHash: string): void => { + debounceChangeCommitHash({ commitHash: commitHash }); + }; + + return ( + { + onClear(); + setCommitHash(""); + }} + > +
+ { + const text = e.target.value; + setCommitHash(text); + onChangeCommitHash(text); + }} + /> +
+
+ ); +}; + +export default DeploymentTraceFilter; diff --git a/web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.test.tsx b/web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.test.tsx new file mode 100644 index 0000000000..21d2fa05b3 --- /dev/null +++ b/web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.test.tsx @@ -0,0 +1,33 @@ +import DeploymentItem from "./deployment-item"; +import { dummyDeployment } from "~/__fixtures__/dummy-deployment"; +import { render } from "~~/test-utils"; + +describe("DeploymentItem", () => { + it("should render deployment item with correct data", () => { + const { getByText } = render( + , + {} + ); + const expectedValues = { + status: "SUCCESS", + applicationName: "DemoApp", + kind: "KUBERNETES", + description: + "Quick sync by deploying the new version and configuring all traffic to it because no pipeline was configured", + }; + + Object.values(expectedValues).forEach((value) => { + expect(getByText(value)).toBeInTheDocument(); + }); + }); + + it("should display 'No description.' when summary is empty", () => { + const deploymentWithoutSummary = { ...dummyDeployment, summary: "" }; + const { getByText } = render( + , + {} + ); + + expect(getByText("No description.")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.tsx b/web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.tsx new file mode 100644 index 0000000000..f9545b164c --- /dev/null +++ b/web/src/components/deployment-trace-page/deployment-trace-item/deployment-item.tsx @@ -0,0 +1,106 @@ +import { Box, Chip, makeStyles, Typography } from "@material-ui/core"; +import dayjs from "dayjs"; +import { FC } from "react"; +import { DeploymentStatusIcon } from "~/components/deployment-status-icon"; +import { APPLICATION_KIND_TEXT } from "~/constants/application-kind"; +import { DEPLOYMENT_STATE_TEXT } from "~/constants/deployment-status-text"; +import { ellipsis } from "~/styles/text"; +import { Deployment } from "~~/model/deployment_pb"; + +type Props = { + deployment: Deployment.AsObject; +}; + +const useStyles = makeStyles((theme) => ({ + root: { + flex: 1, + padding: theme.spacing(2), + display: "flex", + alignItems: "center", + // backgroundColor: theme.palette.background.paper, + }, + info: { + marginLeft: theme.spacing(1), + }, + statusText: { + marginLeft: theme.spacing(1), + lineHeight: "1.5rem", + // Fix width to prevent misalignment of application name. + width: "100px", + }, + description: { + ...ellipsis, + color: theme.palette.text.hint, + }, + labelChip: { + marginLeft: theme.spacing(1), + marginBottom: theme.spacing(0.25), + }, +})); + +enum PipedVersion { + V0 = "v0", + V1 = "v1", +} + +const NO_DESCRIPTION = "No description."; + +const DeploymentItem: FC = ({ deployment }) => { + const classes = useStyles(); + + const pipedVersion = + !deployment.platformProvider || + deployment?.deployTargetsByPluginMap?.length > 0 + ? PipedVersion.V1 + : PipedVersion.V0; + + return ( + + + + + {DEPLOYMENT_STATE_TEXT[deployment.status]} + + + + + + {deployment.applicationName} + + + {pipedVersion === PipedVersion.V0 && + APPLICATION_KIND_TEXT[deployment.kind]} + {pipedVersion === PipedVersion.V1 && "APPLICATION"} + {deployment?.labelsMap.map(([key, value], i) => ( + + ))} + + + + {deployment.summary || NO_DESCRIPTION} + + +
{dayjs(deployment.createdAt * 1000).fromNow()}
+
+ ); +}; + +export default DeploymentItem; diff --git a/web/src/components/deployment-trace-page/deployment-trace-item/index.test.tsx b/web/src/components/deployment-trace-page/deployment-trace-item/index.test.tsx new file mode 100644 index 0000000000..faec98bd09 --- /dev/null +++ b/web/src/components/deployment-trace-page/deployment-trace-item/index.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import DeploymentTraceItem from "./index"; +import { dummyDeploymentTrace } from "~/__fixtures__/dummy-deployment-trace"; +import { MemoryRouter } from "~~/test-utils"; + +describe("DeploymentTraceItem", () => { + it("should render trace information", () => { + render( + + + + ); + + const expectedValues = { + title: "title", + author: "user", + commitMessage: "commit-message", + commitHash: "commit-hash", + commitUrl: "/commit-url", + }; + + expect(screen.getByText(expectedValues.title)).toBeInTheDocument(); + expect( + screen.getByText(expectedValues.author + " authored") + ).toBeInTheDocument(); + expect(screen.getByText(expectedValues.commitMessage)).toBeInTheDocument(); + expect(screen.getByText(expectedValues.commitHash)).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + expectedValues.commitUrl + ); + }); + + it("should render deployment items", () => { + render( + + + + ); + const buttonExpand = screen.getByRole("button", { name: /expand/i }); + fireEvent.click(buttonExpand); + expect(screen.getByText("DemoApp")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/deployment-trace-page/deployment-trace-item/index.tsx b/web/src/components/deployment-trace-page/deployment-trace-item/index.tsx new file mode 100644 index 0000000000..9db18e95bf --- /dev/null +++ b/web/src/components/deployment-trace-page/deployment-trace-item/index.tsx @@ -0,0 +1,172 @@ +import { + Box, + Collapse, + IconButton, + List, + ListItem, + makeStyles, + Typography, +} from "@material-ui/core"; +import dayjs from "dayjs"; +import React, { FC, useEffect, useMemo, useState } from "react"; +import { ListDeploymentTracesResponse } from "~~/api_client/service_pb"; +import MoreHorizIcon from "@material-ui/icons/MoreHoriz"; +import { ArrowDropDown } from "@material-ui/icons"; +import DeploymentItem from "./deployment-item"; +import { Link as RouterLink } from "react-router-dom"; +import { PAGE_PATH_DEPLOYMENTS } from "~/constants/path"; + +const useStyles = makeStyles((theme) => ({ + btnActive: { + backgroundColor: theme.palette.grey[300], + }, + btnRotate: { + transform: "rotate(180deg)", + }, + list: { + listStyle: "none", + padding: theme.spacing(3), + paddingTop: 0, + margin: 0, + flex: 1, + overflowY: "scroll", + }, + listItem: { + borderColor: theme.palette.grey[300], + }, + traceStickyTop: { + position: "sticky", + top: 0, + zIndex: 50, + backgroundColor: theme.palette.background.paper, + paddingBottom: theme.spacing(1), + borderBottom: `1px solid ${theme.palette.grey[300]}`, + }, +})); + +type Props = { + trace: ListDeploymentTracesResponse.DeploymentTraceRes.AsObject["trace"]; + deploymentList: ListDeploymentTracesResponse.DeploymentTraceRes.AsObject["deploymentsList"]; +}; + +const DeploymentTraceItem: FC = ({ trace, deploymentList }) => { + const classes = useStyles(); + const [visibleMessage, setVisibleMessage] = useState(false); + const [visibleDeployments, setVisibleDeployments] = useState(false); + + useEffect(() => { + if (visibleDeployments) { + setVisibleMessage(false); + } + }, [visibleDeployments]); + + const onViewCommitMessage = ( + e: React.MouseEvent + ): void => { + e.stopPropagation(); + setVisibleMessage(!visibleMessage); + }; + + const timeStampCommit = useMemo(() => { + if (!trace?.commitTimestamp) return "-"; + const diff = dayjs().diff(trace.commitTimestamp, "month"); + const date = dayjs(trace.commitTimestamp); + const isCurrentYear = dayjs().isSame(date, "year"); + + if (!isCurrentYear) { + return date.format("MMM D, YYYY"); + } + if (diff > 1) { + return date.format("MMM D"); + } + + return date.fromNow(); + }, [trace?.commitTimestamp]); + + return ( + + + + +
+ {trace?.title} + + + {trace?.commitHash} + + +
+ + + + + + +
+ + + {trace?.commitMessage} + + + + + + {trace?.author} authored + + + {timeStampCommit} + + +
+ setVisibleDeployments(!visibleDeployments)} + > + + +
+ + + + {deploymentList.map((deployment) => ( + + + + ))} + + +
+ ); +}; + +export default DeploymentTraceItem; diff --git a/web/src/components/deployment-trace-page/index.tsx b/web/src/components/deployment-trace-page/index.tsx new file mode 100644 index 0000000000..9d254442c5 --- /dev/null +++ b/web/src/components/deployment-trace-page/index.tsx @@ -0,0 +1,204 @@ +import { + Box, + Button, + CircularProgress, + Divider, + List, + ListItem, + makeStyles, + Toolbar, + Typography, +} from "@material-ui/core"; +import { FC, useCallback, useEffect, useRef, useState } from "react"; +import CloseIcon from "@material-ui/icons/Close"; +import FilterIcon from "@material-ui/icons/FilterList"; +import RefreshIcon from "@material-ui/icons/Refresh"; +import { + UI_TEXT_FILTER, + UI_TEXT_HIDE_FILTER, + UI_TEXT_MORE, + UI_TEXT_REFRESH, +} from "~/constants/ui-text"; +import { useStyles as useButtonStyles } from "~/styles/button"; +import DeploymentTraceFilter from "./deployment-trace-filter"; +import { useNavigate } from "react-router-dom"; +import { PAGE_PATH_DEPLOYMENT_TRACE } from "~/constants/path"; +import { + arrayFormat, + stringifySearchParams, + useSearchParams, +} from "~/utils/search-params"; +import { useAppDispatch, useAppSelector } from "~/hooks/redux"; +import { + fetchDeploymentTraces, + fetchMoreDeploymentTraces, +} from "~/modules/deploymentTrace"; +import useGroupedDeploymentTrace from "./useGroupedDeploymentTrace"; +import DeploymentTraceItem from "./deployment-trace-item"; +import { useInView } from "react-intersection-observer"; + +const useStyles = makeStyles((theme) => ({ + list: { + listStyle: "none", + padding: theme.spacing(3), + paddingTop: 0, + margin: 0, + flex: 1, + overflowY: "scroll", + }, + listItem: { + backgroundColor: theme.palette.background.paper, + }, + listDeployment: { + backgroundColor: theme.palette.background.paper, + }, + date: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})); +const DeploymentTracePage: FC = () => { + const classes = useStyles(); + const [openFilter, setOpenFilter] = useState(true); + const dispatch = useAppDispatch(); + const buttonClasses = useButtonStyles(); + const status = useAppSelector((state) => state.deploymentTrace.status); + const hasMore = useAppSelector((state) => state.deploymentTrace.hasMore); + const navigate = useNavigate(); + const filterValues = useSearchParams(); + const { dates, deploymentTracesMap } = useGroupedDeploymentTrace(); + const isLoading = status === "loading"; + + const listRef = useRef(null); + const [ref, inView] = useInView({ + rootMargin: "400px", + root: listRef.current, + }); + + useEffect(() => { + dispatch(fetchDeploymentTraces(filterValues)); + }, [dispatch, filterValues]); + + useEffect(() => { + if (inView && hasMore && isLoading === false) { + dispatch(fetchMoreDeploymentTraces(filterValues || {})); + } + }, [dispatch, inView, hasMore, isLoading, filterValues]); + + const handleRefreshClick = (): void => { + dispatch(fetchDeploymentTraces(filterValues)); + }; + + const handleMoreClick = useCallback(() => { + dispatch(fetchMoreDeploymentTraces(filterValues || {})); + }, [dispatch, filterValues]); + + const handleFilterChange = (options: { commitHash?: string }): void => { + navigate( + `${PAGE_PATH_DEPLOYMENT_TRACE}?${stringifySearchParams( + { ...options }, + { arrayFormat: arrayFormat } + )}`, + { replace: true } + ); + }; + + const handleFilterClear = useCallback(() => { + navigate(PAGE_PATH_DEPLOYMENT_TRACE, { replace: true }); + }, [navigate]); + + return ( + + + + + + + + + +
    + {dates.length === 0 && isLoading && ( + + + + )} + {dates.length === 0 && !isLoading && ( + + No deployments + + )} + {dates.map((date) => ( + + + {date} + + + {deploymentTracesMap[date].map(({ trace, deploymentsList }) => ( + + + + ))} + + + ))} + {status === "succeeded" &&
    } + {!hasMore && ( + + )} +
+ + {openFilter && ( + + )} +
+
+ ); +}; + +export default DeploymentTracePage; diff --git a/web/src/components/deployment-trace-page/useGroupedDeploymentTrace.tsx b/web/src/components/deployment-trace-page/useGroupedDeploymentTrace.tsx new file mode 100644 index 0000000000..2f2505e74b --- /dev/null +++ b/web/src/components/deployment-trace-page/useGroupedDeploymentTrace.tsx @@ -0,0 +1,51 @@ +import dayjs from "dayjs"; +import { useMemo } from "react"; +import { useShallowEqualSelector } from "~/hooks/redux"; +import { selectIds, selectById } from "~/modules/deploymentTrace"; +import { sortDateFunc } from "~/utils/common"; +import { ListDeploymentTracesResponse } from "~~/api_client/service_pb"; + +type GroupedDeploymentTrace = { + dates: string[]; + deploymentTracesMap: Record< + string, + ListDeploymentTracesResponse.DeploymentTraceRes.AsObject[] + >; +}; + +const useGroupedDeploymentTrace = (): GroupedDeploymentTrace => { + const traceList = useShallowEqualSelector((state) => { + const list = selectIds(state.deploymentTrace) + .map((id) => selectById(state.deploymentTrace, id)) + .filter((trace) => trace !== undefined); + return list; + }) as ListDeploymentTracesResponse.DeploymentTraceRes.AsObject[]; + + const deploymentTracesMap = useMemo(() => { + const listMap: Record< + string, + ListDeploymentTracesResponse.DeploymentTraceRes.AsObject[] + > = {}; + + traceList.forEach((item) => { + if (!item.trace?.commitTimestamp) return; + + const dateStr = dayjs(item.trace?.commitTimestamp * 1000).format( + "YYYY/MM/DD" + ); + if (!listMap[dateStr]) listMap[dateStr] = []; + listMap[dateStr].push(item); + }); + + return listMap; + }, [traceList]); + + const dates = useMemo( + () => Object.keys(deploymentTracesMap).sort(sortDateFunc), + [deploymentTracesMap] + ); + + return { dates, deploymentTracesMap }; +}; + +export default useGroupedDeploymentTrace; diff --git a/web/src/components/header/index.tsx b/web/src/components/header/index.tsx index a82deea263..d513043fca 100644 --- a/web/src/components/header/index.tsx +++ b/web/src/components/header/index.tsx @@ -22,6 +22,7 @@ import { PAGE_PATH_INSIGHTS, PAGE_PATH_DEPLOYMENT_CHAINS, PAGE_PATH_EVENTS, + PAGE_PATH_DEPLOYMENT_TRACE, } from "~/constants/path"; import { APP_NAME } from "~/constants/common"; import { LOGGING_IN_PROJECT, USER_PROJECTS } from "~/constants/localstorage"; @@ -173,6 +174,17 @@ export const Header: FC = memo(function Header() { > Deployments + + Deployment Traces + { + const initialState = { + status: "idle" as LoadingStatus, + loading: {}, + hasMore: true, + cursor: "", + minUpdatedAt: 0, + skippable: {}, + canceling: {}, + entities: {}, + ids: [], + }; + + it("should return the initial state", () => { + expect( + deploymentTraceSlice.reducer(initialState, { + type: "TEST_ACTION", + }) + ).toEqual(initialState); + }); + + describe("fetchDeploymentTrace", () => { + it(`should handle ${fetchDeploymentTraces.pending.type}`, () => { + expect( + deploymentTraceSlice.reducer(initialState, { + type: fetchDeploymentTraces.pending.type, + }) + ).toEqual({ + ...initialState, + status: "loading", + }); + }); + + it(`should handle ${fetchDeploymentTraces.rejected.type}`, () => { + expect( + deploymentTraceSlice.reducer( + { + ...initialState, + status: "loading", + }, + { + type: fetchDeploymentTraces.rejected.type, + } + ) + ).toEqual({ + ...initialState, + status: "failed", + }); + }); + + it(`should handle ${fetchDeploymentTraces.fulfilled.type}`, () => { + expect( + deploymentTraceSlice.reducer( + { + ...initialState, + status: "loading", + }, + { + type: fetchDeploymentTraces.fulfilled.type, + payload: { + tracesList: [dummyDeploymentTrace], + cursor: "next cursor", + }, + } + ) + ).toEqual({ + ...initialState, + entities: dummyDeploymentTrace.trace + ? { [dummyDeploymentTrace.trace.id]: dummyDeploymentTrace } + : {}, + hasMore: false, + ids: dummyDeploymentTrace.trace?.id + ? [dummyDeploymentTrace.trace.id] + : [], + status: "succeeded", + cursor: "next cursor", + minUpdatedAt: 0, + }); + }); + }); + + describe("fetchMoreDeploymentTraces", () => { + it(`should handle ${fetchMoreDeploymentTraces.pending.type}`, () => { + expect( + deploymentTraceSlice.reducer(initialState, { + type: fetchMoreDeploymentTraces.pending.type, + }) + ).toEqual({ ...initialState, status: "loading" }); + }); + + it(`should handle ${fetchMoreDeploymentTraces.rejected.type}`, () => { + expect( + deploymentTraceSlice.reducer( + { ...initialState, status: "loading" }, + { + type: fetchMoreDeploymentTraces.rejected.type, + } + ) + ).toEqual({ ...initialState, status: "failed" }); + }); + + it(`should handle ${fetchMoreDeploymentTraces.fulfilled.type}`, () => { + expect( + deploymentTraceSlice.reducer( + { ...initialState, status: "loading" }, + { + type: fetchMoreDeploymentTraces.fulfilled.type, + payload: { + tracesList: [dummyDeploymentTrace], + cursor: "next cursor", + }, + } + ) + ).toEqual({ + ...initialState, + hasMore: false, + ids: dummyDeploymentTrace.trace?.id + ? [dummyDeploymentTrace.trace.id] + : [], + entities: dummyDeploymentTrace.trace + ? { [dummyDeploymentTrace.trace.id]: dummyDeploymentTrace } + : {}, + status: "succeeded", + cursor: "next cursor", + minUpdatedAt: -2592000, + }); + }); + }); +}); diff --git a/web/src/modules/deploymentTrace/index.ts b/web/src/modules/deploymentTrace/index.ts new file mode 100644 index 0000000000..fce65b8130 --- /dev/null +++ b/web/src/modules/deploymentTrace/index.ts @@ -0,0 +1,151 @@ +import { + createAsyncThunk, + createEntityAdapter, + createSlice, +} from "@reduxjs/toolkit"; +import { LoadingStatus } from "~/types/module"; +import * as deploymentTracesApi from "~/api/deploymentTraces"; +import { AppState } from "~/store"; +import { + ListDeploymentTracesRequest, + ListDeploymentTracesResponse, +} from "~~/api_client/service_pb"; + +const TIME_RANGE_LIMIT_IN_SECONDS = 2592000; +const ITEMS_PER_PAGE = 50; +const FETCH_MORE_ITEMS_PER_PAGE = 30; + +export type DeploymentTraceFilterOptions = { + commitHash?: string; +}; + +const convertFilterOptions = ( + options: DeploymentTraceFilterOptions +): ListDeploymentTracesRequest.Options.AsObject => { + return { + commitHash: options?.commitHash || "", + }; +}; + +export const deploymentTraceAdapter = createEntityAdapter< + ListDeploymentTracesResponse.DeploymentTraceRes.AsObject +>({ + selectId: (trace) => trace.trace?.id as string, + sortComparer: (a, b) => { + if (!b.trace?.updatedAt) return 0; + if (!a.trace?.updatedAt) return 0; + return b.trace?.updatedAt - a.trace?.updatedAt; + }, +}); + +const initialState = deploymentTraceAdapter.getInitialState<{ + status: LoadingStatus; + loading: Record; + hasMore: boolean; + cursor: string; + minUpdatedAt: number; + skippable: Record; +}>({ + status: "idle", + loading: {}, + hasMore: true, + cursor: "", + minUpdatedAt: Math.round(Date.now() / 1000 - TIME_RANGE_LIMIT_IN_SECONDS), + skippable: {}, +}); + +export const fetchDeploymentTraces = createAsyncThunk< + { + tracesList?: ListDeploymentTracesResponse.DeploymentTraceRes.AsObject[]; + cursor: string; + }, + DeploymentTraceFilterOptions, + { state: AppState } +>("deploymentTrace/fetchList", async (options, thunkAPI) => { + const { deploymentTrace } = thunkAPI.getState(); + + const response = await deploymentTracesApi.getDeploymentTraces({ + options: convertFilterOptions(options), + pageSize: ITEMS_PER_PAGE, + cursor: "", + pageMinUpdatedAt: deploymentTrace.minUpdatedAt, + }); + + return response; +}); + +export const fetchMoreDeploymentTraces = createAsyncThunk< + { + tracesList: ListDeploymentTracesResponse.DeploymentTraceRes.AsObject[]; + cursor: string; + }, + DeploymentTraceFilterOptions, + { state: AppState } +>("deploymentTrace/fetchMoreList", async (options, thunkAPI) => { + const { deployments } = thunkAPI.getState(); + + const response = await deploymentTracesApi.getDeploymentTraces({ + options: convertFilterOptions(options), + pageSize: FETCH_MORE_ITEMS_PER_PAGE, + cursor: deployments.cursor, + pageMinUpdatedAt: deployments.minUpdatedAt, + }); + + return response; +}); + +export const deploymentTraceSlice = createSlice({ + name: "deploymentTrace", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchDeploymentTraces.pending, (state) => { + state.status = "loading"; + state.hasMore = true; + state.cursor = ""; + }) + .addCase(fetchDeploymentTraces.fulfilled, (state, action) => { + state.status = "succeeded"; + deploymentTraceAdapter.removeAll(state); + if (action.payload.tracesList) { + if (action.payload.tracesList?.length > 0) { + deploymentTraceAdapter.upsertMany(state, action.payload.tracesList); + } + if (action.payload.tracesList?.length < ITEMS_PER_PAGE) { + state.hasMore = false; + } + } + state.cursor = action.payload.cursor; + }) + .addCase(fetchDeploymentTraces.rejected, (state) => { + state.status = "failed"; + }) + .addCase(fetchMoreDeploymentTraces.pending, (state) => { + state.status = "loading"; + }) + .addCase(fetchMoreDeploymentTraces.fulfilled, (state, action) => { + state.status = "succeeded"; + if (action.payload.tracesList.length > 0) { + deploymentTraceAdapter.upsertMany(state, action.payload.tracesList); + } + if (action.payload.tracesList.length < FETCH_MORE_ITEMS_PER_PAGE) { + state.hasMore = false; + state.minUpdatedAt = state.minUpdatedAt - TIME_RANGE_LIMIT_IN_SECONDS; + } else { + state.hasMore = true; + } + state.cursor = action.payload.cursor; + }) + .addCase(fetchMoreDeploymentTraces.rejected, (state) => { + state.status = "failed"; + }); + }, +}); + +export const { + selectById, + selectAll, + selectEntities, + selectIds, +} = deploymentTraceAdapter.getSelectors(); diff --git a/web/src/modules/index.ts b/web/src/modules/index.ts index a502757af7..27d6bbf58f 100644 --- a/web/src/modules/index.ts +++ b/web/src/modules/index.ts @@ -19,9 +19,11 @@ import { toastsSlice } from "./toasts"; import { updateApplicationSlice } from "./update-application"; import { unregisteredApplicationsSlice } from "./unregistered-applications"; import { eventsSlice } from "./events"; +import { deploymentTraceSlice } from "./deploymentTrace"; export const reducers = combineReducers({ deployments: deploymentsSlice.reducer, + deploymentTrace: deploymentTraceSlice.reducer, applicationLiveState: applicationLiveStateSlice.reducer, applications: applicationsSlice.reducer, updateApplication: updateApplicationSlice.reducer, diff --git a/web/src/routes.tsx b/web/src/routes.tsx index a17866d328..935a273c3d 100644 --- a/web/src/routes.tsx +++ b/web/src/routes.tsx @@ -18,6 +18,7 @@ import { PAGE_PATH_APPLICATIONS, PAGE_PATH_DEPLOYMENTS, PAGE_PATH_DEPLOYMENT_CHAINS, + PAGE_PATH_DEPLOYMENT_TRACE, PAGE_PATH_INSIGHTS, PAGE_PATH_EVENTS, PAGE_PATH_LOGIN, @@ -41,6 +42,7 @@ import { } from "~/modules/commands"; import { fetchPipeds } from "~/modules/pipeds"; import { sortedSet } from "~/utils/sorted-set"; +import DeploymentTracePage from "./components/deployment-trace-page"; const SettingsIndexPage = loadable( () => import(/* webpackChunkName: "settings" */ "~/components/settings-page"), @@ -212,6 +214,10 @@ export const Routes: FC = () => { element={} /> } /> + } + /> } diff --git a/web/src/utils/common.ts b/web/src/utils/common.ts index 6eb66b996e..84d43cf9e4 100644 --- a/web/src/utils/common.ts +++ b/web/src/utils/common.ts @@ -1,8 +1,20 @@ +import dayjs from "dayjs"; + export const sortFunc = ( - a: string, - b: string, + a: string | number, + b: string | number, direction: "ASC" | "DESC" = "ASC" ): number => { if (direction === "ASC") return a > b ? 1 : -1; return a > b ? -1 : 1; }; + +export const sortDateFunc = ( + a: string | number, + b: string | number, + direction: "ASC" | "DESC" = "ASC" +): number => { + const dateA = dayjs(a).valueOf(); + const dateB = dayjs(b).valueOf(); + return sortFunc(dateA, dateB, direction); +}; diff --git a/web/src/utils/debounce.ts b/web/src/utils/debounce.ts new file mode 100644 index 0000000000..6d4d3e6f72 --- /dev/null +++ b/web/src/utils/debounce.ts @@ -0,0 +1,26 @@ +export type Cancelable = { + clear(): void; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const debounce = any>( + func: T, + wait = 300 +): T & Cancelable => { + let timeout: ReturnType; + const debounced = (...args: Parameters): void => { + const later = (): void => { + func.apply(this, args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + + debounced.clear = () => { + clearTimeout(timeout); + }; + + return debounced as T & Cancelable; +}; + +export default debounce;