diff --git a/frontend/src/container/BillingContainer/BillingContainer.styles.scss b/frontend/src/container/BillingContainer/BillingContainer.styles.scss index afb9e80253..05a672b18c 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.styles.scss +++ b/frontend/src/container/BillingContainer/BillingContainer.styles.scss @@ -1,13 +1,29 @@ .billing-container { - padding: 16px 0; - width: 100%; + padding-top: 36px; + width: 65%; .billing-summary { margin: 24px 8px; } .billing-details { - margin: 36px 8px; + margin: 24px 0px; + + .ant-table-title { + color: var(--bg-vanilla-400); + background-color: rgb(27, 28, 32); + } + + .ant-table-cell { + background-color: var(--bg-ink-400); + border-color: var(--bg-slate-500); + } + + .ant-table-tbody { + td { + border-color: var(--bg-slate-500); + } + } } .upgrade-plan-benefits { @@ -24,6 +40,15 @@ } } } + + .empty-graph-card { + .ant-card-body { + height: 40vh; + display: flex; + justify-content: center; + align-items: center; + } + } } .ant-skeleton.ant-skeleton-element.ant-skeleton-active { @@ -34,3 +59,20 @@ .ant-skeleton.ant-skeleton-element .ant-skeleton-input { min-width: 100% !important; } + +.lightMode { + .billing-container { + .billing-details { + .ant-table-cell { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-200); + } + + .ant-table-tbody { + td { + border-color: var(--bg-vanilla-200); + } + } + } + } +} diff --git a/frontend/src/container/BillingContainer/BillingContainer.test.tsx b/frontend/src/container/BillingContainer/BillingContainer.test.tsx index b4eadd433b..cd447e5d60 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.test.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.test.tsx @@ -12,13 +12,36 @@ import BillingContainer from './BillingContainer'; const lisenceUrl = 'http://localhost/api/v2/licenses'; +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + + const uplotMock = jest.fn(() => ({ + paths, + })); + + return { + paths, + default: uplotMock, + }; +}); + +window.ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + describe('BillingContainer', () => { test('Component should render', async () => { act(() => { render(); }); - const unit = screen.getAllByText(/unit/i); - expect(unit[1]).toBeInTheDocument(); + const dataInjection = screen.getByRole('columnheader', { name: /data ingested/i, }); @@ -32,24 +55,15 @@ describe('BillingContainer', () => { }); expect(cost).toBeInTheDocument(); - const total = screen.getByRole('cell', { - name: /total/i, - }); - expect(total).toBeInTheDocument(); - const manageBilling = screen.getByRole('button', { name: /manage billing/i, }); expect(manageBilling).toBeInTheDocument(); - const dollar = screen.getByRole('cell', { - name: /\$0/i, - }); + const dollar = screen.getByText(/\$0/i); expect(dollar).toBeInTheDocument(); - const currentBill = screen.getByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); }); @@ -61,9 +75,7 @@ describe('BillingContainer', () => { const freeTrailText = await screen.findByText('Free Trial'); expect(freeTrailText).toBeInTheDocument(); - const currentBill = await screen.findByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); const dollar0 = await screen.findByText(/\$0/i); @@ -102,9 +114,7 @@ describe('BillingContainer', () => { render(); }); - const currentBill = await screen.findByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); const dollar0 = await screen.findByText(/\$0/i); @@ -137,45 +147,30 @@ describe('BillingContainer', () => { res(ctx.status(200), ctx.json(notOfTrailResponse)), ), ); - render(); + const { findByText } = render(); const billingPeriodText = `Your current billing period is from ${getFormattedDate( billingSuccessResponse.data.billingPeriodStart, )} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`; - const billingPeriod = await screen.findByRole('heading', { - name: new RegExp(billingPeriodText, 'i'), - }); + const billingPeriod = await findByText(billingPeriodText); expect(billingPeriod).toBeInTheDocument(); - const currentBill = await screen.findByRole('heading', { - name: /current bill total/i, - }); + const currentBill = screen.getByText('Billing'); expect(currentBill).toBeInTheDocument(); - const dollar0 = await screen.findAllByText(/\$1278.3/i); - expect(dollar0[0]).toBeInTheDocument(); - expect(dollar0.length).toBe(2); + const dollar0 = await screen.findByText(/\$1,278.3/i); + expect(dollar0).toBeInTheDocument(); const metricsRow = await screen.findByRole('row', { - name: /metrics Million 4012 0.1 \$ 401.2/i, + name: /metrics 4012 Million 0.1 \$ 401.2/i, }); expect(metricsRow).toBeInTheDocument(); const logRow = await screen.findByRole('row', { - name: /Logs GB 497 0.4 \$ 198.8/i, + name: /Logs 497 GB 0.4 \$ 198.8/i, }); expect(logRow).toBeInTheDocument(); - - const totalBill = await screen.findByRole('cell', { - name: /\$1278/i, - }); - expect(totalBill).toBeInTheDocument(); - - const totalBillRow = await screen.findByRole('row', { - name: /total \$1278/i, - }); - expect(totalBillRow).toBeInTheDocument(); }); test('Should render corrent day remaining in billing period', async () => { diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index e419c581ed..54a9aa0ac8 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -2,11 +2,24 @@ import './BillingContainer.styles.scss'; import { CheckCircleOutlined } from '@ant-design/icons'; -import { Button, Col, Row, Skeleton, Table, Tag, Typography } from 'antd'; +import { Color } from '@signozhq/design-tokens'; +import { + Alert, + Button, + Card, + Col, + Flex, + Row, + Skeleton, + Table, + Tag, + Typography, +} from 'antd'; import { ColumnsType } from 'antd/es/table'; import updateCreditCardApi from 'api/billing/checkout'; import getUsage from 'api/billing/getUsage'; import manageCreditCardApi from 'api/billing/manage'; +import Spinner from 'components/Spinner'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import useAnalytics from 'hooks/analytics/useAnalytics'; @@ -22,8 +35,11 @@ import { ErrorResponse, SuccessResponse } from 'types/api'; import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout'; import { License } from 'types/api/licenses/def'; import AppReducer from 'types/reducer/app'; +import { isCloudUser } from 'utils/app'; import { getFormattedDate, getRemainingDays } from 'utils/timeUtils'; +import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph'; + interface DataType { key: string; name: string; @@ -104,12 +120,11 @@ export default function BillingContainer(): JSX.Element { const daysRemainingStr = 'days remaining in your billing period.'; const [headerText, setHeaderText] = useState(''); const [billAmount, setBillAmount] = useState(0); - const [totalBillAmount, setTotalBillAmount] = useState(0); const [activeLicense, setActiveLicense] = useState(null); const [daysRemaining, setDaysRemaining] = useState(0); const [isFreeTrial, setIsFreeTrial] = useState(false); const [data, setData] = useState([]); - const billCurrency = '$'; + const [apiResponse, setApiResponse] = useState({}); const { trackEvent } = useAnalytics(); @@ -120,10 +135,12 @@ export default function BillingContainer(): JSX.Element { const handleError = useAxiosError(); + const isCloudUserVal = isCloudUser(); + const processUsageData = useCallback( (data: any): void => { const { - details: { breakdown = [], total, billTotal }, + details: { breakdown = [], billTotal }, billingPeriodStart, billingPeriodEnd, } = data?.payload || {}; @@ -141,8 +158,7 @@ export default function BillingContainer(): JSX.Element { formattedUsageData.push({ key: `${index}${i}`, name: i === 0 ? element?.type : '', - unit: element?.unit, - dataIngested: tier.quantity, + dataIngested: `${tier.quantity} ${element?.unit}`, pricePerUnit: tier.unitPrice, cost: `$ ${tier.tierCost}`, }); @@ -152,7 +168,6 @@ export default function BillingContainer(): JSX.Element { } setData(formattedUsageData); - setTotalBillAmount(total); if (!licensesData?.payload?.onTrial) { const remainingDays = getRemainingDays(billingPeriodEnd) - 1; @@ -165,6 +180,8 @@ export default function BillingContainer(): JSX.Element { setDaysRemaining(remainingDays > 0 ? remainingDays : 0); setBillAmount(billTotal); } + + setApiResponse(data?.payload || {}); }, [licensesData?.payload?.onTrial], ); @@ -208,11 +225,6 @@ export default function BillingContainer(): JSX.Element { key: 'name', render: (text): JSX.Element =>
{text}
, }, - { - title: 'Unit', - dataIndex: 'unit', - key: 'unit', - }, { title: 'Data Ingested', dataIndex: 'dataIngested', @@ -230,24 +242,6 @@ export default function BillingContainer(): JSX.Element { }, ]; - const renderSummary = (): JSX.Element => ( - - - - Total - - -   -   -   - - - ${totalBillAmount} - - - - ); - const renderTableSkeleton = (): JSX.Element => ( + !isLoading ? ( + + ) : ( + + + + ), + [apiResponse, billAmount, isLoading], + ); + return (
- + + Billing + + + Manage your billing information, invoices, and monitor costs. + + + + -
- - {headerText} - - - {licensesData?.payload?.onTrial && - licensesData?.payload?.trialConvertedToSubscription && ( - - We have received your card details, your billing will only start after - the end of your free trial period. - - )} - - - + + + + {isCloudUserVal ? 'Enterprise Cloud' : 'Enterprise'}{' '} + {isFreeTrial ? Free Trial : ''} + + + {daysRemaining} {daysRemainingStr} + + - - + -
- - Current bill total - + {licensesData?.payload?.onTrial && + licensesData?.payload?.trialConvertedToSubscription && ( + + We have received your card details, your billing will only start after + the end of your free trial period. + + )} - - {billCurrency} - {billAmount}   - {isFreeTrial ? Free Trial : ''} - + {!isLoading ? ( + + ) : ( + + )} + - - {daysRemaining} {daysRemainingStr} - -
+
{!isLoading && ( @@ -403,7 +412,7 @@ export default function BillingContainer(): JSX.Element { columns={columns} dataSource={data} pagination={false} - summary={renderSummary} + bordered={false} /> )} diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.styles.scss b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.styles.scss new file mode 100644 index 0000000000..e5722d4f4a --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.styles.scss @@ -0,0 +1,29 @@ +.billing-graph-card { + .ant-card-body { + height: 40vh; + .uplot-graph-container { + padding: 8px; + } + } + .total-spent { + font-family: 'SF Mono' monospace; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 24px; + } + + .total-spent-title { + font-size: 12px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.48px; + color: rgba(255, 255, 255, 0.5); + } +} + +.lightMode { + .total-spent-title { + color: var(--bg-ink-100); + } +} diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx new file mode 100644 index 0000000000..fcbba624e3 --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/BillingUsageGraph.tsx @@ -0,0 +1,190 @@ +import './BillingUsageGraph.styles.scss'; +import '../../../lib/uPlotLib/uPlotLib.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Card, Flex, Typography } from 'antd'; +import { getComponentForPanelType } from 'constants/panelTypes'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { PropsTypePropsMap } from 'container/GridPanelSwitch/types'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin'; +import getAxes from 'lib/uPlotLib/utils/getAxes'; +import getRenderer from 'lib/uPlotLib/utils/getRenderer'; +import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; +import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale'; +import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale'; +import { FC, useMemo, useRef } from 'react'; +import uPlot from 'uplot'; + +import { + convertDataToMetricRangePayload, + fillMissingValuesForQuantities, +} from './utils'; + +interface BillingUsageGraphProps { + data: any; + billAmount: number; +} +const paths = ( + u: any, + seriesIdx: number, + idx0: number, + idx1: number, + extendGap: boolean, + buildClip: boolean, +): uPlot.Series.PathBuilder => { + const s = u.series[seriesIdx]; + const style = s.drawStyle; + const interp = s.lineInterpolation; + + const renderer = getRenderer(style, interp); + + return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip); +}; + +export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element { + const { data, billAmount } = props; + const graphCompatibleData = useMemo( + () => convertDataToMetricRangePayload(data), + [data], + ); + const chartData = getUPlotChartData(graphCompatibleData); + const graphRef = useRef(null); + const isDarkMode = useIsDarkMode(); + const containerDimensions = useResizeObserver(graphRef); + + const { billingPeriodStart: startTime, billingPeriodEnd: endTime } = data; + + const Component = getComponentForPanelType(PANEL_TYPES.BAR) as FC< + PropsTypePropsMap[PANEL_TYPES] + >; + + const getGraphSeries = (color: string, label: string): any => ({ + drawStyle: 'bars', + paths, + lineInterpolation: 'spline', + show: true, + label, + fill: color, + stroke: color, + width: 2, + spanGaps: true, + points: { + size: 5, + show: false, + stroke: color, + }, + }); + + const uPlotSeries: any = useMemo( + () => [ + { label: 'Timestamp', stroke: 'purple' }, + getGraphSeries( + '#DECCBC', + graphCompatibleData.data.result[0]?.legend as string, + ), + getGraphSeries( + '#4E74F8', + graphCompatibleData.data.result[1]?.legend as string, + ), + getGraphSeries( + '#F24769', + graphCompatibleData.data.result[2]?.legend as string, + ), + ], + [graphCompatibleData.data.result], + ); + + const axesOptions = getAxes(isDarkMode, ''); + + const optionsForChart: uPlot.Options = useMemo( + () => ({ + id: 'billing-usage-breakdown', + series: uPlotSeries, + width: containerDimensions.width, + height: containerDimensions.height - 30, + axes: [ + { + ...axesOptions[0], + grid: { + ...axesOptions.grid, + show: false, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + }, + { + ...axesOptions[1], + stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400, + }, + ], + scales: { + x: { + ...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start + }, + y: { + ...getYAxisScale({ + series: graphCompatibleData?.data.newResult.data.result, + yAxisUnit: '', + softMax: null, + softMin: null, + }), + }, + }, + legend: { + show: true, + live: false, + isolate: true, + }, + cursor: { + lock: false, + focus: { + prox: 1e6, + bias: 1, + }, + }, + focus: { + alpha: 0.3, + }, + padding: [32, 32, 16, 16], + plugins: [ + tooltipPlugin( + fillMissingValuesForQuantities(graphCompatibleData, chartData[0]), + '', + true, + ), + ], + }), + [ + axesOptions, + chartData, + containerDimensions.height, + containerDimensions.width, + endTime, + graphCompatibleData, + isDarkMode, + startTime, + uPlotSeries, + ], + ); + + const numberFormatter = new Intl.NumberFormat('en-US'); + + return ( + + + + + TOTAL SPENT + + + ${numberFormatter.format(billAmount)} + + + +
+ +
+
+ ); +} diff --git a/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts b/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts new file mode 100644 index 0000000000..d40c8a6097 --- /dev/null +++ b/frontend/src/container/BillingContainer/BillingUsageGraph/utils.ts @@ -0,0 +1,87 @@ +import { isEmpty, isNull } from 'lodash-es'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; + +export const convertDataToMetricRangePayload = ( + data: any, +): MetricRangePayloadProps => { + const emptyStateData = { + data: { + newResult: { data: { result: [], resultType: '' } }, + result: [], + resultType: '', + }, + }; + if (isEmpty(data)) { + return emptyStateData; + } + const { + details: { breakdown = [] }, + } = data || {}; + + if (isNull(breakdown) || breakdown.length === 0) { + return emptyStateData; + } + + const payload = breakdown.map((info: any) => { + const metric = info.type; + const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort( + (a: any, b: any) => a.timestamp - b.timestamp, + ); + const values = (sortedBreakdownData || []).map((categoryInfo: any) => [ + categoryInfo.timestamp, + categoryInfo.total, + ]); + const queryName = info.type; + const legend = info.type; + const { unit } = info; + const quantity = sortedBreakdownData.map( + (categoryInfo: any) => categoryInfo.quantity, + ); + return { metric, values, queryName, legend, quantity, unit }; + }); + + const sortedData = payload.sort((a: any, b: any) => { + const sumA = a.values.reduce((acc: any, val: any) => acc + val[1], 0); + const avgA = a.values.length ? sumA / a.values.length : 0; + const sumB = b.values.reduce((acc: any, val: any) => acc + val[1], 0); + const avgB = b.values.length ? sumB / b.values.length : 0; + + return sumA === sumB ? avgB - avgA : sumB - sumA; + }); + + return { + data: { + newResult: { data: { result: sortedData, resultType: '' } }, + result: sortedData, + resultType: '', + }, + }; +}; + +export function fillMissingValuesForQuantities( + data: any, + timestampArray: number[], +): MetricRangePayloadProps { + const { result } = data.data; + + const transformedResultArr: any[] = []; + result.forEach((item: any) => { + const timestampToQuantityMap: { [timestamp: number]: number } = {}; + item.values.forEach((val: number[], index: number) => { + timestampToQuantityMap[val[0]] = item.quantity[index]; + }); + + const quantityArray = timestampArray.map( + (timestamp: number) => timestampToQuantityMap[timestamp] ?? null, + ); + transformedResultArr.push({ ...item, quantity: quantityArray }); + }); + + return { + data: { + newResult: { data: { result: transformedResultArr, resultType: '' } }, + result: transformedResultArr, + resultType: '', + }, + }; +} diff --git a/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts b/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts index 3a69f459fe..4ec3677dfb 100644 --- a/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts +++ b/frontend/src/lib/uPlotLib/plugins/tooltipPlugin.ts @@ -27,6 +27,7 @@ const generateTooltipContent = ( idx: number, yAxisUnit?: string, series?: uPlot.Options['series'], + isBillingUsageGraphs?: boolean, // eslint-disable-next-line sonarjs/cognitive-complexity ): HTMLElement => { const container = document.createElement('div'); @@ -49,12 +50,22 @@ const generateTooltipContent = ( if (Array.isArray(series) && series.length > 0) { series.forEach((item, index) => { if (index === 0) { - tooltipTitle = dayjs(data[0][idx] * 1000).format('MMM DD YYYY HH:mm:ss'); + if (isBillingUsageGraphs) { + tooltipTitle = dayjs(data[0][idx] * 1000).format('MMM DD YYYY'); + } else { + tooltipTitle = dayjs(data[0][idx] * 1000).format('MMM DD YYYY HH:mm:ss'); + } } else if (item.show) { - const { metric = {}, queryName = '', legend = '' } = - seriesList[index - 1] || {}; + const { + metric = {}, + queryName = '', + legend = '', + quantity = [], + unit = '', + } = seriesList[index - 1] || {}; const value = data[index][idx]; + const dataIngested = quantity[idx]; const label = getLabelName(metric, queryName || '', legend || ''); const color = generateColor(label, themeColors.chartcolors); @@ -63,6 +74,7 @@ const generateTooltipContent = ( if (Number.isFinite(value)) { const tooltipValue = getToolTipValue(value, yAxisUnit); + const dataIngestedFormated = getToolTipValue(dataIngested); if ( duplicatedLegendLabels[label] || Object.prototype.hasOwnProperty.call(formattedData, label) @@ -93,7 +105,9 @@ const generateTooltipContent = ( value, tooltipValue, queryName, - textContent: `${tooltipItemLabel} : ${tooltipValue}`, + textContent: isBillingUsageGraphs + ? `${tooltipItemLabel} : $${tooltipValue} - ${dataIngestedFormated} ${unit}` + : `${tooltipItemLabel} : ${tooltipValue}`, }; tooltipCount += 1; @@ -168,6 +182,7 @@ const generateTooltipContent = ( const tooltipPlugin = ( apiResponse: MetricRangePayloadProps | undefined, yAxisUnit?: string, + isBillingUsageGraphs?: boolean, ): any => { let over: HTMLElement; let bound: HTMLElement; @@ -228,6 +243,7 @@ const tooltipPlugin = ( idx, yAxisUnit, u.series, + isBillingUsageGraphs, ); overlay.appendChild(content); placement(overlay, anchor, 'right', 'start', { bound }); diff --git a/frontend/src/pages/Billing/BillingPage.styles.scss b/frontend/src/pages/Billing/BillingPage.styles.scss index ced1d4d055..bb6bd3b529 100644 --- a/frontend/src/pages/Billing/BillingPage.styles.scss +++ b/frontend/src/pages/Billing/BillingPage.styles.scss @@ -2,4 +2,6 @@ display: flex; width: 100%; color: #fff; + justify-content: center; + align-items: center; } diff --git a/frontend/src/types/api/widgets/getQuery.ts b/frontend/src/types/api/widgets/getQuery.ts index 0b36af1541..5f455698dd 100644 --- a/frontend/src/types/api/widgets/getQuery.ts +++ b/frontend/src/types/api/widgets/getQuery.ts @@ -14,6 +14,8 @@ export interface QueryData { queryName: string; legend?: string; values: [number, string][]; + quantity?: number[]; + unit?: string; } export interface SeriesItem { @@ -28,6 +30,9 @@ export interface QueryDataV3 { queryName: string; legend?: string; series: SeriesItem[] | null; + quantity?: number; + unitPrice?: number; + unit?: string; } export interface Props {