mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-04 08:00:26 +01:00
Compare commits
2 Commits
feat/flame
...
billing-gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840914ae58 | ||
|
|
7fb222b6ee |
@@ -3,13 +3,29 @@ import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface DayBreakdownEntry {
|
||||
timestamp: number;
|
||||
total: number;
|
||||
quantity: number;
|
||||
count: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface BreakdownEntry {
|
||||
type: string;
|
||||
unit: string;
|
||||
dayWiseBreakdown: {
|
||||
breakdown: DayBreakdownEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsageResponsePayloadProps {
|
||||
billingPeriodStart: Date;
|
||||
billingPeriodEnd: Date;
|
||||
billingPeriodStart: number;
|
||||
billingPeriodEnd: number;
|
||||
details: {
|
||||
total: number;
|
||||
baseFee: number;
|
||||
breakdown: [];
|
||||
breakdown: BreakdownEntry[];
|
||||
billTotal: number;
|
||||
};
|
||||
discount: number;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.headerRow {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.itemList {
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TooltipHeader from 'lib/uPlotV2/components/Tooltip/components/TooltipHeader/TooltipHeader';
|
||||
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
import {
|
||||
TooltipContentItem,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import TooltipStyles from 'lib/uPlotV2/components/Tooltip/Tooltip.module.scss';
|
||||
import Styles from './BillingBarChartTooltip.module.scss';
|
||||
|
||||
interface BillingBarChartTooltipProps extends TooltipRenderArgs {
|
||||
billingApiResponse: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
export function BillingBarChartTooltip({
|
||||
billingApiResponse,
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
isPinned,
|
||||
}: BillingBarChartTooltipProps): JSX.Element {
|
||||
const content = useMemo((): TooltipContentItem[] => {
|
||||
const baseItems = buildTooltipContent({
|
||||
data: uPlotInstance.data,
|
||||
series: uPlotInstance.series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex: seriesIndex,
|
||||
uPlotInstance,
|
||||
yAxisUnit: '',
|
||||
isStackedBarChart: true,
|
||||
});
|
||||
|
||||
return baseItems.map((item) => {
|
||||
const match = billingApiResponse.data.result.find(
|
||||
(r) => (r.legend || r.queryName) === item.label,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const seriesIdx = uPlotInstance.series.findIndex(
|
||||
(s) => s.label === item.label,
|
||||
);
|
||||
if (seriesIdx === -1) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const dataIndex = dataIndexes[seriesIdx];
|
||||
const quantity = dataIndex != null ? match.quantity?.[dataIndex] : null;
|
||||
const unit = match.unit ?? '';
|
||||
const quantityStr =
|
||||
quantity != null ? ` - ${getToolTipValue(quantity)} ${unit}` : '';
|
||||
|
||||
return {
|
||||
...item,
|
||||
tooltipValue: `$${getToolTipValue(item.value, '')}${quantityStr}`,
|
||||
};
|
||||
});
|
||||
}, [uPlotInstance, seriesIndex, dataIndexes, billingApiResponse]);
|
||||
|
||||
const activeItem = content.find((item) => item.isActive) ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(TooltipStyles.container, {
|
||||
[TooltipStyles.pinned]: isPinned,
|
||||
})}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
<TooltipHeader
|
||||
uPlotInstance={uPlotInstance}
|
||||
showTooltipHeader
|
||||
isPinned={isPinned}
|
||||
activeItem={null}
|
||||
headerRowClassName={Styles.headerRow}
|
||||
dateFormat={DATE_TIME_FORMATS.MONTH_DATE}
|
||||
/>
|
||||
{activeItem != null && <span className={TooltipStyles.divider} />}
|
||||
<div className={Styles.itemList} data-testid="uplot-tooltip-list">
|
||||
{content.map((item) => (
|
||||
<TooltipItem key={item.label} item={item} isItemActive={item.isActive} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
.graph-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.billing-graph-card {
|
||||
.uplot-no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
.uplot-graph-container {
|
||||
@@ -6,7 +16,7 @@
|
||||
}
|
||||
}
|
||||
.total-spent {
|
||||
font-family: 'SF Mono' monospace;
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,206 +1,120 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Card, Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Uplot from 'components/Uplot';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
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 uPlot from 'uplot';
|
||||
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
|
||||
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
|
||||
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
|
||||
import {
|
||||
calculateStartEndTime,
|
||||
convertDataToMetricRangePayload,
|
||||
fillMissingValuesForQuantities,
|
||||
} from './utils';
|
||||
|
||||
import './BillingUsageGraph.styles.scss';
|
||||
import '../../../lib/uPlotLib/uPlotLib.styles.scss';
|
||||
|
||||
interface BillingUsageGraphProps {
|
||||
data: any;
|
||||
data: Partial<UsageResponsePayloadProps>;
|
||||
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);
|
||||
};
|
||||
|
||||
const calculateStartEndTime = (
|
||||
data: any,
|
||||
): { startTime: number; endTime: number } => {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry: any) => {
|
||||
timestamps.push(entry?.timestamp);
|
||||
});
|
||||
});
|
||||
const billingTime = [data?.billingPeriodStart, data?.billingPeriodEnd];
|
||||
const startTime: number = Math.min(...timestamps, ...billingTime);
|
||||
const endTime: number = Math.max(...timestamps, ...billingTime);
|
||||
return { startTime, endTime };
|
||||
};
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
const { data, billAmount } = props;
|
||||
// Added this to fix the issue where breakdown with one day data are causing the bars to spread across multiple days
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length === 1) {
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
breakdown.dayWiseBreakdown.breakdown.push(nextDay);
|
||||
}
|
||||
});
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(data),
|
||||
[data],
|
||||
);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() => calculateStartEndTime(data),
|
||||
[data],
|
||||
);
|
||||
|
||||
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(
|
||||
'#7CEDBE',
|
||||
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, yAxisUnit: '' });
|
||||
|
||||
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({
|
||||
apiResponse: fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0],
|
||||
),
|
||||
yAxisUnit: '',
|
||||
isBillingUsageGraphs: true,
|
||||
isDarkMode,
|
||||
// Single-day data causes bars to span multiple days — add a synthetic
|
||||
// zero-value next-day entry so uPlot renders a correctly-sized single-day bar.
|
||||
const normalizedData = useMemo(() => {
|
||||
if (!data?.details?.breakdown) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
details: {
|
||||
...data.details,
|
||||
breakdown: data.details.breakdown.map((breakdown) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length !== 1) {
|
||||
return breakdown;
|
||||
}
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
return {
|
||||
...breakdown,
|
||||
dayWiseBreakdown: {
|
||||
...breakdown.dayWiseBreakdown,
|
||||
breakdown: [...breakdown.dayWiseBreakdown.breakdown, nextDay],
|
||||
},
|
||||
};
|
||||
}),
|
||||
],
|
||||
}),
|
||||
[
|
||||
axesOptions,
|
||||
chartData,
|
||||
containerDimensions.height,
|
||||
containerDimensions.width,
|
||||
endTime,
|
||||
graphCompatibleData,
|
||||
isDarkMode,
|
||||
startTime,
|
||||
uPlotSeries,
|
||||
],
|
||||
},
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(normalizedData),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
const chartData = useMemo(
|
||||
() => prepareChartData(graphCompatibleData) as uPlot.AlignedData,
|
||||
[graphCompatibleData],
|
||||
);
|
||||
|
||||
const filledApiResponse = useMemo(
|
||||
(): MetricRangePayloadProps =>
|
||||
fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
),
|
||||
[graphCompatibleData, chartData],
|
||||
);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() =>
|
||||
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
// Subtract 86400s (one day) from startTime to add a buffer before first bar
|
||||
minTimeScale: startTime !== undefined ? startTime - 86400 : undefined,
|
||||
maxTimeScale: endTime,
|
||||
apiResponse: graphCompatibleData,
|
||||
}),
|
||||
[isDarkMode, startTime, endTime, graphCompatibleData],
|
||||
);
|
||||
|
||||
const renderBillingTooltip = useCallback(
|
||||
(args: TooltipRenderArgs) => (
|
||||
<BillingBarChartTooltip billingApiResponse={filledApiResponse} {...args} />
|
||||
),
|
||||
[filledApiResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card bordered={false} className="billing-graph-card">
|
||||
@@ -214,8 +128,19 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
|
||||
<Uplot data={chartData} options={optionsForChart} />
|
||||
<div ref={graphRef} className="graph-container">
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
isStackedBarChart
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
customTooltip={renderBillingTooltip}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height - 30}
|
||||
canPinTooltip
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { BillingBarChartTooltip } from '../BillingBarChartTooltip';
|
||||
|
||||
// Mock buildTooltipContent so tests don't depend on uPlot stacking math
|
||||
jest.mock('lib/uPlotV2/components/Tooltip/utils', () => ({
|
||||
buildTooltipContent: jest.fn().mockReturnValue([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 100,
|
||||
tooltipValue: '$100.00',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
{
|
||||
label: 'Traces',
|
||||
value: 50,
|
||||
tooltipValue: '$50.00',
|
||||
color: '#4E74F8',
|
||||
isActive: false,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
function makeUPlotInstance(seriesLabels: string[]): uPlot {
|
||||
return {
|
||||
data: [
|
||||
[1000, 2000],
|
||||
[100, 200],
|
||||
[50, 80],
|
||||
],
|
||||
cursor: { idx: 0 },
|
||||
series: [
|
||||
{ label: 'Timestamp', show: true, stroke: '#000' },
|
||||
...seriesLabels.map((label) => ({
|
||||
label,
|
||||
show: true,
|
||||
stroke: '#aabbcc',
|
||||
})),
|
||||
],
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function makeBillingApiResponse(
|
||||
entries: { legend: string; quantity: (number | null)[]; unit: string }[],
|
||||
): MetricRangePayloadProps {
|
||||
return {
|
||||
data: {
|
||||
result: entries.map((e) => ({
|
||||
legend: e.legend,
|
||||
queryName: e.legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']] as [number, string][],
|
||||
quantity: e.quantity as number[],
|
||||
unit: e.unit,
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseTooltipArgs = {
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
viaSync: false,
|
||||
seriesIndex: 1,
|
||||
dataIndexes: [null, 0, 0],
|
||||
};
|
||||
|
||||
describe('BillingBarChartTooltip', () => {
|
||||
it('augments tooltipValue with quantity and unit for each series', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.5, 2.0], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [500, 800], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/1\.5 GB/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/500 spans/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('omits quantity line when quantity at dataIndex is null', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [null, null], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [null, null], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/null GB/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/null spans/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('uplot-tooltip-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats dollar value via getToolTipValue — strips trailing zeros (0.3076 → $0.3)', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs']);
|
||||
const { buildTooltipContent } = jest.requireMock(
|
||||
'lib/uPlotV2/components/Tooltip/utils',
|
||||
) as { buildTooltipContent: jest.Mock };
|
||||
buildTooltipContent.mockReturnValueOnce([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 0.3076171875,
|
||||
tooltipValue: '$0.31',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.23], unit: 'GB' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/\$0\.3 -/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes through base tooltipValue when series is not in billingApiResponse', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('$100.00').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('$50.00').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { prepareBillingBarConfig } from '../prepareBillingBarConfig';
|
||||
|
||||
const makeApiResponse = (legends: string[]): MetricRangePayloadProps => ({
|
||||
data: {
|
||||
result: legends.map((legend) => ({
|
||||
legend,
|
||||
queryName: legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('prepareBillingBarConfig', () => {
|
||||
const baseProps = { isDarkMode: false };
|
||||
|
||||
it('returns a builder with no series when apiResponse is undefined', () => {
|
||||
const builder = prepareBillingBarConfig(baseProps);
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a builder with no series when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds one series per result entry with correct labels and colors', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(4);
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
expect(config.series?.[2]?.label).toBe('Traces');
|
||||
expect(config.series?.[2]?.stroke).toBe(Color.BG_ROBIN_500);
|
||||
expect(config.series?.[3]?.label).toBe('Metrics');
|
||||
expect(config.series?.[3]?.stroke).toBe(Color.BG_SAKURA_500);
|
||||
});
|
||||
|
||||
it('assigns fallback color (Amber500) for signals beyond the 3-color palette', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['A', 'B', 'C', 'D']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
|
||||
expect(config.focus).toStrictEqual({ alpha: 0.3 });
|
||||
});
|
||||
|
||||
it('sets no bands when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses queryName as label when legend is undefined', () => {
|
||||
const apiResponse: MetricRangePayloadProps = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
legend: undefined as any,
|
||||
queryName: 'Logs',
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
},
|
||||
],
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
const builder = prepareBillingBarConfig({ isDarkMode: false, apiResponse });
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { calculateStartEndTime } from '../utils';
|
||||
|
||||
const makeData = (
|
||||
timestamps: number[],
|
||||
billingPeriodStart?: number,
|
||||
billingPeriodEnd?: number,
|
||||
) => ({
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
details: {
|
||||
total: 0,
|
||||
baseFee: 0,
|
||||
billTotal: 0,
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
dayWiseBreakdown: {
|
||||
breakdown: timestamps.map((timestamp) => ({
|
||||
timestamp,
|
||||
total: 0,
|
||||
quantity: 0,
|
||||
count: 0,
|
||||
size: 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('calculateStartEndTime', () => {
|
||||
it('returns min/max of all breakdown timestamps', () => {
|
||||
const data = makeData([1000, 3000, 2000]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes billingPeriodStart and billingPeriodEnd in the range', () => {
|
||||
const data = makeData([2000, 3000], 500, 4000);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 500,
|
||||
endTime: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when there are no timestamps and no billing period', () => {
|
||||
expect(calculateStartEndTime({})).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when breakdown is empty', () => {
|
||||
const data = makeData([]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out non-finite billingPeriod values', () => {
|
||||
const data = makeData([1000], NaN, Infinity);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('works when details is missing', () => {
|
||||
expect(
|
||||
calculateStartEndTime({ billingPeriodStart: 100, billingPeriodEnd: 200 }),
|
||||
).toStrictEqual({
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
const BILLING_SERIES_COLORS = [
|
||||
Color.BG_FOREST_300,
|
||||
Color.BG_ROBIN_500,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
|
||||
export interface PrepareBillingBarConfigProps {
|
||||
isDarkMode: boolean;
|
||||
timezone?: Timezone;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
export function prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
apiResponse,
|
||||
}: PrepareBillingBarConfigProps): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
id: 'billing-usage-breakdown',
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
const results = apiResponse?.data?.result;
|
||||
if (!results?.length) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
const labels = results.map((s) => s.legend || s.queryName || '');
|
||||
|
||||
const colorMapping = labels.reduce<Record<string, string>>(
|
||||
(acc, label, index) => {
|
||||
acc[label] = BILLING_SERIES_COLORS[index] ?? Color.BG_AMBER_500;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
labels.forEach((label) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping,
|
||||
isDarkMode,
|
||||
metric: {},
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { isEmpty, isNull } from 'lodash-es';
|
||||
import { unparse } from 'papaparse';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -120,11 +120,40 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
|
||||
fileName: string;
|
||||
} {
|
||||
const graphCompatibleData = convertDataToMetricRangePayload(data);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]);
|
||||
const chartData = prepareChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
);
|
||||
|
||||
return {
|
||||
csvData: unparse(generateCsvData(quantityMapArr)),
|
||||
fileName: csvFileName(quantityMapArr),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateStartEndTime(
|
||||
data: Partial<UsageResponsePayloadProps>,
|
||||
): { startTime: number | undefined; endTime: number | undefined } {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
|
||||
timestamps.push(entry.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
const billingTime: number[] = [
|
||||
data?.billingPeriodStart,
|
||||
data?.billingPeriodEnd,
|
||||
].filter((t): t is number => typeof t === 'number' && Number.isFinite(t));
|
||||
|
||||
const allTimes = [...timestamps, ...billingTime];
|
||||
if (allTimes.length === 0) {
|
||||
return { startTime: undefined, endTime: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: Math.min(...allTimes),
|
||||
endTime: Math.max(...allTimes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ interface TooltipHeaderProps {
|
||||
showTooltipHeader: boolean;
|
||||
isPinned: boolean;
|
||||
activeItem: TooltipContentItem | null;
|
||||
headerRowClassName?: string;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export default function TooltipHeader({
|
||||
@@ -26,6 +28,8 @@ export default function TooltipHeader({
|
||||
showTooltipHeader,
|
||||
isPinned,
|
||||
activeItem,
|
||||
headerRowClassName,
|
||||
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
|
||||
}: TooltipHeaderProps): JSX.Element {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
@@ -44,12 +48,13 @@ export default function TooltipHeader({
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
.format(dateFormat);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
dateFormat,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -58,7 +63,7 @@ export default function TooltipHeader({
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={Styles.headerRow}>
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
|
||||
Reference in New Issue
Block a user