diff --git a/package-lock.json b/package-lock.json index 6b916cbac..672d8c02a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-transition-group": "^4.4.5", "recharts": "^2.6.2", "semver": "^7.5.4", + "squarify": "^1.1.0", "styled-components": "^6.1.0", "uuid": "^9.0.1", "zustand": "^4.5.5", @@ -13641,6 +13642,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/squarify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/squarify/-/squarify-1.1.0.tgz", + "integrity": "sha512-0nD8UD4FPOfWHdaVYACbr1SmBF5XQeTbDcRfVs8NHVtueRC0OPo4DN/TJVjAJZ0fLwrgDhEI3XrhUicglD9npw==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -25494,6 +25500,11 @@ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==" }, + "squarify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/squarify/-/squarify-1.1.0.tgz", + "integrity": "sha512-0nD8UD4FPOfWHdaVYACbr1SmBF5XQeTbDcRfVs8NHVtueRC0OPo4DN/TJVjAJZ0fLwrgDhEI3XrhUicglD9npw==" + }, "statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/package.json b/package.json index 69dd78898..00995577b 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "react-transition-group": "^4.4.5", "recharts": "^2.6.2", "semver": "^7.5.4", + "squarify": "^1.1.0", "styled-components": "^6.1.0", "uuid": "^9.0.1", "zustand": "^4.5.5", diff --git a/src/components/Dashboard/NewReport/Chart/Chart.stories.tsx b/src/components/Dashboard/NewReport/Chart/Chart.stories.tsx new file mode 100644 index 000000000..8e516c27c --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/Chart.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { Chart } from "."; +import { mockedReport } from "../MetricsTable/mockData"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Dashboard/NewReport/Chart", + component: Chart, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Default: Story = { + args: { + data: mockedReport.reports + } +}; diff --git a/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/index.tsx b/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/index.tsx new file mode 100644 index 000000000..7c0602a08 --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/index.tsx @@ -0,0 +1,9 @@ +import * as s from "./styles"; +import { TooltipKeyValueProps } from "./types"; + +export const TooltipKeyValue = ({ label, children }: TooltipKeyValueProps) => ( + + {label}: + {children} + +); diff --git a/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/styles.ts b/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/styles.ts new file mode 100644 index 000000000..9fc3c73c4 --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/styles.ts @@ -0,0 +1,14 @@ +import styled from "styled-components"; +import { footnoteRegularTypography } from "../../../../../common/App/typographies"; + +export const Container = styled.div` + ${footnoteRegularTypography} + + display: flex; + gap: 4px; + color: ${({ theme }) => theme.colors.v3.text.primary}; +`; + +export const Label = styled.span` + color: ${({ theme }) => theme.colors.v3.text.secondary}; +`; diff --git a/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/types.ts b/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/types.ts new file mode 100644 index 000000000..e0ae47cf7 --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/ServiceTile/TooltipKeyValue/types.ts @@ -0,0 +1,6 @@ +import { ReactNode } from "react"; + +export interface TooltipKeyValueProps { + label: string; + children: ReactNode; +} diff --git a/src/components/Dashboard/NewReport/Chart/ServiceTile/index.tsx b/src/components/Dashboard/NewReport/Chart/ServiceTile/index.tsx new file mode 100644 index 000000000..907ead5ae --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/ServiceTile/index.tsx @@ -0,0 +1,47 @@ +import { Tile } from "../../../../common/TreeMap/Tile"; +import { ReportTimeMode } from "../../ReportHeader/types"; +import * as s from "./styles"; +import { TooltipKeyValue } from "./TooltipKeyValue"; +import { ServiceTileProps } from "./types"; + +const getFormattedNumber = (viewMode: ReportTimeMode, value: number) => + `${viewMode === "changes" && value > 0 ? "+" : ""}${value}`; + +export const ServiceTile = ({ + name, + criticalIssuesCount, + impactScore, + severity, + viewMode, + onIssuesClick: onSeeIssuesClick +}: ServiceTileProps) => { + const formattedCriticalIssuesCount = getFormattedNumber( + viewMode, + criticalIssuesCount + ); + const formattedImpactScore = getFormattedNumber(viewMode, impactScore); + + return ( + + {name} + + {formattedCriticalIssuesCount} + + + {formattedImpactScore} + + + } + > + onSeeIssuesClick && onSeeIssuesClick(name)}> + + {formattedCriticalIssuesCount} | {formattedImpactScore} + + + + ); +}; diff --git a/src/components/Dashboard/NewReport/Chart/ServiceTile/styles.ts b/src/components/Dashboard/NewReport/Chart/ServiceTile/styles.ts new file mode 100644 index 000000000..d0f208364 --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/ServiceTile/styles.ts @@ -0,0 +1,21 @@ +import styled from "styled-components"; +import { Link } from "../../../../common/v3/Link"; + +export const StatsMainNumber = styled.span` + color: ${({ theme }) => theme.colors.v3.text.primary}; +`; + +export const TooltipContent = styled.div` + color: ${({ theme }) => theme.colors.v3.text.primary}; +`; + +export const StyledLink = styled(Link)` + color: ${({ theme }) => theme.colors.v3.text.primary}; + font-size: 32px; + font-weight: 700; + line-height: normal; + + :hover { + text-decoration: underline; + } +`; diff --git a/src/components/Dashboard/NewReport/Chart/ServiceTile/types.ts b/src/components/Dashboard/NewReport/Chart/ServiceTile/types.ts new file mode 100644 index 000000000..acdf1c42f --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/ServiceTile/types.ts @@ -0,0 +1,11 @@ +import { Severity } from "../../MetricsTable/types"; +import { ReportTimeMode } from "../../ReportHeader/types"; + +export interface ServiceTileProps { + name: string; + criticalIssuesCount: number; + impactScore: number; + severity: Severity; + viewMode: ReportTimeMode; + onIssuesClick?: (service: string) => void; +} diff --git a/src/components/Dashboard/NewReport/Chart/index.tsx b/src/components/Dashboard/NewReport/Chart/index.tsx new file mode 100644 index 000000000..5ef58b8e7 --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/index.tsx @@ -0,0 +1,64 @@ +import useDimensions from "react-cool-dimensions"; +import { Input } from "squarify"; +import { isNumber } from "../../../../typeGuards/isNumber"; +import { sendUserActionTrackingEvent } from "../../../../utils/actions/sendUserActionTrackingEvent"; +import { TreeMap } from "../../../common/TreeMap"; +import { TileData } from "../../../common/TreeMap/types"; +import { ReportTimeMode } from "../ReportHeader/types"; +import { trackingEvents } from "../tracking"; +import { getSeverity } from "../utils"; +import { ServiceTile } from "./ServiceTile"; +import * as s from "./styles"; +import { ChartProps } from "./types"; + +export const Chart = ({ data, onServiceSelected }: ChartProps) => { + const { width, height, observe } = useDimensions(); + + const viewMode: ReportTimeMode = data.some((service) => + isNumber(service.key.lastDays) + ) + ? "changes" + : "baseline"; + + const transformedData = data.map((service) => ({ + ...service, + impact: Math.round(service.impact * 100) + })); + + const handSeeIssuesClick = (service: string) => { + sendUserActionTrackingEvent(trackingEvents.HEATMAP_SEE_ISSUES_LINK_CLICKED); + onServiceSelected(service); + }; + + const minImpactScore = Math.min(...transformedData.map((x) => x.impact)); + const maxImpactScore = Math.max(...transformedData.map((x) => x.impact)); + + const chartData: Input[] = transformedData.map((service) => { + const severity = getSeverity( + minImpactScore, + maxImpactScore, + service.impact + ); + + return { + id: service.key.service, + value: service.impact, + content: ( + + ) + }; + }); + + return ( + + + + ); +}; diff --git a/src/components/Dashboard/NewReport/Chart/styles.ts b/src/components/Dashboard/NewReport/Chart/styles.ts new file mode 100644 index 000000000..cb5735fd4 --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/styles.ts @@ -0,0 +1,6 @@ +import styled from "styled-components"; + +export const Container = styled.div` + width: 100%; + height: 100%; +`; diff --git a/src/components/Dashboard/NewReport/Chart/types.ts b/src/components/Dashboard/NewReport/Chart/types.ts new file mode 100644 index 000000000..ccef973e0 --- /dev/null +++ b/src/components/Dashboard/NewReport/Chart/types.ts @@ -0,0 +1,6 @@ +import { ServiceData } from "../types"; + +export interface ChartProps { + data: ServiceData[]; + onServiceSelected: (name: string) => void; +} diff --git a/src/components/Dashboard/NewReport/MetricsTable/MetricsTable.stories.tsx b/src/components/Dashboard/NewReport/MetricsTable/MetricsTable.stories.tsx new file mode 100644 index 000000000..1e020e715 --- /dev/null +++ b/src/components/Dashboard/NewReport/MetricsTable/MetricsTable.stories.tsx @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { MetricsTable } from "."; +import { mockedReport } from "./mockData"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Dashboard/NewReport/MetricsTable", + component: MetricsTable, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args + +export const Default: Story = { + args: { + data: mockedReport.reports + } +}; + +export const Empty: Story = { + args: { + data: [] + } +}; diff --git a/src/components/Dashboard/NewReport/MetricsTable/index.tsx b/src/components/Dashboard/NewReport/MetricsTable/index.tsx new file mode 100644 index 000000000..886724e80 --- /dev/null +++ b/src/components/Dashboard/NewReport/MetricsTable/index.tsx @@ -0,0 +1,194 @@ +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + sortingFns, + useReactTable +} from "@tanstack/react-table"; + +import { ReactNode } from "react"; +import { isUndefined } from "../../../../typeGuards/isUndefined"; +import { sendUserActionTrackingEvent } from "../../../../utils/actions/sendUserActionTrackingEvent"; +import { SortIcon } from "../../../common/icons/16px/SortIcon"; +import { ChevronIcon } from "../../../common/icons/20px/ChevronIcon"; +import { Direction } from "../../../common/icons/types"; +import { SORTING_ORDER } from "../../../common/SortingSelector/types"; +import { trackingEvents } from "../tracking"; +import { ServiceData } from "../types"; +import { getSeverity } from "../utils"; +import * as s from "./styles"; +import { ColumnMeta, MetricsTableProps, Severity } from "./types"; + +const IssuesLink = ({ + children, + onClick +}: { + onClick: () => void; + children: ReactNode; +}) => { + return ( + + {children} + + + + + ); +}; + +export const MetricsTable = ({ + data, + showSign, + onServiceSelected +}: MetricsTableProps) => { + const columnHelper = createColumnHelper(); + const minImpact = Math.min(...data.map((x) => x.impact)); + const maxImpact = Math.max(...data.map((x) => x.impact)); + + const handleSeeIssuesLinkClick = (service: string, source: string) => { + onServiceSelected(service); + sendUserActionTrackingEvent(trackingEvents.TABLE_SEE_ISSUES_LINK_CLICKED, { + source + }); + }; + + const columns = [ + columnHelper.accessor((row) => row.key.service, { + header: "Service", + enableSorting: false, + cell: (info) => info.getValue() + }), + columnHelper.accessor((row) => row, { + header: "Critical issues", + id: "issues", + cell: (info) => { + const value = info.getValue().issues; + return ( + + handleSeeIssuesLinkClick(info.getValue().key.service, "issues") + } + > + {!showSign ? value : `${value > 0 ? "+" : ""}${value}`} + + ); + }, + sortingFn: sortingFns.alphanumeric, + meta: { + contentAlign: "center" + }, + enableSorting: true + }), + columnHelper.accessor((row) => row, { + id: "impact", + header: "Impact", + cell: (info) => ( + + handleSeeIssuesLinkClick(info.getValue().key.service, "impact") + } + > + {Math.round(info.getValue().impact * 100)} + + ), + sortingFn: sortingFns.alphanumeric, + enableSorting: true, + meta: { + contentAlign: "center" + } + }), + columnHelper.accessor( + (row) => getSeverity(minImpact, maxImpact, row.impact), + { + header: "Rank", + id: "rank", + enableSorting: true, + cell: (info) => { + return info.getValue(); + }, + sortingFn: sortingFns.alphanumeric, + meta: { + contentAlign: "center" + } + } + ) + ]; + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel() + }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as + | ColumnMeta + | undefined; + + return ( + + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + {isUndefined(header.column.columnDef.enableSorting) || + (header.column.columnDef.enableSorting && + { + asc: ( + + + + ), + desc: ( + + + + ) + }[(header.column.getIsSorted() as string) || "asc"])} + + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as ColumnMeta | undefined; + const severity = + cell.column.columnDef.header === "Rank" + ? (cell.getValue() as Severity) + : null; + return ( + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + ); + })} + + ))} + + + ); +}; diff --git a/src/components/Dashboard/NewReport/MetricsTable/mockData.ts b/src/components/Dashboard/NewReport/MetricsTable/mockData.ts new file mode 100644 index 000000000..7b100fdcb --- /dev/null +++ b/src/components/Dashboard/NewReport/MetricsTable/mockData.ts @@ -0,0 +1,69 @@ +import { ServiceMetricsReport } from "../types"; + +export const mockedReport: ServiceMetricsReport = { + reports: [ + { + impact: 100.123123, + key: { + environment: "TEST", + service: "Transactions", + lastDays: null + }, + issues: 10 + }, + { + impact: 50, + key: { + environment: "TEST", + service: "API", + lastDays: null + }, + issues: 10 + }, + { + impact: 1, + key: { + environment: "TEST", + service: "Orders", + lastDays: null + }, + issues: 30 + }, + { + impact: 120, + key: { + environment: "TEST", + service: "Users", + lastDays: null + }, + issues: 110 + }, + { + impact: 120, + key: { + environment: "TEST", + service: "Users1", + lastDays: null + }, + issues: 110 + }, + { + impact: 70, + key: { + environment: "TEST", + service: "Users2", + lastDays: null + }, + issues: 70 + }, + { + impact: 99.1231, + key: { + environment: "TEST", + service: "Users3", + lastDays: null + }, + issues: 110 + } + ] +}; diff --git a/src/components/Dashboard/NewReport/MetricsTable/styles.ts b/src/components/Dashboard/NewReport/MetricsTable/styles.ts new file mode 100644 index 000000000..e514b4148 --- /dev/null +++ b/src/components/Dashboard/NewReport/MetricsTable/styles.ts @@ -0,0 +1,119 @@ +import styled from "styled-components"; +import { + subheadingBoldTypography, + subheadingSemiboldTypography +} from "../../../common/App/typographies"; +import { + SORTING_ORDER, + SortingOrderIconContainerProps +} from "../../../common/SortingSelector/types"; +import { Link } from "../../../common/v3/Link"; +import { TableBodyCellCellProps, TableCellContentProps } from "./types"; + +export const Table = styled.table` + width: 100%; + border-spacing: 0; +`; + +export const TableHead = styled.thead` + color: ${({ theme }) => theme.colors.v3.text.secondary}; + padding-bottom: 4px; +`; + +export const TableHeaderCell = styled.th` + height: 68px; +`; + +export const TableCellContent = styled.div` + display: flex; + padding: 16px; + align-items: center; + text-align: ${({ $align = "left" }) => $align}; + justify-content: ${({ $align }) => { + switch ($align) { + case "right": + return "flex-end"; + case "center": + return "center"; + case "left": + default: + return "flex-start"; + } + }}; +`; + +export const TableHeaderCellContent = styled(TableCellContent)` + ${subheadingSemiboldTypography} + font-weight: 400; + gap: 4px; + color: ${({ theme }) => theme.colors.v3.text.tertiary}; +`; + +export const TableBodyRow = styled.tr` + ${subheadingBoldTypography} + color: ${({ theme }) => theme.colors.v3.text.primary}; + height: 68px; + border-spacing: 0; + + &:hover { + background: ${({ theme }) => theme.colors.v3.surface.primaryLight}; + cursor: pointer; + } +`; + +export const TableBodyCell = styled.td` + border: 1px solid ${({ theme }) => theme.colors.v3.surface.sidePanelHeader}; + background: ${({ $severity }) => { + switch ($severity) { + case "Top": + return "radial-gradient(1166.07% 138.62% at 0% 0%, #B92B2B 0%, #B95E2B 100%)"; + case "High": + return "radial-gradient(129.2% 111.8% at 0% 0%, #B95E2B 0%, #B9A22B 100%)"; + case "Medium": + return "radial-gradient(408.61% 111.8% at 0% 0%, #B9A22B 0%, #6AB92B 100%)"; + case "Low": + return "radial-gradient(408.61% 111.8% at 0% 0%, #6AB92B 0%, #2BB997 100%)"; + default: + return "transparent"; + } + }}; +`; + +export const SortingOrderIconContainer = styled.div` + display: flex; + transform: scaleY( + ${({ $sortingOrder }) => ($sortingOrder === SORTING_ORDER.DESC ? -1 : 1)} + ); +`; + +export const LinkChevron = styled.div` + display: none; +`; + +export const SeeIssuesLink = styled(Link)` + ${subheadingBoldTypography} + color: ${({ theme }) => theme.colors.v3.text.primary}; + min-width: 103px; + width: 100%; + + &:hover { + align-items: center; + justify-content: space-between; + display: flex; + + span { + display: none; + } + + ${LinkChevron} { + display: flex; + margin-left: auto; + } + } + + &:hover::before { + content: "See Issues"; + margin-left: auto; + color: ${({ theme }) => theme.colors.v3.text.link}; + } +`; diff --git a/src/components/Dashboard/NewReport/MetricsTable/types.ts b/src/components/Dashboard/NewReport/MetricsTable/types.ts new file mode 100644 index 000000000..e09b054fe --- /dev/null +++ b/src/components/Dashboard/NewReport/MetricsTable/types.ts @@ -0,0 +1,23 @@ +import { ServiceData } from "../types"; + +export interface MetricsTableProps { + data: ServiceData[]; + showSign: boolean; + onServiceSelected: (name: string) => void; +} + +export type ContentAlignment = "left" | "center" | "right"; +export type Severity = "Top" | "High" | "Medium" | "Low"; + +export interface ColumnMeta { + contentAlign?: ContentAlignment; + info?: string; +} + +export interface TableCellContentProps { + $align?: ContentAlignment; +} + +export interface TableBodyCellCellProps { + $severity: Severity | null; +} diff --git a/src/components/Dashboard/NewReport/NewReport.stories.tsx b/src/components/Dashboard/NewReport/NewReport.stories.tsx new file mode 100644 index 000000000..5da162e7e --- /dev/null +++ b/src/components/Dashboard/NewReport/NewReport.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { NewReport } from "."; +import { actions } from "../actions"; +import { mockedReport } from "./MetricsTable/mockData"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Dashboard/NewReport", + component: NewReport, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Default: Story = { + play: () => { + window.setTimeout(() => { + window.postMessage({ + type: "digma", + action: actions.SET_METRICS_REPORT_DATA, + payload: { ...mockedReport } + }); + }, 500); + + window.setTimeout(() => { + window.postMessage({ + type: "digma", + action: actions.SET_SERVICES, + payload: ["service 1", "service 2", "service 3", "service 4"] + }); + }, 500); + } +}; diff --git a/src/components/Dashboard/NewReport/ReportHeader/ReportHeader.stories.tsx b/src/components/Dashboard/NewReport/ReportHeader/ReportHeader.stories.tsx new file mode 100644 index 000000000..abba7c2d5 --- /dev/null +++ b/src/components/Dashboard/NewReport/ReportHeader/ReportHeader.stories.tsx @@ -0,0 +1,36 @@ +import { Meta, StoryObj } from "@storybook/react"; + +import { fn } from "@storybook/test"; +import { ReportHeader } from "."; +import { actions } from "../../actions"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Dashboard/NewReport/ReportHeader", + component: ReportHeader, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const Default: Story = { + args: { + onFilterChanged: fn(), + onViewModeChanged: fn() + }, + play: () => { + window.setTimeout(() => { + window.postMessage({ + type: "digma", + action: actions.SET_SERVICES, + payload: ["service 3", "service 1", "service 2", "service 4"] + }); + }, 500); + } +}; diff --git a/src/components/Dashboard/NewReport/ReportHeader/index.tsx b/src/components/Dashboard/NewReport/ReportHeader/index.tsx new file mode 100644 index 000000000..00b30e65d --- /dev/null +++ b/src/components/Dashboard/NewReport/ReportHeader/index.tsx @@ -0,0 +1,270 @@ +import { useEffect, useMemo, useState } from "react"; +import { + DataFetcherConfiguration, + useFetchData +} from "../../../../hooks/useFetchData"; +import { useConfigSelector } from "../../../../store/config/useConfigSelector"; +import { WrenchIcon } from "../../../common/icons/12px/WrenchIcon"; + +import { actions } from "../../actions"; + +import { usePrevious } from "../../../../hooks/usePrevious"; +import { Environment } from "../../../common/App/types"; + +import { isEnvironment } from "../../../../typeGuards/isEnvironment"; +import { isNumber } from "../../../../typeGuards/isNumber"; +import { CodeIcon } from "../../../common/icons/12px/CodeIcon"; +import { DurationBreakdownIcon } from "../../../common/icons/12px/DurationBreakdownIcon"; +import { InfinityIcon } from "../../../common/icons/16px/InfinityIcon"; +import { TableIcon } from "../../../common/icons/16px/TableIcon"; +import { TreemapIcon } from "../../../common/icons/16px/TreemapIcon"; +import { GetServicesPayload } from "../types"; +import * as s from "./styles"; +import { ReportHeaderProps, ReportTimeMode, ReportViewMode } from "./types"; + +const baseFetchConfig = { + refreshWithInterval: false, + refreshOnPayloadChange: true +}; + +const dataFetcherFiltersConfiguration: DataFetcherConfiguration = { + requestAction: actions.GET_SERVICES, + responseAction: actions.SET_SERVICES, + ...baseFetchConfig +}; + +export const formatUnit = (value: number, unit: string) => + value === 1 ? `${value} ${unit}` : `${value} ${unit}s`; + +const DEFAULT_PERIOD = 1; + +export const ReportHeader = ({ + onFilterChanged, + onViewModeChanged +}: ReportHeaderProps) => { + const { environments } = useConfigSelector(); + const [periodInDays, setPeriodInDays] = useState(DEFAULT_PERIOD); + const [viewMode, setVieMode] = useState("table"); + const [timeMode, setTimeMode] = useState("baseline"); + const [selectedServices, setSelectedServices] = useState([]); + const [servicesFromStore, setServicesFromStore] = useState([]); + const [selectedEnvironment, setSelectedEnvironment] = + useState(null); + + const previousSelectedServices = usePrevious(selectedServices); + const previousEnvironment = usePrevious(selectedEnvironment); + const previousTimeMode = usePrevious(timeMode); + const previousPeriod = usePrevious(periodInDays); + + const getServicesPayload = useMemo( + () => ({ environment: selectedEnvironment?.id ?? null }), + [selectedEnvironment] + ); + + const { data: services, getData } = useFetchData< + GetServicesPayload, + string[] + >(dataFetcherFiltersConfiguration, getServicesPayload); + const previousServices = usePrevious(services); + + useEffect(() => { + setServicesFromStore(services ?? []); + }, [services, setServicesFromStore]); + + useEffect(() => { + getData(); + }, []); + + useEffect(() => { + setSelectedEnvironment( + environments?.length && environments?.length > 0 ? environments[0] : null + ); + setServicesFromStore([]); + }, [environments]); + + useEffect(() => { + if (!selectedEnvironment?.id) { + return; + } + + onFilterChanged({ + lastDays: timeMode === "baseline" ? null : periodInDays, + services: + selectedServices.length > 0 + ? selectedServices + : servicesFromStore ?? [], + environmentId: selectedEnvironment?.id ?? null + }); + }, [servicesFromStore, selectedEnvironment]); + + useEffect(() => { + if ( + !selectedEnvironment?.id || + getServicesPayload.environment !== selectedEnvironment.id || + servicesFromStore.length === 0 + ) { + return; + } + + if ( + previousTimeMode !== timeMode || + (isEnvironment(selectedEnvironment) && + previousEnvironment !== selectedEnvironment) || + previousSelectedServices !== selectedServices || + (isNumber(periodInDays) && previousPeriod !== periodInDays) + ) { + onFilterChanged({ + lastDays: timeMode === "baseline" ? null : periodInDays, + services: + selectedServices.length > 0 + ? selectedServices + : servicesFromStore ?? [], + environmentId: selectedEnvironment?.id ?? null + }); + } + }, [ + periodInDays, + timeMode, + selectedServices, + selectedEnvironment, + onFilterChanged, + previousEnvironment, + previousTimeMode, + previousPeriod, + previousSelectedServices, + previousServices, + getServicesPayload, + servicesFromStore + ]); + + const handleSelectedEnvironmentChanged = (option: string | string[]) => { + const newItem = + option === selectedEnvironment?.id + ? [""] + : Array.isArray(option) + ? option + : [option]; + + const newItemEnv = environments?.find((x) => x.id === newItem[0]) ?? null; + setSelectedEnvironment(newItemEnv); + setSelectedServices([]); + setServicesFromStore([]); + }; + + const handleSelectedServicesChanged = (option: string | string[]) => { + const newItem = Array.isArray(option) ? option : [option]; + setSelectedServices(newItem); + }; + + const handlePeriodChanged = (option: string | string[]) => { + const newItem = Array.isArray(option) ? option : [option]; + if (newItem.length === 0) { + setPeriodInDays(DEFAULT_PERIOD); + return; + } + + const value = newItem[0]; + const newValue = Number(value); + setPeriodInDays(newValue); + }; + + const handleViewModeChanged = (value: string) => { + const newMode = value as ReportViewMode; + setVieMode(newMode); + onViewModeChanged(newMode); + }; + + const handleTimeModeChanged = (value: string) => { + const newMode = value as ReportTimeMode; + setTimeMode(newMode); + }; + + return ( + + + Services with critical issues + + + + + a.name.localeCompare(b.name)) + .map((x) => ({ + label: x.name, + value: x.id, + enabled: true, + selected: x.id === selectedEnvironment?.id + })) ?? [] + } + showSelectedState={true} + icon={(props) => + selectedEnvironment?.type === "Public" ? ( + + ) : ( + + ) + } + onChange={handleSelectedEnvironmentChanged} + placeholder={selectedEnvironment?.name ?? "Select Environments"} + /> + + {timeMode === "changes" && ( + ({ + value: x.toString(), + label: formatUnit(x, "Day"), + selected: x === periodInDays, + enabled: true + }))} + icon={DurationBreakdownIcon} + onChange={handlePeriodChanged} + placeholder={`Period: ${formatUnit(periodInDays, "day")}`} + /> + )} + + ({ + label: service, + value: service, + enabled: true, + selected: selectedServices.includes(service) + })) ?? [] + } + showSelectedState={true} + multiselect={true} + icon={WrenchIcon} + onChange={handleSelectedServicesChanged} + placeholder={ + selectedServices.length > 0 ? "Services" : "All Services" + } + /> + + + }, + { + value: "treemap", + icon: (props) => + } + ]} + value={viewMode} + onValueChange={handleViewModeChanged} + /> + + + ); +}; diff --git a/src/components/Dashboard/NewReport/ReportHeader/styles.ts b/src/components/Dashboard/NewReport/ReportHeader/styles.ts new file mode 100644 index 000000000..468935cc2 --- /dev/null +++ b/src/components/Dashboard/NewReport/ReportHeader/styles.ts @@ -0,0 +1,72 @@ +import styled from "styled-components"; +import { Select } from "../../../common/v3/Select"; +import { Toggle } from "../../../common/v3/Toggle"; +import { OptionButton } from "../../../common/v3/Toggle/styles"; + +export const Container = styled.div` + display: flex; + gap: 24px; + flex-direction: column; +`; + +export const Title = styled.div` + font-size: 20px; + font-style: normal; + font-weight: 700; + line-height: normal; + color: ${({ theme }) => theme.colors.v3.text.primary}; +`; + +export const Row = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const FilterSelect = styled(Select)` + height: 36px; + width: 168px; + border-radius: 8px; +`; + +export const Filters = styled(Row)` + display: flex; + gap: 18px; +`; + +export const EnvironmentFilter = styled(FilterSelect)` + width: 124px; +`; + +const StyledToggle = styled(Toggle)` + align-items: center; + background-color: transparent; + border-radius: 8px; + border-color: ${({ theme }) => theme.colors.v3.stroke.primaryLight}; +`; + +export const ViewModeToggle = styled(StyledToggle)` + padding: 6px; + + ${OptionButton} { + padding: 6px; + } +`; + +export const TimeModeToggle = styled(StyledToggle)` + padding: 0; + + ${OptionButton} { + padding: 10px 16px; + + &:first-child { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + + &:last-child { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + } +`; diff --git a/src/components/Dashboard/NewReport/ReportHeader/types.ts b/src/components/Dashboard/NewReport/ReportHeader/types.ts new file mode 100644 index 000000000..9a128d144 --- /dev/null +++ b/src/components/Dashboard/NewReport/ReportHeader/types.ts @@ -0,0 +1,12 @@ +import { ReportFilterQuery } from "../types"; + +export interface GetServicesPayload { + environment: string | null; +} + +export type ReportViewMode = "treemap" | "table"; +export type ReportTimeMode = "baseline" | "changes"; +export interface ReportHeaderProps { + onFilterChanged: (query: ReportFilterQuery) => void; + onViewModeChanged: (viewMode: ReportViewMode) => void; +} diff --git a/src/components/Dashboard/NewReport/index.tsx b/src/components/Dashboard/NewReport/index.tsx new file mode 100644 index 000000000..49e83868e --- /dev/null +++ b/src/components/Dashboard/NewReport/index.tsx @@ -0,0 +1,80 @@ +import { useLayoutEffect, useState } from "react"; +import { changeScope } from "../../../utils/actions/changeScope"; +import { DigmaLogoIcon } from "../../common/icons/16px/DigmaLogoIcon"; +import { SCOPE_CHANGE_EVENTS } from "../../Main/types"; +import { actions } from "../actions"; +import { Chart } from "./Chart"; +import { MetricsTable } from "./MetricsTable"; +import { ReportHeader } from "./ReportHeader"; +import { ReportViewMode } from "./ReportHeader/types"; +import * as s from "./styles"; +import { ReportFilterQuery } from "./types"; +import { useReportsData } from "./useReportsData"; + +const DefaultQuery: ReportFilterQuery = { + environmentId: "", + services: [], + lastDays: null +}; + +export const NewReport = () => { + const [query, setQuery] = useState(DefaultQuery); + const { data } = useReportsData(query); + const [viewMode, setViewMode] = useState("table"); + + useLayoutEffect(() => { + window.sendMessageToDigma({ + action: actions.INITIALIZE + }); + }, []); + + const handleFilterChanged = (query: ReportFilterQuery) => { + setQuery(query); + }; + + const handleViewModeChange = (value: ReportViewMode) => { + setViewMode(value); + }; + + const handleServiceSelected = (name: string) => { + changeScope({ + span: null, + environmentId: query.environmentId ?? undefined, + context: { + event: SCOPE_CHANGE_EVENTS.METRICS_SERVICE_SELECTED, + payload: { + service: name + } + } + }); + }; + + const serviceData = (query?.services.length > 0 ? data?.reports : null) ?? []; + + return ( + + + + + + {viewMode === "table" && ( + + )} + {viewMode === "treemap" && ( + + )} + + + © 2024 digma.ai + + + + ); +}; diff --git a/src/components/Dashboard/NewReport/styles.ts b/src/components/Dashboard/NewReport/styles.ts new file mode 100644 index 000000000..a3a1c2896 --- /dev/null +++ b/src/components/Dashboard/NewReport/styles.ts @@ -0,0 +1,58 @@ +import styled from "styled-components"; +import { bodyRegularTypography } from "../../common/App/typographies"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + padding: 24px 24px 16px; + gap: 24px; + box-sizing: border-box; + overflow: auto; +`; + +export const Footer = styled.div` + align-items: center; + display: flex; + justify-content: start; + gap: 8px; + margin-top: auto; + color: ${({ theme }) => theme.colors.v3.text.disabled}; + ${bodyRegularTypography} +`; + +export const Section = styled.div` + display: flex; + height: 100%; + flex-direction: column; + position: relative; + overflow: hidden; +`; + +export const ContainerBackgroundGradient = styled.div` + z-index: -1; + position: absolute; + margin: auto; + right: -24%; + bottom: -117%; + height: 160%; + width: 146%; + border-radius: 100%; + opacity: 0.7; + background: radial-gradient( + 50% 50% at 50% 50%, + rgb(79 93 163 / 60%) 0%, + rgb(79 93 163 / 0%) 100% + ); + filter: blur(5px); +`; + +export const SectionBackground = styled.div` + z-index: -1; + position: absolute; + inset: 0; + height: 100%; + width: 100%; + background: ${({ theme }) => theme.colors.v3.surface.secondary}; +`; diff --git a/src/components/Dashboard/NewReport/tracking.ts b/src/components/Dashboard/NewReport/tracking.ts new file mode 100644 index 000000000..a7a8291fc --- /dev/null +++ b/src/components/Dashboard/NewReport/tracking.ts @@ -0,0 +1,16 @@ +import { addPrefix } from "../../../utils/addPrefix"; + +const TRACKING_PREFIX = "report"; + +export const trackingEvents = addPrefix( + TRACKING_PREFIX, + { + REFRESH_DATA_CLICKED: "refresh report data clicked", + DOWNLOAD_REPORT_CLICKED: "download report data clicked", + ENVIRONMENT_FILTER_SELECTED: "environment filter selected", + SERVICES_FILTER_SELECTED: "service filter selected", + TABLE_SEE_ISSUES_LINK_CLICKED: "table see issues link clicked", + HEATMAP_SEE_ISSUES_LINK_CLICKED: "heatmap see issues link clicked" + }, + " " +); diff --git a/src/components/Dashboard/NewReport/types.ts b/src/components/Dashboard/NewReport/types.ts new file mode 100644 index 000000000..fe1126341 --- /dev/null +++ b/src/components/Dashboard/NewReport/types.ts @@ -0,0 +1,37 @@ +export interface ReportFilterQuery { + environmentId: string | null; + services: string[]; + scope?: string; +} + +export interface ReportQuery { + keys: { + environment: string | null; + service: string | null; + lastDays: number | null; + }[]; +} + +export interface GetServicesPayload { + environment: string | null; +} + +export interface ReportFilterQuery { + environmentId: string | null; + services: string[]; + lastDays: number | null; +} + +export interface ServiceData { + key: { + environment: string; + service: string; + lastDays: number | null; + }; + issues: number; + impact: number; +} + +export interface ServiceMetricsReport { + reports: ServiceData[]; +} diff --git a/src/components/Dashboard/NewReport/useReportsData.ts b/src/components/Dashboard/NewReport/useReportsData.ts new file mode 100644 index 000000000..004a99545 --- /dev/null +++ b/src/components/Dashboard/NewReport/useReportsData.ts @@ -0,0 +1,61 @@ +import { useEffect, useMemo } from "react"; +import { + DataFetcherConfiguration, + useFetchData +} from "../../../hooks/useFetchData"; +import { actions } from "../actions"; +import { ReportFilterQuery, ReportQuery, ServiceMetricsReport } from "./types"; + +const baseFetchConfig = { + refreshWithInterval: false, + refreshOnPayloadChange: true +}; + +const dataFetcherIssuesStatsConfiguration: DataFetcherConfiguration = { + requestAction: actions.GET_METRICS_REPORT_DATA, + responseAction: actions.SET_METRICS_REPORT_DATA, + ...baseFetchConfig +}; + +export const useReportsData = (query: ReportFilterQuery) => { + const payload = useMemo(() => { + if (!query.environmentId && !(query.services?.length > 0)) { + return { + keys: [] + }; + } + + if (!(query.services?.length > 0)) { + return { + keys: [ + { + environment: query.environmentId, + service: null, + lastDays: query.lastDays + } + ] + }; + } + + return { + keys: query.services.map((x) => ({ + environment: query.environmentId, + service: x, + lastDays: query.lastDays + })) + }; + }, [query]); + + const { data, getData } = useFetchData( + dataFetcherIssuesStatsConfiguration, + payload + ); + + useEffect(() => { + getData(); + }, []); + + return { + data + }; +}; diff --git a/src/components/Dashboard/NewReport/utils.ts b/src/components/Dashboard/NewReport/utils.ts new file mode 100644 index 000000000..8865a0afc --- /dev/null +++ b/src/components/Dashboard/NewReport/utils.ts @@ -0,0 +1,27 @@ +import { Severity } from "./MetricsTable/types"; + +export const getSeverity = ( + min: number, + max: number, + value: number +): Severity => { + const normalizedMin = Math.max(min, 0); + const range = max - normalizedMin; + const lowThreshold = normalizedMin + 0.15 * range; + const mediumThreshold = normalizedMin + 0.5 * range; + const highThreshold = normalizedMin + 0.85 * range; + + if (value <= lowThreshold) { + return "Low"; + } + + if (value <= mediumThreshold) { + return "Medium"; + } + + if (value <= highThreshold) { + return "High"; + } + + return "Top"; +}; diff --git a/src/components/Dashboard/actions.ts b/src/components/Dashboard/actions.ts index 80d0d25db..e9b95ee7f 100644 --- a/src/components/Dashboard/actions.ts +++ b/src/components/Dashboard/actions.ts @@ -13,5 +13,7 @@ export const actions = addPrefix(ACTION_PREFIX, { GET_REPORT_ISSUES_STATS: "GET_REPORT_ISSUES_STATS", SET_REPORT_ISSUES_STATS: "SET_REPORT_ISSUES_STATS", GET_REPORT_ASSETS_STATS: "GET_REPORT_ASSETS_STATS", - SET_REPORT_ASSETS_STATS: "SET_REPORT_ASSETS_STATS" + SET_REPORT_ASSETS_STATS: "SET_REPORT_ASSETS_STATS", + GET_METRICS_REPORT_DATA: "GET_METRICS_REPORT_DATA", + SET_METRICS_REPORT_DATA: "SET_METRICS_REPORT_DATA" }); diff --git a/src/components/Main/index.tsx b/src/components/Main/index.tsx index c60836c57..22b3b15c2 100644 --- a/src/components/Main/index.tsx +++ b/src/components/Main/index.tsx @@ -213,6 +213,12 @@ export const Main = () => { case SCOPE_CHANGE_EVENTS.ASSETS_EMPTY_CATEGORY_PARENT_LINK_CLICKED as string: goTo(`/${TAB_IDS.ASSETS}`, { state }); break; + case SCOPE_CHANGE_EVENTS.METRICS_SERVICE_SELECTED as string: { + const serviceToSelect = scope.context.payload?.service as string; + setSelectedServices(serviceToSelect ? [serviceToSelect] : []); + goTo(`/${TAB_IDS.ISSUES}`, { state }); + break; + } case SCOPE_CHANGE_EVENTS.IDE_CODE_LENS_CLICKED as string: { const url = getURLToNavigateOnCodeLensClick(scope); if (url) { diff --git a/src/components/Main/types.ts b/src/components/Main/types.ts index 41017adaf..b795aea50 100644 --- a/src/components/Main/types.ts +++ b/src/components/Main/types.ts @@ -56,7 +56,8 @@ export enum SCOPE_CHANGE_EVENTS { RECENT_ACTIVITY_SPAN_LINK_CLICKED = "RECENT_ACTIVITY_SPAN_LINK_CLICKED", IDE_CODE_LENS_CLICKED = "IDE/CODE_LENS_CLICKED", IDE_NOTIFICATION_LINK_CLICKED = "IDE/NOTIFICATION_LINK_CLICKED", - ASSETS_EMPTY_CATEGORY_PARENT_LINK_CLICKED = "ASSETS/EMPTY_CATEGORY_PARENT_LINK_CLICKED" + ASSETS_EMPTY_CATEGORY_PARENT_LINK_CLICKED = "ASSETS/EMPTY_CATEGORY_PARENT_LINK_CLICKED", + METRICS_SERVICE_SELECTED = "METRICS/SERVICE_SELECTED" } export interface ReactRouterLocationState { diff --git a/src/components/Navigation/KebabMenu/index.tsx b/src/components/Navigation/KebabMenu/index.tsx index 36a301020..954338372 100644 --- a/src/components/Navigation/KebabMenu/index.tsx +++ b/src/components/Navigation/KebabMenu/index.tsx @@ -1,10 +1,12 @@ import { actions as globalActions } from "../../../actions"; import { DIGMA_DOCUMENTATION } from "../../../constants"; +import { getFeatureFlagValue } from "../../../featureFlags"; import { useConfigSelector } from "../../../store/config/useConfigSelector"; -import { OpenInstallationWizardPayload } from "../../../types"; +import { FeatureFlag, OpenInstallationWizardPayload } from "../../../types"; import { openURLInDefaultBrowser } from "../../../utils/actions/openURLInDefaultBrowser"; import { sendUserActionTrackingEvent } from "../../../utils/actions/sendUserActionTrackingEvent"; import { isDigmaEngineRunning } from "../../../utils/isDigmaEngineRunning"; +import { MetricsIcon } from "../../common/icons/12px/MetricsIcon"; import { BookIcon } from "../../common/icons/16px/BookIcon"; import { DigmaLogoFlatIcon } from "../../common/icons/16px/DigmaLogoFlatIcon"; import { FourPointedStarIcon } from "../../common/icons/16px/FourPointedStarIcon"; @@ -20,6 +22,9 @@ import { KebabMenuProps } from "./types"; export const KebabMenu = ({ onClose }: KebabMenuProps) => { const { backendInfo, digmaStatus, environment } = useConfigSelector(); + const isDigmaMetricsEnabled = + backendInfo?.centralize && + getFeatureFlagValue(backendInfo, FeatureFlag.IS_METRICS_REPORT_ENABLED); const handleOnboardingClick = () => { sendUserActionTrackingEvent(trackingEvents.ONBOARDING_LINK_CLICKED); @@ -72,12 +77,13 @@ export const KebabMenu = ({ onClose }: KebabMenuProps) => { onClose(); }; - // const handleReportClick = () => { - // window.sendMessageToDigma({ - // action: globalActions.OPEN_REPORT - // }); - // onClose(); - // }; + const handleReportClick = () => { + window.sendMessageToDigma({ + action: globalActions.OPEN_REPORT + }); + + onClose(); + }; const handleLogoutClick = () => { sendUserActionTrackingEvent(trackingEvents.LOGOUT_CLICKED); @@ -120,15 +126,15 @@ export const KebabMenu = ({ onClose }: KebabMenuProps) => { icon: , onClick: handleDashboardClick }); + } - // if (getFeatureFlagValue(backendInfo, FeatureFlag.IS_REPORT_ENABLED)) { - // items.push({ - // id: "report", - // label: "Open Report", - // icon: , - // onClick: handleReportClick - // }); - // } + if (isDigmaMetricsEnabled) { + items.push({ + id: "metrics", + label: "Digma Metrics", + icon: , + onClick: handleReportClick + }); } items.push({ diff --git a/src/components/common/TreeMap/Tile/index.tsx b/src/components/common/TreeMap/Tile/index.tsx new file mode 100644 index 000000000..8820c6bb3 --- /dev/null +++ b/src/components/common/TreeMap/Tile/index.tsx @@ -0,0 +1,38 @@ +import useDimensions from "react-cool-dimensions"; +import { Tooltip } from "../../v3/Tooltip"; +import * as s from "./styles"; +import { TileProps } from "./types"; + +const MIN_HEIGHT = 86; // in pixels +const MIN_WIDTH = 92; // in pixels +const MIN_HEIGHT_TO_SHOW_CHILDREN = MIN_HEIGHT + 40; // in pixels + +export const Tile = ({ title, children, severity, tooltip }: TileProps) => { + const { observe: observeContainer, entry: containerEntry } = useDimensions(); + + const isContentVisible = + containerEntry?.target && + containerEntry.target.clientHeight >= MIN_HEIGHT && + containerEntry.target.clientWidth >= MIN_WIDTH; + + const isChildrenVisible = + isContentVisible && + containerEntry.target.clientHeight >= MIN_HEIGHT_TO_SHOW_CHILDREN; + + return ( + + + + {isContentVisible && ( + + {title} + {isChildrenVisible && ( + {children} + )} + + )} + + + + ); +}; diff --git a/src/components/common/TreeMap/Tile/styles.ts b/src/components/common/TreeMap/Tile/styles.ts new file mode 100644 index 000000000..d36741031 --- /dev/null +++ b/src/components/common/TreeMap/Tile/styles.ts @@ -0,0 +1,62 @@ +import styled from "styled-components"; +import { hexToRgb } from "../../../../utils/hexToRgb"; +import { + ChildrenContainerProps, + TileContainerProps, + TitleProps +} from "./types"; + +export const Container = styled.div` + width: 100%; + height: 100%; +`; + +export const TileContainer = styled.div` + width: 100%; + height: 100%; + border-radius: 12px; + background: ${({ $severity }) => { + switch ($severity) { + case "Top": + return "radial-gradient(1166.07% 138.62% at 0% 0%, #B92B2B 0%, #B95E2B 100%)"; + case "High": + return "radial-gradient(129.2% 111.8% at 0% 0%, #B95E2B 0%, #B9A22B 100%)"; + case "Medium": + return "radial-gradient(408.61% 111.8% at 0% 0%, #B9A22B 0%, #6AB92B 100%)"; + case "Low": + default: + return "radial-gradient(408.61% 111.8% at 0% 0%, #6AB92B 0%, #2BB997 100%)"; + } + }}; +`; + +export const ContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px; + box-sizing: border-box; +`; + +export const Title = styled.span` + color: ${({ theme }) => theme.colors.v3.text.primary}; + font-size: 24px; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const ChildrenContainer = styled.div` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: ${({ theme }) => { + const rgb = hexToRgb(theme.colors.v3.text.primary); + return rgb + ? `rgba(${rgb.r} ${rgb.g} ${rgb.b} / 70%)` + : theme.colors.v3.text.primary; + }}; + font-size: 32px; + font-weight: 700; +`; diff --git a/src/components/common/TreeMap/Tile/types.ts b/src/components/common/TreeMap/Tile/types.ts new file mode 100644 index 000000000..eafe989a6 --- /dev/null +++ b/src/components/common/TreeMap/Tile/types.ts @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; +import { Severity } from "../../../Dashboard/NewReport/MetricsTable/types"; + +export interface TileProps { + title: string; + children?: ReactNode; + severity?: Severity; + tooltip?: ReactNode; +} + +export interface TileContainerProps { + $severity?: Severity; +} + +export interface TitleProps { + $isVisible?: boolean; +} + +export interface ChildrenContainerProps { + $isVisible?: boolean; +} diff --git a/src/components/common/TreeMap/TreeMap.stories.tsx b/src/components/common/TreeMap/TreeMap.stories.tsx new file mode 100644 index 000000000..8908e6cbd --- /dev/null +++ b/src/components/common/TreeMap/TreeMap.stories.tsx @@ -0,0 +1,92 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { TreeMap } from "."; +import { ServiceTile } from "../../Dashboard/NewReport/Chart/ServiceTile"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "common/TreeMap", + component: TreeMap, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + width: 800, + height: 800, + padding: 40, + data: [ + { + id: "payment", + value: 1500, + content: ( + + ) + }, + { + id: "transaction", + value: 710, + content: ( + + ) + }, + { + id: "share", + value: 530, + content: ( + + ) + }, + { + id: "metadata", + value: 100, + content: ( + + ) + }, + { + id: "monitoring", + value: 3, + content: ( + + ) + } + ] + } +}; diff --git a/src/components/common/TreeMap/index.tsx b/src/components/common/TreeMap/index.tsx new file mode 100644 index 000000000..fe4bbad35 --- /dev/null +++ b/src/components/common/TreeMap/index.tsx @@ -0,0 +1,67 @@ +import squarify from "squarify"; +import { isNull } from "../../../typeGuards/isNull"; +import { TreeMapProps } from "./types"; + +export const TreeMap = ({ padding = 0, data, width, height }: TreeMapProps) => { + const container = { x0: 0, y0: 0, x1: width, y1: height }; + + const dataMax = Math.max(...data.map((item) => item.value)); + const minNormalizedValue = dataMax > 0 ? dataMax * 0.05 : 1; + const normalizedData = data.map((item, index) => { + return { + id: index, + value: item.value < minNormalizedValue ? minNormalizedValue : item.value, + content: item.content + }; + }); + const sortedData = [...normalizedData].sort((a, b) => b.value - a.value); + const tiles = squarify(sortedData, container); + + // Transform coordinates to add paddings between tiles + const transformedTiles = padding + ? tiles.map((tile) => { + const isLeftEdge = tile.x0 === 0; + const isTopEdge = tile.y0 === 0; + const isRightEdge = tile.x1 - width < 1; + const isBottomEdge = tile.y1 - height < 1; + + return { + ...tile, + x0: isLeftEdge ? tile.x0 : tile.x0 + padding, + y0: isTopEdge ? tile.y0 : tile.y0 + padding, + x1: isRightEdge ? tile.x1 : tile.x1 - padding, + y1: isBottomEdge ? tile.y1 : tile.y1 - padding + }; + }) + : tiles; + + return ( +
+ {[width, height].some(isNull) + ? null + : transformedTiles.map((tile) => { + return ( +
+ {tile.content} +
+ ); + })} +
+ ); +}; diff --git a/src/components/common/TreeMap/types.ts b/src/components/common/TreeMap/types.ts new file mode 100644 index 000000000..407e77b40 --- /dev/null +++ b/src/components/common/TreeMap/types.ts @@ -0,0 +1,14 @@ +import { ReactNode } from "react"; +import { Input } from "squarify"; + +export interface TileData { + id: string; + content: ReactNode; +} + +export interface TreeMapProps { + padding?: number; + data: Input[]; + width: number; + height: number; +} diff --git a/src/components/common/icons/12px/DurationBreakdownIcon.tsx b/src/components/common/icons/12px/DurationBreakdownIcon.tsx new file mode 100644 index 000000000..ef37ce34c --- /dev/null +++ b/src/components/common/icons/12px/DurationBreakdownIcon.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { useIconProps } from "../hooks"; +import { IconProps } from "../types"; + +const DurationBreakdownIconComponent = (props: IconProps) => { + const { size } = useIconProps(props); + + return ( + + + + + + + + + + + + + ); +}; + +export const DurationBreakdownIcon = React.memo(DurationBreakdownIconComponent); diff --git a/src/components/common/icons/12px/MetricsIcon.tsx b/src/components/common/icons/12px/MetricsIcon.tsx new file mode 100644 index 000000000..c1b0dc661 --- /dev/null +++ b/src/components/common/icons/12px/MetricsIcon.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { useIconProps } from "../hooks"; +import { IconProps } from "../types"; + +const MetricsIconComponent = (props: IconProps) => { + const { size, color } = useIconProps(props); + + return ( + + + + ); +}; + +export const MetricsIcon = React.memo(MetricsIconComponent); diff --git a/src/components/common/icons/16px/SortIcon.tsx b/src/components/common/icons/16px/SortIcon.tsx new file mode 100644 index 000000000..a86dc66d2 --- /dev/null +++ b/src/components/common/icons/16px/SortIcon.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { useIconProps } from "../hooks"; +import { IconProps } from "../types"; + +const SortIconComponent = (props: IconProps) => { + const { color, size } = useIconProps(props); + + return ( + + + + + + + + + + + ); +}; + +export const SortIcon = React.memo(SortIconComponent); diff --git a/src/components/common/icons/16px/TableIcon.tsx b/src/components/common/icons/16px/TableIcon.tsx new file mode 100644 index 000000000..fe4ccbf43 --- /dev/null +++ b/src/components/common/icons/16px/TableIcon.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { useIconProps } from "../hooks"; +import { IconProps } from "../types"; + +const TableIconComponent = (props: IconProps) => { + const { size, color } = useIconProps(props); + + return ( + + + + ); +}; + +export const TableIcon = React.memo(TableIconComponent); diff --git a/src/components/common/icons/16px/TreemapIcon.tsx b/src/components/common/icons/16px/TreemapIcon.tsx new file mode 100644 index 000000000..76988fff7 --- /dev/null +++ b/src/components/common/icons/16px/TreemapIcon.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useIconProps } from "../hooks"; +import { IconProps } from "../types"; + +const TreemapIconComponent = (props: IconProps) => { + const { size, color } = useIconProps(props); + + return ( + + + + + + + + ); +}; + +export const TreemapIcon = React.memo(TreemapIconComponent); diff --git a/src/components/common/v3/Select/index.tsx b/src/components/common/v3/Select/index.tsx index 98aa55725..63ed55560 100644 --- a/src/components/common/v3/Select/index.tsx +++ b/src/components/common/v3/Select/index.tsx @@ -161,7 +161,11 @@ export const Select = ({ disabled={disabled} className={className} > - {Icon && } + {Icon && ( + + + + )} {isString(placeholder) && {placeholder}} {multiselect && isSelectedStateEnabled && selectedValues.length > 0 && ( {selectedValues.length} diff --git a/src/components/common/v3/Select/styles.ts b/src/components/common/v3/Select/styles.ts index 1a7c0c609..556108bcf 100644 --- a/src/components/common/v3/Select/styles.ts +++ b/src/components/common/v3/Select/styles.ts @@ -19,9 +19,7 @@ export const Button = styled.button` ? theme.colors.v3.stroke.brandPrimary : theme.colors.v3.stroke.primaryLight}; background: ${({ theme, $isActive }) => - $isActive - ? theme.colors.v3.surface.brandDark - : theme.colors.v3.surface.sidePanelHeader}; + $isActive ? theme.colors.v3.surface.brandDark : "transparent"}; border-radius: 4px; padding: 4px 8px; display: flex; @@ -51,6 +49,10 @@ export const Button = styled.button` } `; +export const ButtonIconContainer = styled.div` + display: flex; +`; + export const ButtonLabel = styled.span` ${subscriptRegularTypography} margin-right: auto; diff --git a/src/components/common/v3/Toggle/index.tsx b/src/components/common/v3/Toggle/index.tsx index d70becd2c..5fdf78913 100644 --- a/src/components/common/v3/Toggle/index.tsx +++ b/src/components/common/v3/Toggle/index.tsx @@ -1,18 +1,20 @@ +import { forwardRef } from "react"; import * as s from "./styles"; import { ToggleProps, ToggleValue } from "./types"; -export const Toggle = ({ +const ToggleComponent = ({ size = "large", options, value, - onValueChange + onValueChange, + className }: ToggleProps) => { const handleOptionButtonClick = (value: T) => { onValueChange(value); }; return ( - + {options.map((option) => ( ({ ); }; + +export const Toggle = forwardRef(ToggleComponent) as typeof ToggleComponent; diff --git a/src/components/common/v3/Toggle/types.ts b/src/components/common/v3/Toggle/types.ts index 815c76cf5..6bb92ee8f 100644 --- a/src/components/common/v3/Toggle/types.ts +++ b/src/components/common/v3/Toggle/types.ts @@ -15,6 +15,7 @@ export interface ToggleProps { onValueChange: (value: T) => void; value: T; size?: ToggleSize; + className?: string; } export interface OptionButtonProps { diff --git a/src/components/common/v3/Tooltip/index.tsx b/src/components/common/v3/Tooltip/index.tsx index c769a5bb4..fe38905c7 100644 --- a/src/components/common/v3/Tooltip/index.tsx +++ b/src/components/common/v3/Tooltip/index.tsx @@ -8,6 +8,7 @@ import { hide, offset, shift, + useClientPoint, useFloating, useHover, useInteractions, @@ -76,7 +77,8 @@ export const Tooltip = ({ isDisabled, fullWidth, title, - boundary + boundary, + followCursor }: TooltipProps) => { const [isOpen, setIsOpen] = useState(false); const arrowRef = useRef(null); @@ -113,10 +115,17 @@ export const Tooltip = ({ const hover = useHover(context, { delay: { open: 1000, close: 0 }, - enabled: !isBoolean(forcedIsOpen) + enabled: !isBoolean(forcedIsOpen) || !followCursor }); - const { getReferenceProps, getFloatingProps } = useInteractions([hover]); + const clientPoint = useClientPoint(context, { + enabled: followCursor + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + hover, + clientPoint + ]); const renderArrow = (withShadow: boolean) => ( { switch (initialPath) { case "report": - return ; + return ; default: return ; diff --git a/src/featureFlags.ts b/src/featureFlags.ts index 0f49c7212..87879db68 100644 --- a/src/featureFlags.ts +++ b/src/featureFlags.ts @@ -14,7 +14,8 @@ export const featureFlagMinBackendVersions: Record = { [FeatureFlag.IS_REPORT_ENABLED]: "0.3.95", [FeatureFlag.ARE_ISSUES_SERVICES_FILTERS_ENABLED]: "0.3.103", [FeatureFlag.ARE_EXTENDED_ASSETS_FILTERS_ENABLED]: "0.3.107", - [FeatureFlag.IS_NEW_IMPACT_SCORE_CALCULATION_ENABLED]: "0.3.107" + [FeatureFlag.IS_NEW_IMPACT_SCORE_CALCULATION_ENABLED]: "0.3.107", + [FeatureFlag.IS_METRICS_REPORT_ENABLED]: "0.3.120-alpha.15" }; export const getFeatureFlagValue = ( diff --git a/src/types.ts b/src/types.ts index c2cabb0cf..2120ae17a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,8 @@ export enum FeatureFlag { IS_REPORT_ENABLED, ARE_ISSUES_SERVICES_FILTERS_ENABLED, ARE_EXTENDED_ASSETS_FILTERS_ENABLED, - IS_NEW_IMPACT_SCORE_CALCULATION_ENABLED + IS_NEW_IMPACT_SCORE_CALCULATION_ENABLED, + IS_METRICS_REPORT_ENABLED } export enum InsightType { diff --git a/src/utils/hexToRgb.ts b/src/utils/hexToRgb.ts new file mode 100644 index 000000000..0a691f375 --- /dev/null +++ b/src/utils/hexToRgb.ts @@ -0,0 +1,19 @@ +// Source: https://stackoverflow.com/a/5624139 + +export const hexToRgb = (hex: string) => { + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace( + shorthandRegex, + (_, r: string, g: string, b: string) => r + r + g + g + b + b + ); + + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } + : null; +};