mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-07-28 04:01:59 +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 {
|
.billing-container {
|
||||||
padding: 16px 0;
|
padding-top: 36px;
|
||||||
width: 100%;
|
width: 65%;
|
||||||
|
|
||||||
.billing-summary {
|
.billing-summary {
|
||||||
margin: 24px 8px;
|
margin: 24px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.billing-details {
|
.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 {
|
.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 {
|
.ant-skeleton.ant-skeleton-element.ant-skeleton-active {
|
||||||
@ -34,3 +59,20 @@
|
|||||||
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
|
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
|
||||||
min-width: 100% !important;
|
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';
|
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', () => {
|
describe('BillingContainer', () => {
|
||||||
test('Component should render', async () => {
|
test('Component should render', async () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
render(<BillingContainer />);
|
render(<BillingContainer />);
|
||||||
});
|
});
|
||||||
const unit = screen.getAllByText(/unit/i);
|
|
||||||
expect(unit[1]).toBeInTheDocument();
|
|
||||||
const dataInjection = screen.getByRole('columnheader', {
|
const dataInjection = screen.getByRole('columnheader', {
|
||||||
name: /data ingested/i,
|
name: /data ingested/i,
|
||||||
});
|
});
|
||||||
@ -32,24 +55,15 @@ describe('BillingContainer', () => {
|
|||||||
});
|
});
|
||||||
expect(cost).toBeInTheDocument();
|
expect(cost).toBeInTheDocument();
|
||||||
|
|
||||||
const total = screen.getByRole('cell', {
|
|
||||||
name: /total/i,
|
|
||||||
});
|
|
||||||
expect(total).toBeInTheDocument();
|
|
||||||
|
|
||||||
const manageBilling = screen.getByRole('button', {
|
const manageBilling = screen.getByRole('button', {
|
||||||
name: /manage billing/i,
|
name: /manage billing/i,
|
||||||
});
|
});
|
||||||
expect(manageBilling).toBeInTheDocument();
|
expect(manageBilling).toBeInTheDocument();
|
||||||
|
|
||||||
const dollar = screen.getByRole('cell', {
|
const dollar = screen.getByText(/\$0/i);
|
||||||
name: /\$0/i,
|
|
||||||
});
|
|
||||||
expect(dollar).toBeInTheDocument();
|
expect(dollar).toBeInTheDocument();
|
||||||
|
|
||||||
const currentBill = screen.getByRole('heading', {
|
const currentBill = screen.getByText('Billing');
|
||||||
name: /current bill total/i,
|
|
||||||
});
|
|
||||||
expect(currentBill).toBeInTheDocument();
|
expect(currentBill).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -61,9 +75,7 @@ describe('BillingContainer', () => {
|
|||||||
const freeTrailText = await screen.findByText('Free Trial');
|
const freeTrailText = await screen.findByText('Free Trial');
|
||||||
expect(freeTrailText).toBeInTheDocument();
|
expect(freeTrailText).toBeInTheDocument();
|
||||||
|
|
||||||
const currentBill = await screen.findByRole('heading', {
|
const currentBill = screen.getByText('Billing');
|
||||||
name: /current bill total/i,
|
|
||||||
});
|
|
||||||
expect(currentBill).toBeInTheDocument();
|
expect(currentBill).toBeInTheDocument();
|
||||||
|
|
||||||
const dollar0 = await screen.findByText(/\$0/i);
|
const dollar0 = await screen.findByText(/\$0/i);
|
||||||
@ -102,9 +114,7 @@ describe('BillingContainer', () => {
|
|||||||
render(<BillingContainer />);
|
render(<BillingContainer />);
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentBill = await screen.findByRole('heading', {
|
const currentBill = screen.getByText('Billing');
|
||||||
name: /current bill total/i,
|
|
||||||
});
|
|
||||||
expect(currentBill).toBeInTheDocument();
|
expect(currentBill).toBeInTheDocument();
|
||||||
|
|
||||||
const dollar0 = await screen.findByText(/\$0/i);
|
const dollar0 = await screen.findByText(/\$0/i);
|
||||||
@ -137,45 +147,30 @@ describe('BillingContainer', () => {
|
|||||||
res(ctx.status(200), ctx.json(notOfTrailResponse)),
|
res(ctx.status(200), ctx.json(notOfTrailResponse)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
render(<BillingContainer />);
|
const { findByText } = render(<BillingContainer />);
|
||||||
|
|
||||||
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
|
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
|
||||||
billingSuccessResponse.data.billingPeriodStart,
|
billingSuccessResponse.data.billingPeriodStart,
|
||||||
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
|
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
|
||||||
|
|
||||||
const billingPeriod = await screen.findByRole('heading', {
|
const billingPeriod = await findByText(billingPeriodText);
|
||||||
name: new RegExp(billingPeriodText, 'i'),
|
|
||||||
});
|
|
||||||
expect(billingPeriod).toBeInTheDocument();
|
expect(billingPeriod).toBeInTheDocument();
|
||||||
|
|
||||||
const currentBill = await screen.findByRole('heading', {
|
const currentBill = screen.getByText('Billing');
|
||||||
name: /current bill total/i,
|
|
||||||
});
|
|
||||||
expect(currentBill).toBeInTheDocument();
|
expect(currentBill).toBeInTheDocument();
|
||||||
|
|
||||||
const dollar0 = await screen.findAllByText(/\$1278.3/i);
|
const dollar0 = await screen.findByText(/\$1,278.3/i);
|
||||||
expect(dollar0[0]).toBeInTheDocument();
|
expect(dollar0).toBeInTheDocument();
|
||||||
expect(dollar0.length).toBe(2);
|
|
||||||
|
|
||||||
const metricsRow = await screen.findByRole('row', {
|
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();
|
expect(metricsRow).toBeInTheDocument();
|
||||||
|
|
||||||
const logRow = await screen.findByRole('row', {
|
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();
|
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 () => {
|
test('Should render corrent day remaining in billing period', async () => {
|
||||||
|
@ -2,11 +2,24 @@
|
|||||||
import './BillingContainer.styles.scss';
|
import './BillingContainer.styles.scss';
|
||||||
|
|
||||||
import { CheckCircleOutlined } from '@ant-design/icons';
|
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 { ColumnsType } from 'antd/es/table';
|
||||||
import updateCreditCardApi from 'api/billing/checkout';
|
import updateCreditCardApi from 'api/billing/checkout';
|
||||||
import getUsage from 'api/billing/getUsage';
|
import getUsage from 'api/billing/getUsage';
|
||||||
import manageCreditCardApi from 'api/billing/manage';
|
import manageCreditCardApi from 'api/billing/manage';
|
||||||
|
import Spinner from 'components/Spinner';
|
||||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
import useAnalytics from 'hooks/analytics/useAnalytics';
|
import useAnalytics from 'hooks/analytics/useAnalytics';
|
||||||
@ -22,8 +35,11 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
|
|||||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||||
import { License } from 'types/api/licenses/def';
|
import { License } from 'types/api/licenses/def';
|
||||||
import AppReducer from 'types/reducer/app';
|
import AppReducer from 'types/reducer/app';
|
||||||
|
import { isCloudUser } from 'utils/app';
|
||||||
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
|
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
|
||||||
|
|
||||||
|
import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph';
|
||||||
|
|
||||||
interface DataType {
|
interface DataType {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -104,12 +120,11 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
const daysRemainingStr = 'days remaining in your billing period.';
|
const daysRemainingStr = 'days remaining in your billing period.';
|
||||||
const [headerText, setHeaderText] = useState('');
|
const [headerText, setHeaderText] = useState('');
|
||||||
const [billAmount, setBillAmount] = useState(0);
|
const [billAmount, setBillAmount] = useState(0);
|
||||||
const [totalBillAmount, setTotalBillAmount] = useState(0);
|
|
||||||
const [activeLicense, setActiveLicense] = useState<License | null>(null);
|
const [activeLicense, setActiveLicense] = useState<License | null>(null);
|
||||||
const [daysRemaining, setDaysRemaining] = useState(0);
|
const [daysRemaining, setDaysRemaining] = useState(0);
|
||||||
const [isFreeTrial, setIsFreeTrial] = useState(false);
|
const [isFreeTrial, setIsFreeTrial] = useState(false);
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const billCurrency = '$';
|
const [apiResponse, setApiResponse] = useState<any>({});
|
||||||
|
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
@ -120,10 +135,12 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
|
|
||||||
const handleError = useAxiosError();
|
const handleError = useAxiosError();
|
||||||
|
|
||||||
|
const isCloudUserVal = isCloudUser();
|
||||||
|
|
||||||
const processUsageData = useCallback(
|
const processUsageData = useCallback(
|
||||||
(data: any): void => {
|
(data: any): void => {
|
||||||
const {
|
const {
|
||||||
details: { breakdown = [], total, billTotal },
|
details: { breakdown = [], billTotal },
|
||||||
billingPeriodStart,
|
billingPeriodStart,
|
||||||
billingPeriodEnd,
|
billingPeriodEnd,
|
||||||
} = data?.payload || {};
|
} = data?.payload || {};
|
||||||
@ -141,8 +158,7 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
formattedUsageData.push({
|
formattedUsageData.push({
|
||||||
key: `${index}${i}`,
|
key: `${index}${i}`,
|
||||||
name: i === 0 ? element?.type : '',
|
name: i === 0 ? element?.type : '',
|
||||||
unit: element?.unit,
|
dataIngested: `${tier.quantity} ${element?.unit}`,
|
||||||
dataIngested: tier.quantity,
|
|
||||||
pricePerUnit: tier.unitPrice,
|
pricePerUnit: tier.unitPrice,
|
||||||
cost: `$ ${tier.tierCost}`,
|
cost: `$ ${tier.tierCost}`,
|
||||||
});
|
});
|
||||||
@ -152,7 +168,6 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setData(formattedUsageData);
|
setData(formattedUsageData);
|
||||||
setTotalBillAmount(total);
|
|
||||||
|
|
||||||
if (!licensesData?.payload?.onTrial) {
|
if (!licensesData?.payload?.onTrial) {
|
||||||
const remainingDays = getRemainingDays(billingPeriodEnd) - 1;
|
const remainingDays = getRemainingDays(billingPeriodEnd) - 1;
|
||||||
@ -165,6 +180,8 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
setDaysRemaining(remainingDays > 0 ? remainingDays : 0);
|
setDaysRemaining(remainingDays > 0 ? remainingDays : 0);
|
||||||
setBillAmount(billTotal);
|
setBillAmount(billTotal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setApiResponse(data?.payload || {});
|
||||||
},
|
},
|
||||||
[licensesData?.payload?.onTrial],
|
[licensesData?.payload?.onTrial],
|
||||||
);
|
);
|
||||||
@ -208,11 +225,6 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
key: 'name',
|
key: 'name',
|
||||||
render: (text): JSX.Element => <div>{text}</div>,
|
render: (text): JSX.Element => <div>{text}</div>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Unit',
|
|
||||||
dataIndex: 'unit',
|
|
||||||
key: 'unit',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Data Ingested',
|
title: 'Data Ingested',
|
||||||
dataIndex: 'dataIngested',
|
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 => (
|
const renderTableSkeleton = (): JSX.Element => (
|
||||||
<Table
|
<Table
|
||||||
dataSource={dummyData}
|
dataSource={dummyData}
|
||||||
@ -336,66 +330,81 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
updateCreditCard,
|
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 (
|
return (
|
||||||
<div className="billing-container">
|
<div className="billing-container">
|
||||||
<Row
|
<Flex vertical style={{ marginBottom: 16 }}>
|
||||||
justify="space-between"
|
<Typography.Text style={{ fontWeight: 500, fontSize: 18 }}>
|
||||||
align="middle"
|
Billing
|
||||||
gutter={[16, 16]}
|
</Typography.Text>
|
||||||
style={{
|
<Typography.Text color={Color.BG_VANILLA_400}>
|
||||||
margin: 0,
|
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}>
|
<Flex justify="space-between" align="center">
|
||||||
<Typography.Title level={4} ellipsis style={{ fontWeight: '300' }}>
|
<Flex vertical>
|
||||||
{headerText}
|
<Typography.Title level={5} style={{ marginTop: 2, fontWeight: 500 }}>
|
||||||
</Typography.Title>
|
{isCloudUserVal ? 'Enterprise Cloud' : 'Enterprise'}{' '}
|
||||||
|
{isFreeTrial ? <Tag color="success"> Free Trial </Tag> : ''}
|
||||||
{licensesData?.payload?.onTrial &&
|
</Typography.Title>
|
||||||
licensesData?.payload?.trialConvertedToSubscription && (
|
<Typography.Text style={{ fontSize: 12, color: Color.BG_VANILLA_400 }}>
|
||||||
<Typography.Title
|
{daysRemaining} {daysRemainingStr}
|
||||||
level={5}
|
</Typography.Text>
|
||||||
ellipsis
|
</Flex>
|
||||||
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' }}>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="middle"
|
size="middle"
|
||||||
loading={isLoadingBilling || isLoadingManageBilling}
|
loading={isLoadingBilling || isLoadingManageBilling}
|
||||||
|
disabled={isLoading}
|
||||||
onClick={handleBilling}
|
onClick={handleBilling}
|
||||||
>
|
>
|
||||||
{isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription
|
{isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription
|
||||||
? 'Upgrade Plan'
|
? 'Upgrade Plan'
|
||||||
: 'Manage Billing'}
|
: 'Manage Billing'}
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Flex>
|
||||||
</Row>
|
|
||||||
|
|
||||||
<div className="billing-summary">
|
{licensesData?.payload?.onTrial &&
|
||||||
<Typography.Title level={4} style={{ margin: '16px 0' }}>
|
licensesData?.payload?.trialConvertedToSubscription && (
|
||||||
Current bill total
|
<Typography.Text
|
||||||
</Typography.Title>
|
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
|
{!isLoading ? (
|
||||||
level={3}
|
<Alert
|
||||||
style={{ margin: '16px 0', display: 'flex', alignItems: 'center' }}
|
message={headerText}
|
||||||
>
|
type="info"
|
||||||
{billCurrency}
|
showIcon
|
||||||
{billAmount}
|
style={{ marginTop: 12 }}
|
||||||
{isFreeTrial ? <Tag color="success"> Free Trial </Tag> : ''}
|
/>
|
||||||
</Typography.Title>
|
) : (
|
||||||
|
<Skeleton.Input active style={{ height: 20, marginTop: 20 }} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Typography.Paragraph style={{ margin: '16px 0' }}>
|
<BillingUsageGraphCallback />
|
||||||
{daysRemaining} {daysRemainingStr}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="billing-details">
|
<div className="billing-details">
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
@ -403,7 +412,7 @@ export default function BillingContainer(): JSX.Element {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
pagination={false}
|
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,
|
idx: number,
|
||||||
yAxisUnit?: string,
|
yAxisUnit?: string,
|
||||||
series?: uPlot.Options['series'],
|
series?: uPlot.Options['series'],
|
||||||
|
isBillingUsageGraphs?: boolean,
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
): HTMLElement => {
|
): HTMLElement => {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
@ -49,12 +50,22 @@ const generateTooltipContent = (
|
|||||||
if (Array.isArray(series) && series.length > 0) {
|
if (Array.isArray(series) && series.length > 0) {
|
||||||
series.forEach((item, index) => {
|
series.forEach((item, index) => {
|
||||||
if (index === 0) {
|
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) {
|
} else if (item.show) {
|
||||||
const { metric = {}, queryName = '', legend = '' } =
|
const {
|
||||||
seriesList[index - 1] || {};
|
metric = {},
|
||||||
|
queryName = '',
|
||||||
|
legend = '',
|
||||||
|
quantity = [],
|
||||||
|
unit = '',
|
||||||
|
} = seriesList[index - 1] || {};
|
||||||
|
|
||||||
const value = data[index][idx];
|
const value = data[index][idx];
|
||||||
|
const dataIngested = quantity[idx];
|
||||||
const label = getLabelName(metric, queryName || '', legend || '');
|
const label = getLabelName(metric, queryName || '', legend || '');
|
||||||
|
|
||||||
const color = generateColor(label, themeColors.chartcolors);
|
const color = generateColor(label, themeColors.chartcolors);
|
||||||
@ -63,6 +74,7 @@ const generateTooltipContent = (
|
|||||||
|
|
||||||
if (Number.isFinite(value)) {
|
if (Number.isFinite(value)) {
|
||||||
const tooltipValue = getToolTipValue(value, yAxisUnit);
|
const tooltipValue = getToolTipValue(value, yAxisUnit);
|
||||||
|
const dataIngestedFormated = getToolTipValue(dataIngested);
|
||||||
if (
|
if (
|
||||||
duplicatedLegendLabels[label] ||
|
duplicatedLegendLabels[label] ||
|
||||||
Object.prototype.hasOwnProperty.call(formattedData, label)
|
Object.prototype.hasOwnProperty.call(formattedData, label)
|
||||||
@ -93,7 +105,9 @@ const generateTooltipContent = (
|
|||||||
value,
|
value,
|
||||||
tooltipValue,
|
tooltipValue,
|
||||||
queryName,
|
queryName,
|
||||||
textContent: `${tooltipItemLabel} : ${tooltipValue}`,
|
textContent: isBillingUsageGraphs
|
||||||
|
? `${tooltipItemLabel} : $${tooltipValue} - ${dataIngestedFormated} ${unit}`
|
||||||
|
: `${tooltipItemLabel} : ${tooltipValue}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
tooltipCount += 1;
|
tooltipCount += 1;
|
||||||
@ -168,6 +182,7 @@ const generateTooltipContent = (
|
|||||||
const tooltipPlugin = (
|
const tooltipPlugin = (
|
||||||
apiResponse: MetricRangePayloadProps | undefined,
|
apiResponse: MetricRangePayloadProps | undefined,
|
||||||
yAxisUnit?: string,
|
yAxisUnit?: string,
|
||||||
|
isBillingUsageGraphs?: boolean,
|
||||||
): any => {
|
): any => {
|
||||||
let over: HTMLElement;
|
let over: HTMLElement;
|
||||||
let bound: HTMLElement;
|
let bound: HTMLElement;
|
||||||
@ -228,6 +243,7 @@ const tooltipPlugin = (
|
|||||||
idx,
|
idx,
|
||||||
yAxisUnit,
|
yAxisUnit,
|
||||||
u.series,
|
u.series,
|
||||||
|
isBillingUsageGraphs,
|
||||||
);
|
);
|
||||||
overlay.appendChild(content);
|
overlay.appendChild(content);
|
||||||
placement(overlay, anchor, 'right', 'start', { bound });
|
placement(overlay, anchor, 'right', 'start', { bound });
|
||||||
|
@ -2,4 +2,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,8 @@ export interface QueryData {
|
|||||||
queryName: string;
|
queryName: string;
|
||||||
legend?: string;
|
legend?: string;
|
||||||
values: [number, string][];
|
values: [number, string][];
|
||||||
|
quantity?: number[];
|
||||||
|
unit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeriesItem {
|
export interface SeriesItem {
|
||||||
@ -28,6 +30,9 @@ export interface QueryDataV3 {
|
|||||||
queryName: string;
|
queryName: string;
|
||||||
legend?: string;
|
legend?: string;
|
||||||
series: SeriesItem[] | null;
|
series: SeriesItem[] | null;
|
||||||
|
quantity?: number;
|
||||||
|
unitPrice?: number;
|
||||||
|
unit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user