mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-07-25 08:54:26 +08:00
feat: [SIG-557]: added Billing usage graph - daily and weekly (#4686)
* feat: added Billing usage graph - daily and weekly * feat: removed mocked response * feat: removed weekly chart and fixed data transformations * feat: added loading states * feat: moved function to util file * feat: fixed review comments * feat: fixed JEST test case * feat: test fix - commit * feat: test fix - commit * feat: test fix - commit * feat: edited title conditionally * feat: edited tooltip content * feat: removed time from tooltip content and skeleton for cycleInfo Alert --------- Co-authored-by: Sagar Rajput <sagarrajput@192.168.1.2>
This commit is contained in:
parent
506448fe61
commit
c6080ca02e
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(<BillingContainer />);
|
||||
});
|
||||
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(<BillingContainer />);
|
||||
});
|
||||
|
||||
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(<BillingContainer />);
|
||||
const { findByText } = render(<BillingContainer />);
|
||||
|
||||
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 () => {
|
||||
|
@ -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<License | null>(null);
|
||||
const [daysRemaining, setDaysRemaining] = useState(0);
|
||||
const [isFreeTrial, setIsFreeTrial] = useState(false);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const billCurrency = '$';
|
||||
const [apiResponse, setApiResponse] = useState<any>({});
|
||||
|
||||
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 => <div>{text}</div>,
|
||||
},
|
||||
{
|
||||
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 => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<Typography.Title level={3} style={{ fontWeight: 500, margin: ' 0px' }}>
|
||||
Total
|
||||
</Typography.Title>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}> </Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2}> </Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}> </Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4}>
|
||||
<Typography.Title level={3} style={{ fontWeight: 500, margin: ' 0px' }}>
|
||||
${totalBillAmount}
|
||||
</Typography.Title>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
|
||||
const renderTableSkeleton = (): JSX.Element => (
|
||||
<Table
|
||||
dataSource={dummyData}
|
||||
@ -336,66 +330,81 @@ export default function BillingContainer(): JSX.Element {
|
||||
updateCreditCard,
|
||||
]);
|
||||
|
||||
const BillingUsageGraphCallback = useCallback(
|
||||
() =>
|
||||
!isLoading ? (
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
) : (
|
||||
<Card className="empty-graph-card" bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
),
|
||||
[apiResponse, billAmount, isLoading],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="billing-container">
|
||||
<Row
|
||||
justify="space-between"
|
||||
align="middle"
|
||||
gutter={[16, 16]}
|
||||
style={{
|
||||
margin: 0,
|
||||
}}
|
||||
<Flex vertical style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 18 }}>
|
||||
Billing
|
||||
</Typography.Text>
|
||||
<Typography.Text color={Color.BG_VANILLA_400}>
|
||||
Manage your billing information, invoices, and monitor costs.
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ minHeight: 150, marginBottom: 16 }}
|
||||
className="page-info"
|
||||
>
|
||||
<Col span={20}>
|
||||
<Typography.Title level={4} ellipsis style={{ fontWeight: '300' }}>
|
||||
{headerText}
|
||||
</Typography.Title>
|
||||
|
||||
{licensesData?.payload?.onTrial &&
|
||||
licensesData?.payload?.trialConvertedToSubscription && (
|
||||
<Typography.Title
|
||||
level={5}
|
||||
ellipsis
|
||||
style={{ fontWeight: '300', color: '#49aa19' }}
|
||||
>
|
||||
We have received your card details, your billing will only start after
|
||||
the end of your free trial period.
|
||||
</Typography.Title>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex vertical>
|
||||
<Typography.Title level={5} style={{ marginTop: 2, fontWeight: 500 }}>
|
||||
{isCloudUserVal ? 'Enterprise Cloud' : 'Enterprise'}{' '}
|
||||
{isFreeTrial ? <Tag color="success"> Free Trial </Tag> : ''}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ fontSize: 12, color: Color.BG_VANILLA_400 }}>
|
||||
{daysRemaining} {daysRemainingStr}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
disabled={isLoading}
|
||||
onClick={handleBilling}
|
||||
>
|
||||
{isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription
|
||||
? 'Upgrade Plan'
|
||||
: 'Manage Billing'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Flex>
|
||||
|
||||
<div className="billing-summary">
|
||||
<Typography.Title level={4} style={{ margin: '16px 0' }}>
|
||||
Current bill total
|
||||
</Typography.Title>
|
||||
{licensesData?.payload?.onTrial &&
|
||||
licensesData?.payload?.trialConvertedToSubscription && (
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
style={{ fontWeight: '300', color: '#49aa19', fontSize: 12 }}
|
||||
>
|
||||
We have received your card details, your billing will only start after
|
||||
the end of your free trial period.
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<Typography.Title
|
||||
level={3}
|
||||
style={{ margin: '16px 0', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{billCurrency}
|
||||
{billAmount}
|
||||
{isFreeTrial ? <Tag color="success"> Free Trial </Tag> : ''}
|
||||
</Typography.Title>
|
||||
{!isLoading ? (
|
||||
<Alert
|
||||
message={headerText}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton.Input active style={{ height: 20, marginTop: 20 }} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Typography.Paragraph style={{ margin: '16px 0' }}>
|
||||
{daysRemaining} {daysRemainingStr}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<BillingUsageGraphCallback />
|
||||
|
||||
<div className="billing-details">
|
||||
{!isLoading && (
|
||||
@ -403,7 +412,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
summary={renderSummary}
|
||||
bordered={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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<HTMLDivElement>(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 (
|
||||
<Card bordered={false} className="billing-graph-card">
|
||||
<Flex justify="space-between">
|
||||
<Flex vertical gap={6}>
|
||||
<Typography.Text className="total-spent-title">
|
||||
TOTAL SPENT
|
||||
</Typography.Text>
|
||||
<Typography.Text color={Color.BG_VANILLA_100} className="total-spent">
|
||||
${numberFormatter.format(billAmount)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
|
||||
<Component data={chartData} options={optionsForChart} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -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: '',
|
||||
},
|
||||
};
|
||||
}
|
@ -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 });
|
||||
|
@ -2,4 +2,6 @@
|
||||
display: flex;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user